diff --git a/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json b/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json index bfc0594..923b4a3 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.26", + "version": "0.7.27", "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 17ce0f5..211223f 100644 --- a/plugins/minimax-ai/minimax-bot/README.md +++ b/plugins/minimax-ai/minimax-bot/README.md @@ -95,6 +95,12 @@ The hand-over between two Bots of a group carried the tail of the previous reply - The approval claim in **Agent turns** mixed the plugin's own behaviour with the Host's. The plugin never approves a call by itself — that part was true and stays true — but whether approval is asked for at all is decided by the Host's permission mode, and a Host that never asks for approval never shows the card. The old sentence stated the first half as if it also settled the second, so it read as a promise about an outcome the plugin does not control. It now names the Host condition, and keeps the plugin's own side: when approval is needed, the plugin never approves it by itself. +## 0.7.27 A request the plugin already accepted is still sendable after a restart + +- MiniMax Code may stop the plugin's Node process while no page is visible, and it does so without a callback. A request that was already accepted and had produced no reply used to come back as waiting for a queue that no longer existed: it stayed in the chat with no answer, the page offered no way to send it again, and the resume route refused it. The request was unreachable for good. +- The page now offers the same **Continue with follow-ups** button for it, and the resume route accepts it, so the request can be sent again. Nothing is replayed on its own: the turn may already have run tools before the process stopped, so sending it again stays the user's decision. +- A request whose reply had already started is unchanged, and a request this page has just sent never offers to resume itself. + ## 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/client/app.js b/plugins/minimax-ai/minimax-bot/miniapp/client/app.js index 3551890..9f03372 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/app.js +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/app.js @@ -792,7 +792,14 @@ function userDelivery(message) { const status = message.dispatchState; if (!status || status === 'processing' || status === 'delivered') return null; const key = `${message.chatId}:${message.id}`; - const button = status === 'held' ? h('button', { + // A restart leaves an accepted request pending with no queue left to send it and nothing running. + // Offer the same resume on the proof the server uses: nothing was ever written back for it. + // dispatchRevision is only written when a delivery state changes, so a request this page just + // sent has none yet and never offers to resume itself. + const stranded = status === 'pending' && message.dispatchRevision > 0 && !state.busy.has(message.chatId) + && !(state.messages.get(message.chatId) ?? []).some( + (item) => item.sourceMessageId === message.id || item.sourceMessageIds?.includes(message.id)); + const button = status === 'held' || stranded ? h('button', { type: 'button', class: 'btn sm', disabled: deliveryActions.has(key), onclick: async (event) => { if (deliveryActions.has(key)) return; diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs index 884b4cc..6c0ed61 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.26' }, + serverInfo: { name: 'minimax-bot', version: '0.7.27' }, instructions: t('mcp.instructions'), }; break; diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/user-inbox.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/user-inbox.mjs index 6170649..f25f44b 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/user-inbox.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/user-inbox.mjs @@ -149,7 +149,15 @@ export class UserInbox { async resumeHeld(chatId) { // Only resend supplements that never started; an uncertain original request is never replayed. const history = await this.engine.store.getMessages(chatId); - const held = history.filter((message) => message.role === 'user' && message.dispatchState === 'held' && !this.has(message.id)); + const held = history.filter((message) => { + if (message.role !== 'user' || this.has(message.id)) return false; + if (message.dispatchState === 'held') return true; + // A restart leaves an accepted request `pending` with the queue that owned it gone. It is + // recoverable on the same proof the retry path uses: no reply and no durable assistant + // placeholder means the request never reached the Host. + return message.dispatchState === 'pending' + && !history.some((item) => item.sourceMessageId === message.id || item.sourceMessageIds?.includes(message.id)); + }); if (!held.length || this.busy(chatId) || [...this.engine.tickets].some((ticket) => ticket.chatId === chatId)) { throw new this.ErrorType('busy', t('err.messageAlreadyProcessing')); } diff --git a/plugins/minimax-ai/minimax-bot/package.json b/plugins/minimax-ai/minimax-bot/package.json index d81662b..d53dd4e 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.26", + "version": "0.7.27", "private": true, "type": "module", "mcode": { diff --git a/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs b/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs index 4063caa..f909b38 100644 --- a/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs +++ b/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs @@ -570,6 +570,22 @@ test('held supplements require an explicit continuation and a failed action leav assert.equal(calls.filter((call) => call.path.endsWith('/resume-pending')).length, 1); }); +test('a request stranded by a restart offers to send it again, and one this page just sent does not', async () => { + // dispatchRevision is written when a delivery state changes, so a recovered request has one and + // a request the composer just created does not. + const stranded = message('msg_stranded', 'Ship the hotfix', { role: 'user', dispatchState: 'pending', dispatchRevision: 1 }); + await reset([stranded]); + const button = document.querySelector('.message-delivery button'); + assert.equal(button.textContent, 'Continue with follow-ups'); + route = ({ path }) => path.endsWith('/resume-pending') ? response({ ok: true }) : undefined; + button.click(); await settle(); + assert.equal(calls.filter((call) => call.path.endsWith('/resume-pending')).length, 1); + + const fresh = message('msg_fresh', 'Just typed', { role: 'user', dispatchState: 'pending' }); + await reset([fresh]); + assert.equal(document.querySelector('.message-delivery button'), null); +}); + test('a delayed send receipt cannot restore an obsolete pending state after the live processing event', async () => { await reset(); const user = message('msg_receipt', 'Request', { role: 'user', dispatchState: 'pending', dispatchRevision: 0 }); diff --git a/plugins/minimax-ai/minimax-bot/tests/engine-reliability.test.mjs b/plugins/minimax-ai/minimax-bot/tests/engine-reliability.test.mjs index 4de0eb3..9f2e312 100644 --- a/plugins/minimax-ai/minimax-bot/tests/engine-reliability.test.mjs +++ b/plugins/minimax-ai/minimax-bot/tests/engine-reliability.test.mjs @@ -648,6 +648,44 @@ test('an interrupted associated assistant is reported as uncertain, and legacy m await restored.writeChain; }); +test('a request whose queue died with the process is still sendable from the page', async (t) => { + const { dir, store, memory, engine } = await fixture(t); + const bot = store.state.bots[0]; + const chatId = `bot:${bot.id}`; + // The process died after the inbox marked the batch processing and before any reply existed. + const user = await store.appendMessage(chatId, { + role: 'user', text: 'Ship the hotfix', clientMessageId: 'orphan-1', + clientMessageHash: engine.userMessageFingerprint('Ship the hotfix', []), dispatchState: 'processing', + }); + await store.writeChain; + + const restored = new Store(dir); + await restored.init(); + let runs = 0; + const reopened = new Engine({ store: restored, memory, logger: { warn() {} }, broadcast() {}, retryDelaysMs: [], agent: { + async run(input, options) { runs += 1; return { ...started(input, options, 'resumed'), status: 'finished', text: 'Shipped' }; }, + } }); + reopened.startScheduler(); + reopened.tasks.start(); + + // The load-time mapping keeps `pending`, which the client retry path needs. The queue that owned + // it is gone, so the resume route has to recognise it too or the request is stranded for good. + const afterRestart = (await restored.getMessages(chatId)).find((message) => message.id === user.id); + assert.equal(afterRestart.dispatchState, 'pending'); + await until(() => idle(reopened)); + // Nothing replays it on its own: the turn may already have run tools before the process died. + assert.equal(runs, 0); + + await reopened.inbox.resume(chatId); + await until(() => idle(reopened)); + assert.equal(runs, 1); + const messages = await restored.getMessages(chatId); + assert.equal(messages.find((message) => message.id === user.id).dispatchState, 'delivered'); + assert.equal(messages.filter((message) => message.sourceMessageId === user.id).length, 1); + reopened.dispose(); + await restored.writeChain; +}); + test('repeated pending submissions do not duplicate queued turns or revive a deliberately cancelled queue', async (t) => { const { store, engine } = await fixture(t); const bot = store.state.bots[0];