From 8ec53d43b53e4939e22748e2199de024ac0ec722 Mon Sep 17 00:00:00 2001 From: EdgeMLHacker <126883848+Apageoflove@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:59:58 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(minimax-bot):=20=E5=85=A8=E8=A7=92?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E6=B3=95=E4=B8=8E=E6=B3=B0=E8=80=81=E6=9F=AC?= =?UTF-8?q?=E7=BC=85=E6=96=87=E7=B4=A7=E8=B4=B4=E7=9D=80=20@=20=E6=97=B6?= =?UTF-8?q?=E4=B9=9F=E7=82=B9=E5=BE=97=E5=88=B0=E4=BA=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../minimax-bot/.minimax-plugin/plugin.json | 2 +- plugins/minimax-ai/minimax-bot/README.md | 4 ++++ .../minimax-ai/minimax-bot/miniapp/node/engine.mjs | 6 ++++-- .../minimax-ai/minimax-bot/miniapp/node/mcp.mjs | 2 +- plugins/minimax-ai/minimax-bot/package.json | 2 +- .../minimax-bot/tests/mention-nonascii.test.mjs | 14 ++++++++++++++ 6 files changed, 25 insertions(+), 5 deletions(-) diff --git a/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json b/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json index 95c15d0..b914bff 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.16", + "version": "0.7.19", "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 8934bda..91b5cc8 100644 --- a/plugins/minimax-ai/minimax-bot/README.md +++ b/plugins/minimax-ai/minimax-bot/README.md @@ -61,6 +61,10 @@ An avatar may come from the avatar library or from an image uploaded in the page - A background job is an Agent turn like any other, so the usage panel counts it. Before, the panel showed fewer turns than the model actually ran: the job's own turn never reached the ledger, only the turn that relayed its report back. - The job's row carries the time it took, the tools it called and the reply it produced, and it says how the job ended: a job that failed is an error, one that was stopped or cut off is aborted. +## 0.7.19 The mention rule, read by the script that writes the word + +0.7.16 started to read the character in front of the `@` as a word of any script, so a letter of a script that writes with spaces (`josé@alice`) makes an address and calls nobody. This release completes that: a full-width IME writes full-width letters and digits (`OK@某人`), and Thai, Lao, Khmer and Myanmar write a word right up against the `@` as Chinese does, so those characters do not swallow the mention either. + ## 0.7.13 A mention that reads like Chinese, and marks that stay out - A mention works right after a Chinese word. The character in front of the `@` used to end the mention whenever it was a letter of any script, and Chinese has no spaces between words, so `请@某人` and `好的@某人` reached nobody. Any letter, digit, underscore or combining mark in front of the `@` ends it now, in every script that writes with spaces: `josé@alice` and `Привет@alice` reach nobody, the way `mail@alice.com` does. Chinese, Japanese and Korean write a word right against the `@`, so their characters do not swallow the mention and `请@某人` still calls that member. A name that runs into more words (`@Alicex`) still is not a mention either, and English is unchanged: `mail@alice.com` and `ok@alice` reach nobody, while `@alice`, `@alice.` and `@Alice Smith` do. diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs index 2f43222..e918e00 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs @@ -1180,9 +1180,11 @@ export class Engine { const isWord = (value) => Boolean(value && /[\p{L}\p{N}\p{M}_]/u.test(value)); // Chinese, Japanese kana and Korean write a word right up against the '@' without a space // ("请@某人"), so a character of those scripts does not belong to an address and must not - // swallow the mention. Any other word character in front of the '@' does belong to one + // swallow the mention. Thai, Lao, Khmer and Myanmar write the same way. The full-width and + // half-width forms are what a full-width IME produces ("OK@某人"), and they stand in the + // same place. Any other word character in front of the '@' does belong to an address // ("mail@alice.com", "josé@alice"). - const isSpaceless = (value) => Boolean(value && /[\p{Script_Extensions=Han}\p{Script_Extensions=Hiragana}\p{Script_Extensions=Katakana}\p{Script_Extensions=Hangul}]/u.test(value)); + const isSpaceless = (value) => Boolean(value && /[\p{Script_Extensions=Han}\p{Script_Extensions=Hiragana}\p{Script_Extensions=Katakana}\p{Script_Extensions=Hangul}\p{Script_Extensions=Thai}\p{Script_Extensions=Lao}\p{Script_Extensions=Khmer}\p{Script_Extensions=Myanmar}\uFF00-\uFFEF]/u.test(value)); const isAddressChar = (value) => isWord(value) && !isSpaceless(value); const found = []; for (let index = lower.indexOf('@'); index >= 0; index = lower.indexOf('@', index + 1)) { diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs index f91de64..be20894 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.16' }, + serverInfo: { name: 'minimax-bot', version: '0.7.19' }, instructions: t('mcp.instructions'), }; break; diff --git a/plugins/minimax-ai/minimax-bot/package.json b/plugins/minimax-ai/minimax-bot/package.json index 0675509..ba8ce2b 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.16", + "version": "0.7.19", "private": true, "type": "module", "mcode": { diff --git a/plugins/minimax-ai/minimax-bot/tests/mention-nonascii.test.mjs b/plugins/minimax-ai/minimax-bot/tests/mention-nonascii.test.mjs index f0668db..3275e39 100644 --- a/plugins/minimax-ai/minimax-bot/tests/mention-nonascii.test.mjs +++ b/plugins/minimax-ai/minimax-bot/tests/mention-nonascii.test.mjs @@ -50,6 +50,20 @@ test('a letter of a script that writes with spaces makes an address, not a menti assert.deepEqual(engine.mentionsIn('josé@alice @bob', group), ['bob']); }); +test('a full-width IME and the scripts that write up against the "@" still reach the member', async (t) => { + const { engine, group } = await fixture(t); + // A full-width IME writes full-width letters and digits. + assert.deepEqual(engine.mentionsIn('OK@alice', group), ['alice']); + assert.deepEqual(engine.mentionsIn('第2@alice', group), ['alice']); + // Thai, Lao, Khmer and Myanmar write a word right up against the "@" as well. + assert.deepEqual(engine.mentionsIn('\u0e02\u0e2d\u0e1a\u0e38\u0e19@alice', group), ['alice']); + assert.deepEqual(engine.mentionsIn('\u0e82\u0eb2\u0e99@alice', group), ['alice']); + assert.deepEqual(engine.mentionsIn('\u17e1\u179c\u17b7@alice', group), ['alice']); + assert.deepEqual(engine.mentionsIn('\u1000\u103b\u102c\u1019\u1038@alice', group), ['alice']); + // A letter of a script that writes with spaces still makes an address. + assert.deepEqual(engine.mentionsIn('josé@alice', group), []); +}); + test('Chinese, Japanese and Korean write up against the "@" and still reach the member', async (t) => { const { engine, group } = await fixture(t); assert.deepEqual(engine.mentionsIn('请@alice', group), ['alice']); From 98c7833070be958bd08eb10fa1ef55ef12426146 Mon Sep 17 00:00:00 2001 From: EdgeMLHacker <126883848+Apageoflove@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:12:36 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(minimax-bot):=20=E5=85=A8=E8=A7=92=20@?= =?UTF-8?q?=20=E4=B9=9F=E8=83=BD=E7=82=B9=E5=88=B0=E4=BA=BA=EF=BC=8C?= =?UTF-8?q?=E6=94=B9=E7=BE=A4=E6=88=90=E5=91=98=E4=B8=8D=E5=86=8D=E9=A1=BA?= =?UTF-8?q?=E5=B8=A6=E6=8D=A2=E6=8E=89=E7=AE=A1=E7=90=86=E5=91=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../minimax-bot/.minimax-plugin/plugin.json | 2 +- plugins/minimax-ai/minimax-bot/README.md | 18 +- .../minimax-bot/miniapp/node/engine.mjs | 45 ++- .../minimax-bot/miniapp/node/mcp.mjs | 2 +- .../miniapp/node/task-dispatcher.mjs | 30 +- .../minimax-bot/miniapp/node/team.mjs | 8 +- plugins/minimax-ai/minimax-bot/package.json | 2 +- .../tests/group-admin-transfer.test.mjs | 90 +++++ .../tests/ledger-retry-duration.test.mjs | 155 ++++++++ .../tests/mention-fullwidth-at.test.mjs | 97 +++++ .../tests/task-shutdown-queue.test.mjs | 354 ++++++++++++++++++ 11 files changed, 783 insertions(+), 20 deletions(-) create mode 100644 plugins/minimax-ai/minimax-bot/tests/group-admin-transfer.test.mjs create mode 100644 plugins/minimax-ai/minimax-bot/tests/ledger-retry-duration.test.mjs create mode 100644 plugins/minimax-ai/minimax-bot/tests/mention-fullwidth-at.test.mjs create mode 100644 plugins/minimax-ai/minimax-bot/tests/task-shutdown-queue.test.mjs diff --git a/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json b/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json index b914bff..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.19", + "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 3f56e1e..88f5a56 100644 --- a/plugins/minimax-ai/minimax-bot/README.md +++ b/plugins/minimax-ai/minimax-bot/README.md @@ -52,16 +52,26 @@ The note section of the group panel reads the note's limits (6 entries and 1200 In the usage view an `aborted` row is a turn that was cut off: it counts as a turn and it shows the characters and tool calls it did make, but it has no duration, so it is left out of the average. A turn that was never metered at all is not on the ledger; the panel's turn count is the number of rows. +## 0.7.19 The mention rule, read by the script that writes the word + +0.7.16 started to read the character in front of the `@` as a word of any script, so a letter of a script that writes with spaces (`josé@alice`) makes an address and calls nobody. This release completes that: a full-width IME writes full-width letters and digits (`OK@某人`), and Thai, Lao, Khmer and Myanmar write a word right up against the `@` as Chinese does, so those characters do not swallow the mention either. + +## 0.7.21 The mention sign, the admin's seat, the queued work and the clock + +The mention rule of the section above ships with this release, and four 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 a Bot may hand a group's admin over; the operator (this plugin's own page and API) still may. 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.18 An unconfirmed outcome still closes its batch An unconfirmed outcome is still never replayed, but it no longer leaves its batch open forever: the batch closes, its report says the outcome is unknown, and it is trimmed like any settled batch. Finished batches no longer pile up in the state file either: the batches that still owe something stay, the newest 30 settled ones stay, and older ones leave a small record without the reply. The reply of a batch that was trimmed is read back from the chat message that holds it, page by page, and a reply whose chat message is gone is reported as truncated instead of silently shortened. Clearing a chat therefore takes the full replies of its older tasks with it: only the trimmed window is left in the task record. Two bounds are worth knowing. The archive keeps at most 1,000 records, so a `request_id` older than that is no longer known and its work would start again. A build older than this one does not read the archive at all, so after a downgrade an archived `request_id` can run again. -## 0.7.19 The mention rule, read by the script that writes the word - -0.7.16 started to read the character in front of the `@` as a word of any script, so a letter of a script that writes with spaces (`josé@alice`) makes an address and calls nobody. This release completes that: a full-width IME writes full-width letters and digits (`OK@某人`), and Thai, Lao, Khmer and Myanmar write a word right up against the `@` as Chinese does, so those characters do not swallow the mention either. - ## 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 cee96f1..f3e4fe5 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 keeps the two facts the ledger needs: how long the model ran, and + * whether it ran at all. `undefined` means the Host was never called (the turn was stopped + * before the call), and a thrown error means the call failed: only the Host reaching the + * turn (`turn.started`) says that the model really ran. + */ + const callModel = async (...args) => { + const callStartedAt = Date.now(); + try { + const raw = await this.invoke(...args); + if (raw !== undefined) modelRan = true; + return raw; + } 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 be20894..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.19' }, + 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 ba8ce2b..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.19", + "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'); +}); From 398178798df4f7d0a73ff2ca49752a8e77f5813a Mon Sep 17 00:00:00 2001 From: EdgeMLHacker <126883848+Apageoflove@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:50:44 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(minimax-bot):=20=E5=85=A8=E8=A7=92=20@?= =?UTF-8?q?=20=E4=B9=9F=E8=83=BD=E7=82=B9=E5=88=B0=E4=BA=BA=EF=BC=8C?= =?UTF-8?q?=E6=94=B9=E7=BE=A4=E6=88=90=E5=91=98=E4=B8=8D=E5=86=8D=E9=A1=BA?= =?UTF-8?q?=E5=B8=A6=E6=8D=A2=E6=8E=89=E7=AE=A1=E7=90=86=E5=91=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/minimax-ai/minimax-bot/README.md | 143 +----------------- .../minimax-bot/miniapp/node/engine.mjs | 14 +- 2 files changed, 8 insertions(+), 149 deletions(-) diff --git a/plugins/minimax-ai/minimax-bot/README.md b/plugins/minimax-ai/minimax-bot/README.md index 48f9c4b..ef2d94d 100644 --- a/plugins/minimax-ai/minimax-bot/README.md +++ b/plugins/minimax-ai/minimax-bot/README.md @@ -70,153 +70,12 @@ A group created before 0.7.8 can hold an admin that is not on its member list. Q 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 a Bot may hand a group's admin over; the operator (this plugin's own page and API) still may. 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. +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. ->>>>>>> slices/pr-next-m3/README.md -## 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. -- The note is kept with the group settings, not with the messages. It is still there after the chat was cleared (**Start new chat**), and a Bot that joins the group later reads it too, so a group that was reset does not start from nothing. -- It is prompt text: the note is written from the replies the round already produced, so it costs no extra Agent turn, no extra tool call and no memory write. -- The note is a record, not an agreement. It says what the Bots said at the end of earlier rounds, and every Bot of the group is told exactly that in the prompt. -- The note is off unless you switch it on in the group settings, and a group without a note gets exactly the prompt it got before. Only you can set it: a Bot cannot switch it on, and the record itself is written by the plugin — a Bot cannot write into it. -- The note holds the most recent rounds and stays within a small size limit. The group settings show what was recorded, and you can clear it there. Switching the note off stops the recording and stops handing the record to the Bots, but what was already recorded stays until you clear it. - -## 0.7.12 The group note, again, and background jobs that count - -- A recorded line keeps its Chinese text. Cutting a line to its closing part used to trim up to the first space, and Chinese has no spaces, so a Chinese entry could lose almost all of itself. The trim now only drops a half Latin word, and never more than 20 characters. -- The protocol marks are matched in any spelling: a tag with spaces, with an attribute of any length, in another case, with a dash that looks like a hyphen, or with a character that takes no space inside it is removed as well. An attribute is read as one — the characters a tag attribute is made of — so a mark's name written inside a sentence does not take the rest of the sentence with it. The text around it is left alone, and Chinese punctuation is not rewritten. -- The group settings state the limits the plugin actually uses. The panel reads them from the server instead of writing down numbers of its own. -- A background job is an Agent turn like any other, so the usage panel counts it. Before, the panel showed fewer turns than the model actually ran: the job's own turn never reached the ledger, only the turn that relayed its report back. -- The job's row carries the time it took, the tools it called and the reply it produced, and it says how the job ended: a job that failed is an error, one that was stopped or cut off is aborted. - -## 0.7.13 A mention that reads like Chinese, and marks that stay out - -- A mention works right after a Chinese word. The character in front of the `@` used to end the mention whenever it was a letter of any script, and Chinese has no spaces between words, so `请@某人` and `好的@某人` reached nobody. Any letter, digit, underscore or combining mark in front of the `@` ends it now, in every script that writes with spaces: `josé@alice` and `Привет@alice` reach nobody, the way `mail@alice.com` does. Chinese, Japanese and Korean write a word right against the `@`, so their characters do not swallow the mention and `请@某人` still calls that member. A name that runs into more words (`@Alicex`) still is not a mention either, and English is unchanged: `mail@alice.com` and `ok@alice` reach nobody, while `@alice`, `@alice.` and `@Alice Smith` do. -- The side after a name stays closed: a character that could still be part of the name keeps the name open. `@某人帮忙` calls nobody, while `@某人 帮忙` and `@某人,帮忙` call 某人. The same boundary is what keeps `@设计师傅` from calling 设计师. Put a space or punctuation after the name when more text follows it. -- A name, a role, a notice, an instruction or a memory entry cannot close a block of the plugin's own. Those travel into the standing brief of every Bot of the group and into the memory block, and a protocol mark in one of them used to close the block early and let the text after it read as if the plugin had written it. A reply in a group travels into the next Bot's prompt the same way, so a mark in it cannot open a block of its own there either. The marks go the way they go in a reply: out of the text, the words around them untouched. -- The brief of a background job gets the same treatment. The Bot's name and role are written into that brief's block, so a protocol mark in either of them used to close it early as well; both are now cleaned the same way before they go in. - -## 0.7.11 The group note, cleaned up - -- Switching the note off no longer hides what was recorded. The group settings keep showing the record and its **Clear** button while the record exists, and say that the switch is off. The record itself is kept until you clear it, so switching the note back on hands the same record to the Bots again. -- The panel shows the same list the Bots read: the cleaned and bounded entries, not whatever the state file happens to hold. -- The protocol marks are now matched case-insensitively, and the marks of the standing-context block join the list of marks a reply may not forge. A reply that ends with such a tag can no longer close the context block early. -- A recorded line is cut on a word boundary, so it no longer starts in the middle of a word. - -## 0.7.9 Optional relay guardrails for groups - -- A group can set a hop limit in the group settings: how many Bots may speak in one relay chain. `0` (the default) means no limit, exactly as before; the largest limit is 20. -- When the limit is reached the chain stops there. The group gets a notice saying so, and the Bot that just spoke is asked to summarise instead of calling the next one. The blocked hand-off does not run, but the wrap-up is a turn of its own: a round that reaches the limit runs `limit + 1` Agent turns, the last one being the wrap-up. -- The limit counts every Bot that speaks in the round. In a group where every member replies (`reply_mode: all`), the first replies of the members already in the queue count towards it. -- A group can also hand the previous conclusion over to the next Bot: the next prompt carries the conclusion of the previous reply, so the next Bot does not have to dig the same ground out of the tools again. The previous reply is listed in the new messages anyway — the hand-over repeats its conclusion on purpose. It is prompt text — no extra turn, no extra tool call, no memory write. -- Both settings belong to the group and only you can set them. A Bot cannot change them: the MCP catalogue does not offer them, and a call that smuggles them in is ignored. - -## 0.7.8 Safer automatic retries - -- A failed turn is still retried automatically at most twice, after 8 s and 20 s. -- A failure marked `retryable: false` is never retried automatically, whether it was thrown or returned, and whatever its message says. -- A turn whose outcome is unknown (`AGENT_OUTCOME_UNKNOWN`) is never resent automatically: it may already have run, and resending it could repeat a reply or an action. - -## 0.7.0 Reliable delegation and result delivery - -Chat with your Bot as usual. It can ask other Bots for help while you keep chatting; completed results return here, and you can stop the work at any time. - -- `message_bot` and `dispatch_tasks` both return durable admission receipts immediately. They use the same asynchronous execution path, without requiring a short/long-task classification. `start_helper` provides an isolated execution context when needed, without a tool-count rule. -- Reuse a stable `request_id` on retry: identical requests from the same caller return the original task; changed content conflicts. Batch dispatch requires the ID. Legacy `message_bot` and `post_group` calls may omit it, but then retries cannot be deduplicated. Task and deduplication records persist in `state.json`. -- Results return to the source chat for verification by the caller Bot. Only task outputs are forwarded, never full private-chat history. There are no custom result destinations, summarizers, user time budgets or separate resume controls. External host chats have no callback and must query `get_tasks`. -- A task that delegates to a helper or child task releases its execution slot and waits for the output. Verification and follow-up work retain the original task ID; an admission acknowledgment is not completion. Cancelling the parent also cancels linked work and verification turns. Existing side effects are not rolled back. -- Different Bots may run concurrently, while each private chat stays serial. Background dispatch uses at most two scheduler slots; chats, tasks and helpers share three Host slots. Queued work can be cancelled, and Host limits still apply. -- Group discussions have no fixed speaking-turn cap. Exact repeated handoffs without tool progress pause the discussion. Continue by sending an ordinary group message or asking the originating Bot to send more instructions; no task ID or resume action is required. `post_group` remains private-chat-only and retains membership checks. -- The task page is an auxiliary view for progress, opening the relevant chat/result, and stopping work. Safe group interruptions preserve the task and its result route. Waiting for local persistence also respects Stop. Pending members are persisted before a questionnaire or verification turn can report completion. -- After a restart, work that never started stays queued. Interrupted executions become `unknown` and are not replayed automatically. Persisted dependency results can be verified, but missing execution outcomes remain unknown. Stable result-message IDs prevent duplicate delivery; unconfirmed verification and summary turns are not automatically repeated. -- MCP SSE sends headers immediately and a keepalive every 15 seconds. Disconnecting does not cancel admitted work; `cancel_tasks` explicitly stops it. `get_tasks` can wait up to 20,000 ms for a state change and paginate full task results. `create_group` uses `name`, not `group`. - -## 0.6.4 Custom avatars and batch import - -- Custom avatars fit their containers in the chat list, chat header, details and settings. -- Import several images at once, reuse them across Bots, and scroll through a large saved library. -- Creating a Bot preserves its selected custom avatar when the preview is rendered. - -Avatars are stored in the Mini App's `dataDir/avatars/`. `GET /api/avatars` lists reusable file names; `POST /api/avatars` accepts one resized image data URL and saves it atomically. Batch import sends one request per file. A Bot's `avatar` accepts a library file name, a data URL or a local image path; `null` / `default` clears its selection without deleting the library file. - -## Follow-ups adjust the current reply - -- While a Bot is replying, just send a follow-up and it adjusts the current task; no extra button is needed. Follow-ups sent while the old reply is winding down are merged in order into the next turn (up to 20 messages and 60,000 characters per batch; larger amounts are split in order). Attachments and original messages are kept individually. Group chats run user turns in order with the same mention rules. -- The follow-up is saved first; then the current reply is cancelled, and once the host confirms that turn has ended, the follow-up is handled in the same session. A request already submitted to the host is never sent again; if cancellation wins before the original request was submitted, its content is included in the next turn. -- This is "interrupt and continue", not a native steer that injects input into a running turn. Actions already taken are not rolled back, and background jobs keep running. -- If cancellation or the outcome cannot be confirmed, the follow-up is held with "Not yet confirmed safe to continue" and is not resent automatically. After checking the full session and answering any pending questionnaire, click "Continue with follow-ups"; this submits only the unprocessed follow-ups and never replays a request whose outcome is unknown. -- Delivery states are saved with each message and synced across windows. After an unexpected restart, a message that was processing with a reply placeholder is marked as uncertain, and a follow-up that was being adjusted is kept for confirmation. - -## Reliability - -- Full history is kept locally. The page loads the latest 200 messages first and can page back through older ones; Copy as Markdown covers the whole history. -- Messages carry stable increasing sequence numbers. A group member only advances past the snapshot that was actually submitted to the host, so messages arriving during its reply are not skipped. -- Sending is de-duplicated per chat, and retries of the same content and attachments reuse the same submission ID. After a crash, only requests that clearly never started are recovered; when a reply placeholder exists but the host outcome is unknown, the request is reported as `delivery_uncertain` and the draft is kept, so tasks with side effects are not run twice. -- Failed turns are retried automatically (after 8 s and 20 s), and a retry button is shown afterwards; if a message never reached the Bot, retry resends it. -- Memory writes use a recoverable transaction covering the entry and its journal; an interrupted write returns `memory_pending` and is completed by a read, retry or restart without double counting. Before the first change to an existing scope, the original file is backed up to `dataDir/memory/backups/`. - -## 0.6.5 Execution records and outcome notices - -- Open **Execution records** from the chat header to inspect tool calls and turn errors in the details drawer. Calls are grouped by reply and tool name, with each failed, successful or unconfirmed attempt retained. Older records can be loaded, group replies identify their Bot, and the full-session link opens that reply's own session. -- Individual tool errors no longer interrupt the conversation or produce toasts. A failed turn keeps one short outcome notice and its safe retry action. Settled replies with unconfirmed memory saves keep one notice, details and memory management. Approvals, questionnaires and message delivery actions remain available where needed. -- Only a receipt for the same scope and memory topic clears its notice. Another successful call with the same tool name does not prove recovery. Historical failures remain in records; the subtle header indicator only reflects each participant's latest reply. -- Bot reply guidance asks for the final delivery impact and any action needed from the user, without narrating recovered intermediate failures. Missing evidence stays unconfirmed, and the UI does not automatically replay side-effecting operations. This guidance is not real-model acceptance evidence. -- Streaming updates retain expanded records, keyboard focus, scroll position, media and input drafts. Narrow windows reuse the existing full-width drawer; close it with its controls or Escape. - -## How it runs - -- The Node service (`miniapp/node/server.mjs`) uses only Node built-in modules, runs Bot turns through `context.agent`, and stores data in the host-assigned `dataDir`. -- The page (`miniapp/client/`) is build-free native ES modules and loads no remote resources. -- The same Node process serves Streamable HTTP MCP at `/mcp/minimax-bot`, exposing `list_team`, `message_bot`, `dispatch_tasks`, `get_tasks`, `cancel_tasks`, `post_group`, `start_helper`, `list_helpers`, `stop_helper`, `remember`, `forget`, `recall`, `create_bot`, `update_bot`, `delete_bot`, `create_group`, `update_group`, `delete_group`, `create_schedule`, `list_schedules` and `delete_schedule` to Bots. -- `miniapp.json` declares `agentAccess: ["session.run"]`. Tool approvals and questionnaires from Bots are rendered in the page, and the full session can be opened in the MiniMax Code sidebar. - -## Tested environment - -- MiniMax Code 3.1.2 on macOS and Windows, and MiniMax Code 3.1.4 on Linux. Earlier versions cannot install this package, because it declares `agentAccess` and `surface.window`. - -## Data & access - -- **Agent turns**: every Bot reply, scheduled task and background job is an Agent turn run through `context.agent`, with the Agent's full capabilities (files, shell, Skills and MCP, including this plugin's own MCP tools). A tool call that needs approval is shown in the page or in the session's sidebar tab; it runs only after you choose **Allow once**. Nothing is approved automatically. A group with a relay limit spends one more Agent turn on the round that reaches the limit: the wrap-up, which replaces the blocked hand-off. A group note spends no turn at all: it is written from the replies the round already produced. Every turn is counted once in the usage panel, background jobs included. -- **Files written**: only inside the Mini App's `dataDir`: `state.json`, `messages/`, `usage.jsonl` (one row per Agent turn), `memory/` (including its journal, archive and backups), `uploads/` (your attachments) and `avatars/`. A group's note is part of that group's record in `state.json`, so it is removed with the group. -- **Files read outside `dataDir`**: - - `/api/media` serves a local file (up to 1 GiB) only when its absolute path appears in a Bot message of that same chat. - - An avatar given as a local image path (by you or by Chief through `update_bot`) is read once and copied into `avatars/`. -- **Processes**: on macOS, runs `id -F` once (1.5 s timeout) to read your account's full name as the default display name. No other processes are started by the plugin itself. -- **Network**: the Node service makes no outbound requests, and the page loads no remote resources. HTML previews may load the HTTP(S) styles and resources that the previewed document references, inside an isolated sandbox. -- **Environment**: reads `LC_ALL`, `LC_MESSAGES` and `LANG` to choose the default language. - -## Tests - -The `tests/` directory holds unit and integration tests that use a mock Agent, isolated temporary data, jsdom and local HTTP/SSE. Run them from this plugin's directory with Node.js 22 or later: - -```bash -npm ci --prefix tests --ignore-scripts --no-audit --no-fund -node --test tests/*.test.mjs -``` - -They do not replace testing in the MiniMax Code desktop app with a real model. - -## Known limitations - -- The host Agent API cannot filter the MCP catalog per conversation. Group context omits the `post_group` usage prompt and explicitly disables it, while the server continues to reject group-origin calls. The tool may still appear in the shared catalog, where its description marks it as private chat only. Existing group sessions receive the updated context on their next `agent.run`. - -- The host does not provide token counts to Mini Apps, so usage is measured in turns and characters. -- `caller_bot_id` in MCP calls is self-reported by the Bot; permission checks are behavioral constraints, not a security boundary. -- The plugin manifest (name, description, example queries) does not support multiple languages, so the marketplace shows English only. - -## Acknowledgements - -The interaction and product design draw on the team, group chat, Chief Bot and memory design of the community project [DeepSeek Bot](https://github.com/FeiZhuLulu/DeepSeek-Bot) (Apache-2.0). This plugin is an independent implementation and contains none of its code. - -## License - -[MIT](./LICENSE) - ## 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 6d73514..6607583 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs @@ -713,17 +713,17 @@ export class Engine { let failureReason = ''; let attempt = 0; /** - * Calls the Host and keeps the two facts the ledger needs: how long the model ran, and - * whether it ran at all. `undefined` means the Host was never called (the turn was stopped - * before the call), and a thrown error means the call failed: only the Host reaching the - * turn (`turn.started`) says that the model really ran. + * 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 { - const raw = await this.invoke(...args); - if (raw !== undefined) modelRan = true; - return raw; + return await this.invoke(...args); } finally { // Only the time the model really ran belongs to the turn's duration. modelMs += Date.now() - callStartedAt;