diff --git a/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json b/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json index 3b34468..a5927be 100644 --- a/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json +++ b/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "name": "minimax-bot", "displayName": "MiniMax Bot", - "version": "0.7.20", + "version": "0.7.21", "description": "Turn MiniMax Code into an always-on team of Bots. A Chief Bot helps you build the team; in group chats the admin answers and @mentions hand work to other members. Every Bot has its own octopus avatar and memory, with scheduled tasks and usage stats.", "author": "MiniMax Code", "icon": "icon.png", diff --git a/plugins/minimax-ai/minimax-bot/README.md b/plugins/minimax-ai/minimax-bot/README.md index 18edd05..ef2d94d 100644 --- a/plugins/minimax-ai/minimax-bot/README.md +++ b/plugins/minimax-ai/minimax-bot/README.md @@ -66,6 +66,16 @@ Two bounds are worth knowing. The archive keeps at most 1,000 records, so a `req A group created before 0.7.8 can hold an admin that is not on its member list. Queueing that admin hands the group a speaker it then skips, so every message without an `@` went quiet: the reply was marked cancelled and nothing explained it. The queue is now built from the member list only — the admin speaks first when it is on the list, the first member takes over when it is not, and in the `all` mode only real members are queued. An `@` still beats every reply mode. +## 0.7.21 The mention sign, the admin's seat, the queued work and the clock + +The mention rule of the section above left one sign out, and three more corners are fixed. A full-width keyboard writes the full-width `@`, which is the same sign as the ASCII `@`: a mention that starts with one now reaches the member like any other (`@某人` calls that member), and a Bot or group name may no longer carry the sign. + +Only the main Bot or the operator may hand a group's admin over. A group admin who patched the member list and left themselves out used to move the admin to the first member left, while the very same change through the group's admin field was refused. Both doors now ask the same question, and the refused one says why. + +A shutdown no longer throws away work that never started. Such a task stays queued and runs again after a restart, as the section on reliable delegation says; before, it was written down as `unknown`, a state that cannot be cancelled, so the work was lost. Work the Host had already taken is not replayed either: it stays `unknown`. A task the user cancelled stays cancelled, a task whose result is already there settles by that result instead of being marked `unknown`, and a turn that ended in a question stays answerable instead of being written off as unknown. + +The usage ledger counts the time the model really ran. A retried turn waits between the attempts, and that wait was counted as the model's time: a turn that waited half a minute between two attempts was written down as a turn of half a minute, and the panel averaged that up. The duration is now the time the model ran and the panel's average follows it; the wait between the attempts is kept beside it in the ledger (`usage.jsonl`), not inside the duration. A turn the model never ran has no duration to report, so it is left out of the average instead of counting as zero seconds. + ## 0.7.10 Optional group notes - A group can keep a note of what it said. After every round the group records the closing lines of the replies, and every Bot of the group gets that record in its standing brief. diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs index b7b19f7..6607583 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs @@ -581,7 +581,6 @@ export class Engine { this.running.set(key, entry); this.emitBusy(chatId); - const startedAt = Date.now(); let lastFlush = 0; let lastPersist = 0; let rootSessionId = this.store.getSession(chatId, bot.id)?.sessionId; @@ -591,6 +590,12 @@ export class Engine { let invoked = false; /** How many times the model was called for this turn, retries included. */ let modelCalls = 0; + /** How long the model really ran: the wait between two attempts is not its time. */ + let modelMs = 0; + /** Whether the model really ran for this turn. A turn it never ran has no duration. */ + let modelRan = false; + /** How long the turn waited between two attempts, the backoff. */ + let sleptMs = 0; let preparedInput = input; let acceptedGroupCursor; /** @type {{ type: 'text', text: string, live?: boolean } | undefined} */ @@ -623,6 +628,8 @@ export class Engine { case 'turn.started': // The Host has the message now: a later retry need not (and must not) send it again. reachedHost = true; + // The model is running for this turn, so it has a duration to report. + modelRan = true; acceptedGroupCursor = preparedInput.groupCursor; rootSessionId = event.sessionId; message.sessionId = event.sessionId; @@ -705,6 +712,23 @@ export class Engine { let failureRetryable; let failureReason = ''; let attempt = 0; + /** + * Calls the Host and times it: the ledger's duration is the time the model ran, and the + * wait between two attempts is not part of it. `undefined` means the Host was never called + * (the turn was stopped before the call), and a thrown error means the call failed. + * Whether the model ran at all is not decided here: a call that returns is not a turn that + * ran — the Host accepts an answer without starting a continuation turn — so only the Host + * reaching the turn (`turn.started`) says that the model really ran. + */ + const callModel = async (...args) => { + const callStartedAt = Date.now(); + try { + return await this.invoke(...args); + } finally { + // Only the time the model really ran belongs to the turn's duration. + modelMs += Date.now() - callStartedAt; + } + }; try { for (;;) { result = undefined; @@ -726,7 +750,7 @@ export class Engine { invoked = true; modelCalls += 1; message.modelCalls = modelCalls; - result = await this.invoke(attemptInput, rootSessionId, controller.signal, onEvent) ?? skippedTurn(); + result = await callModel(attemptInput, rootSessionId, controller.signal, onEvent) ?? skippedTurn(); } catch (error) { if (errorCode(error) === 'SESSION_NOT_FOUND' && !input.answer && attempt === 0) { // The Agent session was deleted; start a fresh one and resend the Bot's context. @@ -735,7 +759,7 @@ export class Engine { preparedInput = await this.prepareGroupInput(input); modelCalls += 1; message.modelCalls = modelCalls; - result = await this.invoke(preparedInput, undefined, controller.signal, onEvent) ?? skippedTurn(); + result = await callModel(preparedInput, undefined, controller.signal, onEvent) ?? skippedTurn(); } catch (retryError) { failure = describeAgentError(retryError); failureCode = errorCode(retryError); @@ -769,7 +793,10 @@ export class Engine { liveText = undefined; message.live = { key: 'live.retrying', params: { s: Math.round(delay / 1000), n: attempt + 1, max: this.retryDelays.length + 1 } }; flush(true); + const sleepStartedAt = Date.now(); const waited = await sleepUnlessAborted(delay, controller.signal); + // The wait between two attempts belongs to the turn, not to the model. + sleptMs += Date.now() - sleepStartedAt; message.live = undefined; if (!waited) { // Stopped while waiting to retry: end as the stop it was, not as the failure before it. @@ -798,7 +825,10 @@ export class Engine { message.text = finalText; message.status = status; message.live = undefined; - message.durationMs = Date.now() - startedAt; + // The ledger says what the model really did: the duration is the time the model ran, not + // the time the turn waited between two attempts. A turn the model never ran has no + // duration to report, so it is left out of the average instead of counting as zero. + message.durationMs = modelRan ? modelMs : null; if (failure) { message.error = failure; // The page localises known codes; `error` keeps the text for anything else. @@ -858,6 +888,8 @@ export class Engine { tools, // One turn, but the model may have been called more than once to finish it. attempts: modelCalls, + // The wait between two attempts is recorded on its own: it is not the model's time. + sleptMs, }); this.broadcast({ type: 'usage.changed' }); } @@ -1197,7 +1229,10 @@ export class Engine { } mentionsIn(text, group, excludeId) { - const lower = String(text ?? '').toLowerCase(); + // A full-width IME writes the full-width "@" (and the small commercial "﹫"), and they stand + // where the ASCII "@" stands. Both are one character wide, so the text can be normalised + // without moving any other character: the offsets below stay the offsets of the original. + const lower = String(text ?? '').toLowerCase().replace(/[@﹫]/g, '@'); const members = group.memberIds.map((id) => this.store.getBot(id)).filter(Boolean) .map((bot) => ({ id: bot.id, name: bot.name.toLowerCase() })) .sort((a, b) => b.name.length - a.name.length); diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs index 864face..7a47310 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs @@ -662,7 +662,7 @@ export function createMcpHandler({ team, engine }) { result = { protocolVersion: PROTOCOL_VERSIONS.includes(requested) ? requested : PROTOCOL_VERSIONS[0], capabilities: { tools: { listChanged: false } }, - serverInfo: { name: 'minimax-bot', version: '0.7.20' }, + serverInfo: { name: 'minimax-bot', version: '0.7.21' }, instructions: t('mcp.instructions'), }; break; diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/task-dispatcher.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/task-dispatcher.mjs index 9e30aed..8fd3339 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/task-dispatcher.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/task-dispatcher.mjs @@ -646,7 +646,12 @@ export class TaskDispatcher { } await this.save(); if (run.controller.signal.aborted || this.disposed) { - task.status = this.disposed ? "unknown" : "cancelled"; + // The turn never reached the Host, so it did no work. A cancelled task stays cancelled; + // a task the dispatcher was disposed for stays queued, so a restart runs it again + // instead of losing it. + task.status = this.disposed && !task.cancelRequested ? "queued" : "cancelled"; + // It never ran, so it has no running time to show either. + if (task.status === "queued") delete task.startedAt; return { status: "aborted", skipped: true, accepted: false, safeToContinue: !this.disposed }; } let result; @@ -756,13 +761,24 @@ export class TaskDispatcher { if (text.length > MAX_TASK_RESULT) task.resultTruncated = true; else delete task.resultTruncated; task.error = result.errorCode ?? result.error; - if ( - this.disposed || - (result.safeToContinue === false && !result.pendingQuestion) - ) + // Whether the Host ever took this turn: `accepted` is true only when the Host has the + // message or the turn produced a result of its own. A turn it did not take did no work, so + // it can be run again; a turn it took has an outcome nobody can observe any more. + const neverStarted = result.accepted === false; + if (this.disposed && neverStarted && !task.cancelRequested) { + // The dispatcher is shutting down and this turn never reached the Host: the work was + // never started, so it stays queued and a restart runs it again. + task.status = "queued"; + // It never ran, so it has no running time to show either. + delete task.startedAt; + } else if (result.safeToContinue === false && !result.pendingQuestion) { + task.status = "unknown"; + } else if (task.cancelRequested && (result.status !== "done" || result.pendingQuestion || this.pendingDependencies(task).length)) { + // The user asked for it to stop: a shutdown does not undo that. + task.status = "cancelled"; + } else if (this.disposed && result.status !== "done") { task.status = "unknown"; - else if (task.cancelRequested && (result.status !== "done" || result.pendingQuestion || this.pendingDependencies(task).length)) task.status = "cancelled"; - else if (result.pendingQuestion) task.status = "waiting_for_user"; + } else if (result.pendingQuestion) task.status = "waiting_for_user"; else if (result.paused) { task.status = "paused"; task.pauseReason = result.paused; diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/team.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/team.mjs index cbb6682..48e51eb 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/team.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/team.mjs @@ -105,7 +105,9 @@ export class Team { const kind = t(`kind.${kindKey}`); if (!value) throw new EngineError('invalid', t('err.nameEmpty', { kind })); if (value.length > 24) throw new EngineError('invalid', t('err.nameTooLong', { kind })); - if (/[@\n\r]/.test(value)) throw new EngineError('invalid', t('err.nameChars', { kind })); + // The full-width "@" and the small commercial "﹫" are the same sign as the ASCII "@", + // so a name must not carry them either: a mention is found by its "@". + if (/[@@﹫\n\r]/.test(value)) throw new EngineError('invalid', t('err.nameChars', { kind })); const list = kindKey === 'bot' ? this.store.state.bots : this.store.state.groups; if (list.some((item) => item.id !== selfId && item.name.toLowerCase() === value.toLowerCase())) { throw new EngineError('invalid', t('err.nameTaken', { name: value, kind, kindKey })); @@ -420,6 +422,10 @@ export class Team { if (patch.admin !== undefined) draft.adminId = this.resolveBot(patch.admin).id; if (!draft.memberIds.includes(draft.adminId)) { if (patch.admin !== undefined) draft.memberIds.unshift(draft.adminId); + // Dropping the admin from the member list moves the admin to the first member left. Only + // the main Bot may hand the admin over: another Bot would change the admin by a side + // door, which is the very thing the check above refuses. + else if (caller && !caller.isMain) throw new EngineError('forbidden', t('err.adminChangeForbidden')); else draft.adminId = draft.memberIds[0]; } Object.assign(group, draft); diff --git a/plugins/minimax-ai/minimax-bot/package.json b/plugins/minimax-ai/minimax-bot/package.json index e215a67..af689e4 100644 --- a/plugins/minimax-ai/minimax-bot/package.json +++ b/plugins/minimax-ai/minimax-bot/package.json @@ -1,6 +1,6 @@ { "name": "minimax-bot", - "version": "0.7.20", + "version": "0.7.21", "private": true, "type": "module", "mcode": { diff --git a/plugins/minimax-ai/minimax-bot/tests/group-admin-transfer.test.mjs b/plugins/minimax-ai/minimax-bot/tests/group-admin-transfer.test.mjs new file mode 100644 index 0000000..438f502 --- /dev/null +++ b/plugins/minimax-ai/minimax-bot/tests/group-admin-transfer.test.mjs @@ -0,0 +1,90 @@ +// @ts-check +// Who may hand a group's admin over. +// +// Only the main Bot may change the admin of a group. The explicit `admin` field is refused for +// every other Bot, but the member list was a second door to the same change: a group admin who +// patched the member list and left themselves out moved the admin to the first member left. +// The rule is one rule, so both doors have to ask the same question. + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { Store } from '../miniapp/node/store.mjs'; +import { MemoryStore } from '../miniapp/node/memory.mjs'; +import { Engine } from '../miniapp/node/engine.mjs'; +import { Team } from '../miniapp/node/team.mjs'; +import { setLocale } from '../miniapp/node/i18n.mjs'; + +async function fixture(t) { + const dir = await mkdtemp(join(tmpdir(), 'bot-admin-transfer-')); + const store = new Store(dir); + const memory = new MemoryStore(dir); + await store.init(); + await memory.init(); + setLocale('en'); + const engine = new Engine({ + store, memory, logger: { warn() {} }, broadcast: () => {}, retryDelaysMs: [], + }); + const team = new Team({ store, memory, engine, broadcast() {} }); + t.after(async () => { + engine.dispose(); + await rm(dir, { recursive: true, force: true }); + }); + store.state.bots = [ + { id: 'chief', name: 'Chief', isMain: true }, + { id: 'alice', name: 'Alice' }, + { id: 'bob', name: 'Bob' }, + { id: 'carol', name: 'Carol' }, + ]; + // Alice is the admin of the group, and she is not the main Bot. + store.state.groups = [{ + id: 'grp_g', name: 'Team', adminId: 'alice', + memberIds: ['alice', 'bob'], replyMode: 'admin', + }]; + await store.save(); + return { store, engine, team }; +} + +test('a group admin cannot hand the admin over by patching the member list', async (t) => { + const { team, store } = await fixture(t); + const alice = store.getBot('alice'); + await assert.rejects( + () => team.updateGroup(alice, 'grp_g', { members: ['bob'] }), + (error) => error.code === 'forbidden' && /admin/i.test(error.message), + ); + // Nothing changed: the same Bot is still the admin and still a member. + const group = store.getGroup('grp_g'); + assert.equal(group.adminId, 'alice'); + assert.deepEqual(group.memberIds, ['alice', 'bob']); +}); + +test('the explicit admin field is refused for the same Bot', async (t) => { + const { team, store } = await fixture(t); + const alice = store.getBot('alice'); + await assert.rejects( + () => team.updateGroup(alice, 'grp_g', { admin: 'bob' }), + (error) => error.code === 'forbidden' && /admin/i.test(error.message), + ); + assert.equal(store.getGroup('grp_g').adminId, 'alice'); +}); + +test('a group admin may still change the member list as long as the admin stays', async (t) => { + const { team, store } = await fixture(t); + const alice = store.getBot('alice'); + await team.updateGroup(alice, 'grp_g', { members: ['alice', 'bob', 'carol'] }); + const group = store.getGroup('grp_g'); + assert.equal(group.adminId, 'alice'); + assert.deepEqual(group.memberIds, ['alice', 'bob', 'carol']); +}); + +test('the main Bot may still move the admin by patching the member list', async (t) => { + const { team, store } = await fixture(t); + const chief = store.getBot('chief'); + await team.updateGroup(chief, 'grp_g', { members: ['bob'] }); + const group = store.getGroup('grp_g'); + assert.equal(group.adminId, 'bob'); + assert.deepEqual(group.memberIds, ['bob']); +}); diff --git a/plugins/minimax-ai/minimax-bot/tests/ledger-retry-duration.test.mjs b/plugins/minimax-ai/minimax-bot/tests/ledger-retry-duration.test.mjs new file mode 100644 index 0000000..4479f23 --- /dev/null +++ b/plugins/minimax-ai/minimax-bot/tests/ledger-retry-duration.test.mjs @@ -0,0 +1,155 @@ +// @ts-check +// What the usage ledger says about a turn that had to be retried. +// +// The ledger records what the model really did. A retried turn waits between the attempts (the +// backoff), and that wait is not the model's time: it used to be counted as such, so a turn +// that waited half a minute between two attempts was written down as a turn of half a minute +// while the model had run for a fraction of a second — and the panel averaged that up. The +// duration is now the time the model really ran, and the wait is written down on its own. + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { createServer } from 'node:net'; + +import { Store } from '../miniapp/node/store.mjs'; +import { MemoryStore } from '../miniapp/node/memory.mjs'; +import { Engine } from '../miniapp/node/engine.mjs'; +import { start } from '../miniapp/node/server.mjs'; +import { setLocale } from '../miniapp/node/i18n.mjs'; + +test('the ledger counts the time the model ran, not the wait between the attempts', async (t) => { + const dir = await mkdtemp(join(tmpdir(), 'bot-ledger-duration-')); + const store = new Store(dir); + const memory = new MemoryStore(dir); + await store.init(); + await memory.init(); + setLocale('en'); + const engine = new Engine({ + store, memory, logger: { warn() {} }, broadcast() {}, retryDelaysMs: [150, 150], + }); + t.after(async () => { + engine.dispose(); + await store.writeChain; + await rm(dir, { recursive: true, force: true }); + }); + let calls = 0; + /** How long the model really ran, measured by the model itself. */ + let modelMs = 0; + engine.agent = { + async run(input, options) { + calls += 1; + if (calls <= 2) { + // A host that is not there yet: the turn is retried after a wait. + throw Object.assign(new Error('Unavailable'), { code: 'AGENT_UNAVAILABLE', retryable: true }); + } + const startedAt = Date.now(); + await new Promise((r) => setTimeout(r, 40)); + modelMs += Date.now() - startedAt; + options.onEvent({ type: 'turn.started', sessionId: 's1' }); + return { sessionId: 's1', status: 'finished', text: 'Done' }; + }, + }; + const bot = store.state.bots[0]; + const wallStartedAt = Date.now(); + await engine.runBotTurn({ bot, chatId: `bot:${bot.id}`, text: 'Hello', kind: 'private' }); + const wallMs = Date.now() - wallStartedAt; + await store.writeChain; + + const rows = await store.readUsage(0); + assert.equal(rows.length, 1); + const row = rows[0]; + // One turn, but the model was called three times. + assert.equal(row.attempts, 3); + // The duration is what the model really ran: it does not carry the two waits of 150 ms. + assert.ok( + row.durationMs >= modelMs - 20, + `duration ${row.durationMs} is shorter than the ${modelMs} ms the model ran`, + ); + assert.ok( + wallMs - row.durationMs >= 250, + `the turn took ${wallMs} ms and the ledger says ${row.durationMs} ms: the wait is still in it`, + ); + // The wait is written down on its own. + assert.ok(row.sleptMs >= 280, `sleptMs ${row.sleptMs} does not cover the two waits`); + + // The panel averages the same number, so it does not show the waiting time either. + const probe = createServer(); + await new Promise((done) => probe.listen(0, '127.0.0.1', done)); + const port = probe.address().port; + await new Promise((done) => probe.close(done)); + const app = await start({ + pluginRoot: resolve(import.meta.dirname, '..'), + dataDir: dir, + listen: { host: '127.0.0.1', port }, + signal: new AbortController().signal, + logger: { info() {}, warn() {}, error() {} }, + }); + t.after(async () => { await app.dispose(); }); + const usage = await (await fetch(`http://127.0.0.1:${port}/api/usage?days=1`)).json(); + assert.equal(usage.totals.turns, 1); + assert.equal(usage.totals.modelCalls, 3, 'the model really ran three times'); + // The panel shows what the ledger says, and that is not the waiting time either. + assert.equal(usage.totals.avgDurationMs, row.durationMs, 'the panel shows what the ledger says'); + assert.ok( + wallMs - usage.totals.avgDurationMs >= 250, + `the panel averages ${usage.totals.avgDurationMs} ms, which is the wait and not the model`, + ); +}); + +test('a turn the model never ran has no duration and stays out of the average', async (t) => { + const dir = await mkdtemp(join(tmpdir(), 'bot-ledger-no-model-')); + const store = new Store(dir); + const memory = new MemoryStore(dir); + await store.init(); + await memory.init(); + setLocale('en'); + const engine = new Engine({ + store, memory, logger: { warn() {} }, broadcast() {}, retryDelaysMs: [], + }); + t.after(async () => { + engine.dispose(); + await store.writeChain; + await rm(dir, { recursive: true, force: true }); + }); + const bot = store.state.bots[0]; + const chatId = `bot:${bot.id}`; + let called = 0; + engine.agent = { async run() { called += 1; return { sessionId: 's1', status: 'finished', text: 'ok' }; } }; + // The turn is stopped after the Bot's message is written but before the model is called. + const appendMessage = store.appendMessage.bind(store); + store.appendMessage = async (chat, message) => { + const written = await appendMessage(chat, message); + if (chat === chatId && message.role === 'bot') engine.cancelChat(chatId); + return written; + }; + await engine.runBotTurn({ bot, chatId, text: 'Hello', kind: 'private' }); + await store.writeChain; + assert.equal(called, 0, 'the model was never called'); + + const rows = await store.readUsage(0); + assert.equal(rows.length, 1); + // The turn is on the ledger, but it has no duration: it never ran the model. + assert.equal(rows[0].durationMs, null); + assert.equal(rows[0].attempts, 0); + + // The panel leaves it out of the average instead of counting it as zero seconds. + const probe = createServer(); + await new Promise((done) => probe.listen(0, '127.0.0.1', done)); + const port = probe.address().port; + await new Promise((done) => probe.close(done)); + const app = await start({ + pluginRoot: resolve(import.meta.dirname, '..'), + dataDir: dir, + listen: { host: '127.0.0.1', port }, + signal: new AbortController().signal, + logger: { info() {}, warn() {}, error() {} }, + }); + t.after(async () => { await app.dispose(); }); + const usage = await (await fetch(`http://127.0.0.1:${port}/api/usage?days=1`)).json(); + assert.equal(usage.totals.turns, 1, 'the turn is still a turn'); + assert.equal(usage.totals.durationSamples, 0, 'a turn without a duration is not averaged'); + assert.equal(usage.totals.avgDurationMs, null, 'the panel does not show a zero-second average'); +}); diff --git a/plugins/minimax-ai/minimax-bot/tests/mention-fullwidth-at.test.mjs b/plugins/minimax-ai/minimax-bot/tests/mention-fullwidth-at.test.mjs new file mode 100644 index 0000000..dffd64b --- /dev/null +++ b/plugins/minimax-ai/minimax-bot/tests/mention-fullwidth-at.test.mjs @@ -0,0 +1,97 @@ +// @ts-check +// The "@" of a mention when the keyboard writes it full-width. +// +// A Chinese IME writes the full-width "@" (U+FF20), and the small commercial "﹫" (U+FE6B) +// is the same sign in a smaller box. Both stand where the ASCII "@" stands, so a mention that +// starts with one has to reach the member like any other. A name may not carry the sign either: +// a mention is found by its "@", so a name with one in it would be unreachable. + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { Store } from '../miniapp/node/store.mjs'; +import { MemoryStore } from '../miniapp/node/memory.mjs'; +import { Engine } from '../miniapp/node/engine.mjs'; +import { Team } from '../miniapp/node/team.mjs'; +import { setLocale } from '../miniapp/node/i18n.mjs'; + +async function fixture(t) { + const dir = await mkdtemp(join(tmpdir(), 'bot-mention-wide-')); + const store = new Store(dir); + const memory = new MemoryStore(dir); + await store.init(); + await memory.init(); + setLocale('en'); + const engine = new Engine({ + store, memory, logger: { warn() {} }, broadcast: () => {}, retryDelaysMs: [], + }); + const team = new Team({ store, memory, engine, broadcast() {} }); + t.after(async () => { + engine.dispose(); + await rm(dir, { recursive: true, force: true }); + }); + store.state.bots = [ + { id: 'chief', name: 'Chief', isMain: true }, + { id: 'designer', name: '设计师' }, + { id: 'alice', name: 'Alice' }, + { id: 'bob', name: 'Bob' }, + ]; + const group = { + id: 'grp_g', name: 'Team', adminId: 'bob', + memberIds: ['designer', 'alice', 'bob'], replyMode: 'admin', + }; + store.state.groups = [group]; + await store.save(); + return { store, engine, team, group }; +} + +test('the full-width "@" reaches the member like the ASCII "@"', async (t) => { + const { engine, group } = await fixture(t); + // What a Chinese IME types: the full-width sign and a Chinese name. + assert.deepEqual(engine.mentionsIn('@设计师 帮忙', group), ['designer']); + assert.deepEqual(engine.groupQueue(group, '@设计师 帮忙'), ['designer']); + // The small commercial sign of the same kind. + assert.deepEqual(engine.mentionsIn('﹫alice hi', group), ['alice']); + // The sign may stand right against a word, as the ASCII one does. + assert.deepEqual(engine.mentionsIn('请@设计师', group), ['designer']); + assert.deepEqual(engine.mentionsIn('界@alice', group), ['alice']); + // Two mentions in one text are both found. + assert.deepEqual(engine.mentionsIn('@设计师 帮忙 @alice', group), ['designer', 'alice']); + // The ASCII form still works the same way. + assert.deepEqual(engine.mentionsIn('@设计师 帮忙', group), ['designer']); +}); + +test('the address forms stay silent with the full-width "@"', async (t) => { + const { engine, group } = await fixture(t); + assert.deepEqual(engine.mentionsIn('mail@alice.com', group), []); + assert.deepEqual(engine.mentionsIn('josé@alice', group), []); + assert.deepEqual(engine.mentionsIn('@alicex', group), []); + assert.deepEqual(engine.mentionsIn('1@alice', group), []); +}); + +test('a name may not carry the full-width "@"', async (t) => { + const { team, store } = await fixture(t); + const chief = store.getBot('chief'); + await assert.rejects( + () => team.createBot(chief, { name: '@Bot' }), + (error) => error.code === 'invalid' && /@/.test(error.message), + ); + await assert.rejects( + () => team.createBot(chief, { name: 'Bo﹫t' }), + (error) => error.code === 'invalid' && /@/.test(error.message), + ); + await assert.rejects( + () => team.createGroup(chief, { name: '@Team', members: ['alice'] }), + (error) => error.code === 'invalid' && /@/.test(error.message), + ); + // The ASCII sign was already refused, and a plain name still works. + await assert.rejects( + () => team.createBot(chief, { name: 'Bad@Name' }), + (error) => error.code === 'invalid', + ); + const bot = await team.createBot(chief, { name: 'Carol' }); + assert.equal(bot.name, 'Carol'); +}); diff --git a/plugins/minimax-ai/minimax-bot/tests/task-shutdown-queue.test.mjs b/plugins/minimax-ai/minimax-bot/tests/task-shutdown-queue.test.mjs new file mode 100644 index 0000000..baccc3a --- /dev/null +++ b/plugins/minimax-ai/minimax-bot/tests/task-shutdown-queue.test.mjs @@ -0,0 +1,354 @@ +// @ts-check +// What a graceful shutdown does to the work that is still held. +// +// The README promises that work that never started stays queued and runs again after a +// restart. A shutdown used to write `unknown` for every task it was holding, even for the ones +// whose turn never reached the Host: those did no work at all, so they were lost — and +// `unknown` cannot be cancelled either, so the batch could never be cleaned up. A task whose +// result is already there settles by that result instead of being thrown away. + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { mkdtemp, rm, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { Store } from '../miniapp/node/store.mjs'; +import { MemoryStore } from '../miniapp/node/memory.mjs'; +import { Engine } from '../miniapp/node/engine.mjs'; +import { setLocale } from '../miniapp/node/i18n.mjs'; + +const deferred = () => { + let resolve; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +}; + +async function until(predicate, label = 'condition', tries = 1600) { + for (let i = 0; i < tries; i++) { + if (await predicate()) return; + await new Promise((r) => setTimeout(r, 5)); + } + throw new Error(`Timed out waiting for ${label}`); +} + +const idle = (engine) => + !engine.tasks.runs.size && !engine.tasks.deliveries.size && !engine.tickets.size; + +function agentResult(input, options, text = 'Complete') { + const ids = { + sessionId: input.sessionId ?? `session-${String(input.text).slice(-16)}`, + turnId: 'turn', + }; + options.onEvent({ type: 'turn.started', ...ids }); + return { ...ids, status: 'finished', text }; +} + +async function fixture(t, { retryDelaysMs = [] } = {}) { + const dir = await mkdtemp(join(tmpdir(), 'bot-shutdown-queue-')); + const store = new Store(dir); + await store.init(); + const memory = new MemoryStore(dir); + await memory.init(); + setLocale('en'); + store.state.bots = [ + { id: 'bot_a', name: 'Alice', isMain: true }, + { id: 'bot_b', name: 'Bob' }, + { id: 'bot_c', name: 'Carol' }, + { id: 'bot_d', name: 'Dave' }, + { id: 'bot_e', name: 'Eve' }, + ]; + await store.save(); + const engine = new Engine({ + store, memory, logger: { warn() {} }, broadcast() {}, retryDelaysMs, + }); + t.after(async () => { + engine.dispose(); + await until(() => idle(engine), 'idle after dispose'); + await store.writeChain; + await rm(dir, { recursive: true, force: true }); + }); + return { dir, store, memory, engine }; +} + +test('a task whose turn never started stays queued and runs again after a restart', async (t) => { + const { dir, store, memory, engine } = await fixture(t); + const gate = deferred(); + const calls = []; + engine.agent = { + async run(input, options) { + calls.push(String(input.text)); + const result = agentResult(input, options, 'blocked-turn'); + if (String(input.text).includes('BLOCK')) await gate.promise; + return result; + }, + }; + // Fill every top-level slot, so the tasks below cannot start. + const blockers = ['bot_a', 'bot_b', 'bot_c'].map((id) => + engine.runBotTurn({ bot: store.getBot(id), chatId: `bot:${id}`, kind: 'private', text: 'BLOCK' })); + await until(() => engine.activeSlots === 3, 'slots saturated'); + const receipt = await engine.tasks.dispatch(undefined, { + request_id: 'never-started', + tasks: [ + { to: 'bot_d', message: 'TASK-D' }, + { to: 'bot_e', message: 'TASK-E' }, + ], + }); + await until(() => engine.tasks.tasks.every((task) => task.status === 'running'), 'tasks admitted'); + // Wait until the tasks' turns are really with the engine (they wait for a free slot there). + await until(() => engine.tickets.size >= 5, 'task turns admitted'); + // The Host never saw the work: the tasks were still waiting for a slot. + assert.equal(calls.filter((text) => /TASK-D|TASK-E/.test(text)).length, 0); + + engine.dispose(); + gate.resolve(); + await Promise.allSettled(blockers); + await until(() => idle(engine), 'idle after dispose'); + await store.writeChain; + + // The work never started, so it stays queued: in memory and on disk. + assert.deepEqual(engine.tasks.tasks.map((task) => task.status), ['queued', 'queued']); + // It never ran, so it has no running time to show either. + assert.deepEqual(engine.tasks.tasks.map((task) => task.startedAt), [undefined, undefined]); + const onDisk = JSON.parse(await readFile(store.statePath, 'utf8')); + const batch = onDisk.taskBatches.find((item) => item.id === receipt.batch_id); + assert.deepEqual(batch.tasks.map((task) => task.status), ['queued', 'queued']); + + // Restart: the queued work runs again, and it runs to the end this time. + let resumed = 0; + const recoveredStore = new Store(dir); + await recoveredStore.init(); + const recovered = new Engine({ + store: recoveredStore, memory, logger: { warn() {} }, broadcast() {}, retryDelaysMs: [], + agent: { + async run(input, options) { + if (/TASK-D|TASK-E/.test(String(input.text))) resumed += 1; + return agentResult(input, options, 'resumed'); + }, + }, + }); + t.after(async () => { + recovered.dispose(); + await until(() => idle(recovered), 'idle recovered dispose'); + await recoveredStore.writeChain; + }); + recovered.tasks.start(); + await until( + () => recovered.tasks.tasks.every((task) => task.status === 'done') && idle(recovered), + 'tasks resumed', + ); + assert.equal(resumed, 2); + assert.deepEqual( + recovered.tasks.tasks.map((task) => task.result), + ['resumed', 'resumed'], + ); +}); + +test('a task whose result is already there settles by that result', async (t) => { + const { dir, store, memory, engine } = await fixture(t); + engine.agent = { + async run(input, options) { + const result = agentResult(input, options, 'COMPLETED_TEXT'); + if (String(input.text).endsWith('Finish and stop')) engine.dispose(); + return result; + }, + }; + const receipt = await engine.tasks.dispatch(undefined, { + request_id: 'finish-stop', + tasks: [{ to: 'bot_b', message: 'Finish and stop' }], + }); + await until(() => idle(engine), 'idle'); + await store.writeChain; + const task = engine.tasks.task(receipt.tasks[0].task_id); + // The answer is known, so the task is done: a shutdown does not throw it away. + assert.equal(task.status, 'done'); + assert.equal(task.result, 'COMPLETED_TEXT'); + + // After a restart the finished task is still finished, with its answer. + const recoveredStore = new Store(dir); + await recoveredStore.init(); + const recovered = new Engine({ + store: recoveredStore, memory, logger: { warn() {} }, broadcast() {}, retryDelaysMs: [], + agent: { async run(input, options) { return agentResult(input, options, 'SUMMARY'); } }, + }); + t.after(async () => { + recovered.dispose(); + await until(() => idle(recovered), 'idle recovered dispose'); + await recoveredStore.writeChain; + }); + recovered.tasks.start(); + await until(() => idle(recovered), 'idle recovered'); + const recoveredTask = recovered.tasks.task(receipt.tasks[0].task_id); + assert.equal(recoveredTask.status, 'done'); + assert.equal(recoveredTask.result, 'COMPLETED_TEXT'); +}); + +test('a turn the Host had already taken is not put back in the queue', async (t) => { + const { dir, store, memory, engine } = await fixture(t, { retryDelaysMs: [30] }); + engine.agent = { + async run(input, options) { + // The Host has the message now, and then the host goes away: the turn is retried. + options.onEvent({ type: 'turn.started', sessionId: 's1' }); + throw Object.assign(new Error('Unavailable'), { code: 'AGENT_UNAVAILABLE', retryable: true }); + }, + }; + // The retry is stopped while the brief is built: the Host had the message already, so the + // outcome of that turn is nobody's to observe. + const buildBrief = engine.buildMemoryBrief.bind(engine); + let briefs = 0; + engine.buildMemoryBrief = async (...args) => { + briefs += 1; + const brief = await buildBrief(...args); + if (briefs === 2) engine.dispose(); + return brief; + }; + const receipt = await engine.tasks.dispatch(undefined, { + request_id: 'host-took-it', + tasks: [{ to: 'bot_b', message: 'WORK' }], + }); + await until(() => idle(engine), 'idle'); + await store.writeChain; + const task = engine.tasks.task(receipt.tasks[0].task_id); + // The Host took it, so the outcome is unknown and the work is not replayed. + assert.equal(task.status, 'unknown'); + + // After a restart it stays unknown and the model is not called again. + let replayed = 0; + const recoveredStore = new Store(dir); + await recoveredStore.init(); + const recovered = new Engine({ + store: recoveredStore, memory, logger: { warn() {} }, broadcast() {}, retryDelaysMs: [], + agent: { + async run(input, options) { + replayed += 1; + return agentResult(input, options, 'REPLAYED'); + }, + }, + }); + t.after(async () => { + recovered.dispose(); + await until(() => idle(recovered), 'idle recovered dispose'); + await recoveredStore.writeChain; + }); + recovered.tasks.start(); + await until(() => idle(recovered), 'idle recovered'); + assert.equal(replayed, 0, 'the work the Host had taken must not be replayed'); + assert.equal(recovered.tasks.task(receipt.tasks[0].task_id).status, 'unknown'); +}); + +test('a turn that ended in a question stays answerable after a shutdown', async (t) => { + const { dir, store, memory, engine } = await fixture(t); + engine.agent = { + async run(input, options) { + const ids = { sessionId: 's1', turnId: 't1' }; + options.onEvent({ type: 'turn.started', ...ids }); + // The Host asks the user a question, and the app shuts down right after. + engine.dispose(); + return { + ...ids, + status: 'finished', + text: 'Choose', + pendingQuestion: { + requestId: 'q1', + mode: 'single', + request: { prompt: 'pick', steps: [{ id: 's1', question: 'pick?' }] }, + }, + }; + }, + }; + const receipt = await engine.tasks.dispatch(undefined, { + request_id: 'questionnaire', + tasks: [{ to: 'bot_b', message: 'QUESTION' }], + }); + await until(() => idle(engine), 'idle'); + await store.writeChain; + const task = engine.tasks.task(receipt.tasks[0].task_id); + // The question is in the chat and the user can still answer it: the task waits for the user + // instead of being written off as an unknown outcome. + assert.equal(task.status, 'waiting_for_user'); + + // After a restart it is still waiting for the answer, and the model is not called again. + let replayed = 0; + const recoveredStore = new Store(dir); + await recoveredStore.init(); + const recovered = new Engine({ + store: recoveredStore, memory, logger: { warn() {} }, broadcast() {}, retryDelaysMs: [], + agent: { + async run(input, options) { + replayed += 1; + return agentResult(input, options, 'REPLAYED'); + }, + }, + }); + t.after(async () => { + recovered.dispose(); + await until(() => idle(recovered), 'idle recovered dispose'); + await recoveredStore.writeChain; + }); + recovered.tasks.start(); + await until(() => idle(recovered), 'idle recovered'); + assert.equal(replayed, 0, 'a task waiting for an answer must not run again'); + assert.equal(recovered.tasks.task(receipt.tasks[0].task_id).status, 'waiting_for_user'); +}); + +test('a task the user cancelled stays cancelled, even when the app shuts down', async (t) => { + const { dir, store, memory, engine } = await fixture(t); + const gate = deferred(); + const calls = []; + engine.agent = { + async run(input, options) { + calls.push(String(input.text)); + const result = agentResult(input, options, 'blocked-turn'); + if (String(input.text).includes('BLOCK')) await gate.promise; + return result; + }, + }; + // Fill every top-level slot, so the task below cannot start. + const blockers = ['bot_a', 'bot_b', 'bot_c'].map((id) => + engine.runBotTurn({ bot: store.getBot(id), chatId: `bot:${id}`, kind: 'private', text: 'BLOCK' })); + await until(() => engine.activeSlots === 3, 'slots saturated'); + const receipt = await engine.tasks.dispatch(undefined, { + request_id: 'cancelled-by-user', + tasks: [{ to: 'bot_d', message: 'TASK-CANCEL' }], + }); + await until(() => engine.tasks.tasks.every((task) => task.status === 'running'), 'task admitted'); + // Wait until the task's turn is really with the engine (it waits for a free slot there). + await until(() => engine.tickets.size >= 4, 'task turn admitted'); + // The user stops it while it is still waiting for a slot, and the app shuts down right + // after: the cancellation must outlive the shutdown. + const cancelling = engine.tasks.cancel(undefined, { batch_id: receipt.batch_id }); + engine.dispose(); + await cancelling; + gate.resolve(); + await Promise.allSettled(blockers); + await until(() => idle(engine), 'idle after dispose'); + await store.writeChain; + + const task = engine.tasks.task(receipt.tasks[0].task_id); + assert.equal(task.status, 'cancelled', 'a shutdown does not undo the cancellation'); + + // After a restart the cancelled task is not run again. + let replayed = 0; + const recoveredStore = new Store(dir); + await recoveredStore.init(); + const recovered = new Engine({ + store: recoveredStore, memory, logger: { warn() {} }, broadcast() {}, retryDelaysMs: [], + agent: { + async run(input, options) { + replayed += 1; + return agentResult(input, options, 'REPLAYED'); + }, + }, + }); + t.after(async () => { + recovered.dispose(); + await until(() => idle(recovered), 'idle recovered dispose'); + await recoveredStore.writeChain; + }); + recovered.tasks.start(); + await until(() => idle(recovered), 'idle recovered'); + assert.equal(replayed, 0, 'a cancelled task must not run again'); + assert.equal(recovered.tasks.task(receipt.tasks[0].task_id).status, 'cancelled'); +});