Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions plugins/minimax-ai/minimax-bot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 8 additions & 1 deletion plugins/minimax-ai/minimax-bot/miniapp/client/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 9 additions & 1 deletion plugins/minimax-ai/minimax-bot/miniapp/node/user-inbox.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
}
Expand Down
2 changes: 1 addition & 1 deletion plugins/minimax-ai/minimax-bot/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "minimax-bot",
"version": "0.7.26",
"version": "0.7.27",
"private": true,
"type": "module",
"mcode": {
Expand Down
16 changes: 16 additions & 0 deletions plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
38 changes: 38 additions & 0 deletions plugins/minimax-ai/minimax-bot/tests/engine-reliability.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
Loading