From ad5a58b123eb205056f18fda5ad15c4a3fb3e5ee Mon Sep 17 00:00:00 2001 From: EdgeMLHacker <126883848+Apageoflove@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:13:20 -0700 Subject: [PATCH 1/2] =?UTF-8?q?fix(minimax-bot):=20=E7=BE=A4=E7=BA=AA?= =?UTF-8?q?=E8=A6=81=E7=9A=84=E8=AE=B0=E5=BD=95=E5=8F=AF=E8=A7=81=E6=80=A7?= =?UTF-8?q?=E3=80=81=E4=B8=8A=E4=B8=8B=E6=96=87=E6=A0=87=E8=AE=B0=E9=98=B2?= =?UTF-8?q?=E4=BC=AA=E4=B8=8E=E9=9D=A2=E6=9D=BF=E4=B8=80=E8=87=B4=E6=80=A7?= 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 | 9 +- .../minimax-bot/miniapp/client/panels.js | 5 +- .../miniapp/client/strings-panels.js | 6 +- .../minimax-bot/miniapp/node/engine.mjs | 6 +- .../minimax-bot/miniapp/node/group-note.mjs | 24 +++- .../minimax-bot/miniapp/node/mcp.mjs | 2 +- .../minimax-bot/miniapp/node/relay-guard.mjs | 28 ++++- .../minimax-bot/miniapp/node/server.mjs | 7 +- .../minimax-bot/miniapp/node/team.mjs | 3 +- plugins/minimax-ai/minimax-bot/package.json | 2 +- .../minimax-bot/tests/group-note.test.mjs | 112 +++++++++++++++++- 12 files changed, 187 insertions(+), 19 deletions(-) diff --git a/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json b/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json index b5ad84a9..89632147 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.10", + "version": "0.7.11", "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 ef044f7f..669513f0 100644 --- a/plugins/minimax-ai/minimax-bot/README.md +++ b/plugins/minimax-ai/minimax-bot/README.md @@ -37,7 +37,14 @@ Copy this directory, including the hidden `.minimax-plugin/` directory, into the - 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. +- 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.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 diff --git a/plugins/minimax-ai/minimax-bot/miniapp/client/panels.js b/plugins/minimax-ai/minimax-bot/miniapp/client/panels.js index 0d29198d..8bfff37d 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/panels.js +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/panels.js @@ -339,9 +339,12 @@ function groupOverview(group) { h('span', null, t('panels.group.notes')), ), h('p', { class: 'hint', style: { marginTop: '8px' } }, t('panels.group.notesHint')), - ...(group.notes === true && Array.isArray(group.noteEntries) && group.noteEntries.length + // The record stays visible while it exists, even with the switch off: what was recorded + // has to be readable and clearable, otherwise turning the switch off would hide it. + ...(Array.isArray(group.noteEntries) && group.noteEntries.length ? [ h('p', { class: 'hint', style: { marginTop: '8px' } }, t('panels.group.notesList')), + ...(group.notes === true ? [] : [h('p', { class: 'hint' }, t('panels.group.notesKept'))]), ...group.noteEntries.map((entry) => clipped(`[${entry.speaker}] ${entry.text}`)), h( 'button', diff --git a/plugins/minimax-ai/minimax-bot/miniapp/client/strings-panels.js b/plugins/minimax-ai/minimax-bot/miniapp/client/strings-panels.js index fe229833..3e960648 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/strings-panels.js +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/strings-panels.js @@ -182,8 +182,9 @@ export default { 'panels.group.handoverHint': '下一位的提示词里会带上上一位回复的结论(就是它的尾部),省得下一位再翻一遍工具。上一位的原话本来就在「新消息」里,这里是再强调一遍结论。只多一段提示词,不多花回合。', 'panels.group.notesHeading': '群纪要', 'panels.group.notes': '把本群说过的话记下来', - 'panels.group.notesHint': '每轮结束后,群会把各 Bot 回复的结尾记成一份纪要,写在群资料里:清空聊天记录(新建对话)之后纪要还在,新加入的 Bot 也能看到。纪要只是记录,不代表群里已经达成共识。只多一段提示词,不多花回合。', + 'panels.group.notesHint': '每轮结束后,群会把各 Bot 回复的结尾记成一份纪要,写在群资料里:清空聊天记录(新建对话)之后纪要还在,新加入的 Bot 也能看到。纪要只留最近几轮(最多 6 条 / 1200 字),更早的会掉出去;关掉开关只是不再记、也不再给 Bot 看,记下的内容会保留到你清空为止。纪要只是记录,不代表群里已经达成共识。只多一段提示词,不多花回合。', 'panels.group.notesList': '已记录的纪要:', + 'panels.group.notesKept': '开关关着,这份纪要保留着,但不再记录、也不再给 Bot 看。', 'panels.group.notesClear': '清空纪要', 'panels.group.settingsKept': '成员设置保留', 'panels.group.needMember': '至少选一个成员', @@ -543,8 +544,9 @@ export default { 'panels.group.handoverHint': "The next Bot's prompt carries the conclusion of the previous reply (its tail), so the next Bot does not have to dig the same ground out of the tools again. The previous reply is in the new messages anyway — this repeats the conclusion on purpose. It is prompt text only — no extra turn.", 'panels.group.notesHeading': 'Group notes', 'panels.group.notes': 'Keep a record of what this group said', - 'panels.group.notesHint': 'After every round the group records the closing lines of the replies as a note, kept with the group settings: the note is still there after the chat was cleared (Start new chat), and a Bot that joins later sees it too. A note is a record, not an agreement the group reached. It is prompt text only — no extra turn.', + 'panels.group.notesHint': 'After every round the group records the closing lines of the replies as a note, kept with the group settings: the note is still there after the chat was cleared (Start new chat), and a Bot that joins later sees it too. Only the last rounds are kept (6 entries and 1200 characters at most); older ones drop off. Switching the note off stops the recording and stops handing it to the Bots, but what was recorded stays until you clear it. A note is a record, not an agreement the group reached. It is prompt text only — no extra turn.', 'panels.group.notesList': 'Recorded so far:', + 'panels.group.notesKept': 'The switch is off, so this record is kept but neither added to nor read by the Bots.', 'panels.group.notesClear': 'Clear the note', 'panels.group.settingsKept': 'Member settings are kept', 'panels.group.needMember': 'Pick at least one member', diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs index f62f33b1..85b00b54 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs @@ -9,7 +9,7 @@ import { selectMemoryContext } from './memory-context.mjs'; import { UserInbox } from './user-inbox.mjs'; import { TaskDispatcher } from './task-dispatcher.mjs'; import { modelKey, newId, withEffort } from './store.mjs'; -import { CONVERGE_MARK, handoverRequested, handoverSummary, hopLimit, renderHandover } from './relay-guard.mjs'; +import { CONVERGE_MARK, CONTEXT_BLOCK_END, CONTEXT_BLOCK_MARK, handoverRequested, handoverSummary, hopLimit, renderHandover } from './relay-guard.mjs'; import { appendNote, notesEnabled, renderNote } from './group-note.mjs'; /** @typedef {import('./miniapp-api.js').MiniAppAgentClient} MiniAppAgentClient */ @@ -393,7 +393,7 @@ export class Engine { // the group keeps one, so the brief of a group without notes stays byte-identical. const note = group ? renderNote(group) : ''; const lines = [ - '', + CONTEXT_BLOCK_MARK, t('brief.intro', { name: bot.name, id: bot.id }), bot.role ? t('brief.role', { role: bot.role }) : '', bot.isMain ? t('brief.mainPower') : '', @@ -442,7 +442,7 @@ export class Engine { '', t('brief.hReply'), t('brief.reply'), - '', + CONTEXT_BLOCK_END, ]; return lines.filter((line, index, all) => !(line === '' && all[index - 1] === '')).join('\n'); } diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/group-note.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/group-note.mjs index 68e86ef2..27d21d49 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/group-note.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/group-note.mjs @@ -68,7 +68,29 @@ function cleanEntry(entry) { const speaker = neutralizeMarks(String(entry.speaker ?? '')).replace(/\s+/g, ' ').trim(); const text = neutralizeMarks(String(entry.text ?? '')).replace(/\s+/g, ' ').trim(); if (!speaker || !text) return undefined; - return { speaker: speaker.slice(0, NOTE_SPEAKER_MAX), text: text.slice(-NOTE_ENTRY_MAX) }; + return { speaker: speaker.slice(0, NOTE_SPEAKER_MAX), text: tailOf(text, NOTE_ENTRY_MAX) }; +} + +/** + * The closing part of a text, cut on a word boundary. + * + * A plain cut can land inside a word — or inside the half of a tag the neutralising left + * behind — and the entry would start mid-word. When the cut lands inside a word, the partial + * word is dropped, so the entry starts where a word starts. + * + * @param {string} text + * @param {number} [max] + * @returns {string} + */ +export function tailOf(text, max = NOTE_ENTRY_MAX) { + const value = String(text ?? ''); + if (value.length <= max) return value; + const cut = value.length - max; + const tail = value.slice(cut); + // The character before the cut tells whether the cut landed inside a word. + if (!/\S/.test(value[cut - 1] ?? '')) return tail; + const trimmed = tail.replace(/^\S*\s+/, ''); + return trimmed || tail; } /** diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs index 6a64fd0c..0da6842f 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs @@ -586,7 +586,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.10' }, + serverInfo: { name: 'minimax-bot', version: '0.7.11' }, instructions: t('mcp.instructions'), }; break; diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/relay-guard.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/relay-guard.mjs index 2e0ee555..cb2e082b 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/relay-guard.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/relay-guard.mjs @@ -29,6 +29,15 @@ export const HANDOVER_BLOCK_MARK = ''; export const HANDOVER_BLOCK_END = ''; /** Marks the prompt that asks the current speaker to wrap up. */ export const CONVERGE_MARK = ''; +/** + * Opens the block that carries the Bot's standing context. + * + * The context block wraps the whole brief, so a reply that ends with its closing tag would + * close the block early and make the rest of the text look like it sits outside the context. + */ +export const CONTEXT_BLOCK_MARK = ''; +/** Closes the block that carries the Bot's standing context. */ +export const CONTEXT_BLOCK_END = ''; /** Longest summary handed over to the next hop. */ export const HANDOVER_MAX = 600; /** How much of the previous reply's tail is kept when the group keeps no note. */ @@ -42,6 +51,8 @@ const FORGED_MARKS = [ HANDOVER_BLOCK_MARK, HANDOVER_BLOCK_END, CONVERGE_MARK, + CONTEXT_BLOCK_MARK, + CONTEXT_BLOCK_END, '', '', '', @@ -82,6 +93,21 @@ export function handoverRequested(group) { return group?.handover === true; } +/** + * A protocol mark is a literal, so the pattern has to escape whatever it contains. + * @param {string} text + * @returns {string} + */ +function escapeRegExp(text) { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * The marks are matched case-insensitively: a tag in another case is still a tag, and a Host + * that reads the marks case-insensitively must not be handed one. + */ +const FORGED_PATTERNS = FORGED_MARKS.map((mark) => new RegExp(escapeRegExp(mark), 'gi')); + /** * Removes the protocol marks from a text, so a reply can never pretend to open or close a * block of its own. @@ -90,7 +116,7 @@ export function handoverRequested(group) { */ export function neutralizeMarks(text) { let out = String(text ?? ''); - for (const mark of FORGED_MARKS) out = out.split(mark).join(' '); + for (const pattern of FORGED_PATTERNS) out = out.replace(pattern, ' '); return out; } diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/server.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/server.mjs index 9e27e240..cc125b5c 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/server.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/server.mjs @@ -8,6 +8,7 @@ import { extname, isAbsolute, join } from 'node:path'; import { createHash } from 'node:crypto'; import { Engine, EngineError, UPLOAD_NAME_RE } from './engine.mjs'; +import { noteEntries } from './group-note.mjs'; import { createMcpHandler } from './mcp.mjs'; import { LANGUAGE_PREFS, locale, matchLocale, t } from './i18n.mjs'; import { memoryBudget, MemoryError, MemoryStore } from './memory.mjs'; @@ -138,7 +139,11 @@ export async function start(context) { const snapshot = () => ({ bots: store.state.bots, - groups: store.state.groups, + // A group's note is shown in the panel exactly as the Bots get it: the cleaned and bounded + // list, not whatever the state file happens to hold. + groups: store.state.groups.map((group) => ( + Array.isArray(group.noteEntries) ? { ...group, noteEntries: noteEntries(group) } : group + )), schedules: store.state.schedules, helpers: engine.helpersList(), tasks: engine.tasks.uiList(), diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/team.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/team.mjs index 366e43eb..3be81eaa 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/team.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/team.mjs @@ -374,7 +374,8 @@ export class Team { if (patch.maxHops !== undefined) draft.maxHops = hopLimit({ maxHops: patch.maxHops }); if (patch.handover !== undefined) draft.handover = Boolean(patch.handover); // The group's note: the switch and the record are both the operator's. Switching it off - // stops the record and the hand-over; clearing it drops what was recorded. + // stops the recording and stops handing the record to the Bots; the record itself stays + // until it is cleared. (The relay hand-over has its own switch, this one does not touch it.) if (patch.notes !== undefined) draft.notes = Boolean(patch.notes); if (patch.clearNotes === true) draft.noteEntries = []; if (patch.members !== undefined) { diff --git a/plugins/minimax-ai/minimax-bot/package.json b/plugins/minimax-ai/minimax-bot/package.json index ffeefa00..759b3485 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.10", + "version": "0.7.11", "private": true, "type": "module", "mcode": { diff --git a/plugins/minimax-ai/minimax-bot/tests/group-note.test.mjs b/plugins/minimax-ai/minimax-bot/tests/group-note.test.mjs index 81afa5a5..53e75c0f 100644 --- a/plugins/minimax-ai/minimax-bot/tests/group-note.test.mjs +++ b/plugins/minimax-ai/minimax-bot/tests/group-note.test.mjs @@ -10,20 +10,24 @@ // is the whole point of it; // 4. a group without a note gets exactly the prompt it got before: the brief does not grow // by a byte, in either language; -// 5. a reply can never forge the note block; -// 6. the model cannot switch the note on, and cannot write into it. +// 5. a reply can never forge the note block, and never the standing-context block either; +// 6. the model cannot switch the note on, and cannot write into it; +// 7. what the panel shows is what the Bots get: one cleaned, bounded list; +// 8. switching the note off keeps the record, and the record is still clearable. import assert from 'node:assert/strict'; import { test } from 'node:test'; import { mkdtemp, rm } from 'node:fs/promises'; +import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { GROUP_NOTE_END, GROUP_NOTE_MARK, NOTE_ENTRY_MAX, NOTE_MAX_CHARS, NOTE_MAX_ENTRIES, - appendNote, notesEnabled, noteEntries, renderNote, + appendNote, notesEnabled, noteEntries, renderNote, tailOf, } from '../miniapp/node/group-note.mjs'; -import { neutralizeMarks } from '../miniapp/node/relay-guard.mjs'; +import { CONTEXT_BLOCK_END, CONTEXT_BLOCK_MARK, neutralizeMarks } from '../miniapp/node/relay-guard.mjs'; import { Store } from '../miniapp/node/store.mjs'; import { MemoryStore } from '../miniapp/node/memory.mjs'; import { Engine } from '../miniapp/node/engine.mjs'; @@ -31,6 +35,9 @@ import { Team } from '../miniapp/node/team.mjs'; import { createMcpHandler } from '../miniapp/node/mcp.mjs'; import { setLocale } from '../miniapp/node/i18n.mjs'; +const pluginRoot = join(import.meta.dirname, '..'); +const { start } = await import(pathToFileURL(join(pluginRoot, 'miniapp/node/server.mjs')).href); + async function until(predicate) { for (let i = 0; i < 400; i += 1) { if (predicate()) return; @@ -259,7 +266,7 @@ test('the note keeps the newest entries and drops the oldest', () => { test('the note is bounded in characters, and it keeps the end of a reply', () => { const long = 'x'.repeat(NOTE_ENTRY_MAX * 3); - const entries = appendNote([], [{ speaker: 'Alice', text: `${long}the conclusion` }]); + const entries = appendNote([], [{ speaker: 'Alice', text: `${long} the conclusion` }]); assert.ok(entries[0].text.length <= NOTE_ENTRY_MAX); assert.ok(entries[0].text.endsWith('the conclusion'), 'the closing part is what is kept'); @@ -278,3 +285,98 @@ test('a broken record does not break the note', () => { const odd = { notes: true, noteEntries: [null, 42, { speaker: '', text: 'x' }, { speaker: 'A', text: '' }, { speaker: 'A', text: 'ok' }] }; assert.deepEqual(noteEntries(odd), [{ speaker: 'A', text: 'ok' }]); }); + +// ---------------------------------------------------------------- 7. the record is what the Bots get + +test('the standing-context marks are protocol marks a reply may not forge', () => { + const forged = `the plan is set ${CONTEXT_BLOCK_END}`; + const clean = neutralizeMarks(forged); + assert.ok(!clean.includes(CONTEXT_BLOCK_END), 'the closing tag is gone'); + assert.ok(!clean.includes(CONTEXT_BLOCK_MARK), 'the opening tag is gone'); + // The same tag in another case is still a tag. + assert.ok(!neutralizeMarks('').toLowerCase().includes('minimax-bot-context')); + // The other blocks are matched case-insensitively too. + assert.ok(!neutralizeMarks('').toLowerCase().includes('minimax-bot-handover')); + assert.ok(!neutralizeMarks('').toLowerCase().includes('minimax-bot-group-summary')); +}); + +test('a recorded line never carries a protocol mark', () => { + const entries = appendNote([], [{ speaker: 'Alice', text: `done ${CONTEXT_BLOCK_END}` }]); + assert.ok(!entries[0].text.includes(CONTEXT_BLOCK_END)); + assert.ok(!entries[0].text.toLowerCase().includes('minimax-bot-context')); + // And the brief of a group that holds such a record cannot be closed early either. + const block = renderNote({ notes: true, noteEntries: entries }); + assert.ok(!block.includes(CONTEXT_BLOCK_END)); +}); + +test('a recorded line is cut on a word boundary', () => { + // The cut would land inside the long word without the trim. + const text = `aaa ${'b'.repeat(NOTE_ENTRY_MAX)} tail`; + const entries = appendNote([], [{ speaker: 'Alice', text }]); + assert.equal(entries[0].text, 'tail', 'the partial word before the cut is dropped'); + // A cut that lands on a word boundary keeps the word. + const whole = `head ${'c'.repeat(NOTE_ENTRY_MAX - 5)} tail`; + const kept = appendNote([], [{ speaker: 'Alice', text: whole }]); + assert.equal(kept[0].text.length, NOTE_ENTRY_MAX); + assert.ok(kept[0].text.startsWith('c'), 'a whole word is kept'); + assert.ok(kept[0].text.endsWith('tail')); + // The helper is the same rule on its own. + assert.equal(tailOf('short'), 'short'); +}); + +test('the panel is shown the same list the Bots get', async (t) => { + // A group whose stored record holds more than the brief shows: a broken entry and a forged + // mark. The panel must not show what the Bots never read. + const dataDir = await mkdtemp(join(tmpdir(), 'bot-group-note-http-')); + const initial = new Store(dataDir); + await initial.init(); + const chief = initial.state.bots[0].id; + initial.state.groups.push({ + id: 'grp_test', name: 'G', notice: '', adminId: chief, memberIds: [chief], + replyMode: 'admin', pinned: false, createdAt: 1, notes: true, + noteEntries: [ + { speaker: 'Alice', text: 'we go with B' }, + { speaker: '', text: 'no speaker' }, + { speaker: 'Bob', text: `x ${CONTEXT_BLOCK_END}` }, + ], + }); + await initial.save(); + const probe = createServer(); + await new Promise((resolve, reject) => { probe.once('error', reject); probe.listen(0, '127.0.0.1', resolve); }); + const port = probe.address().port; + await new Promise((resolve, reject) => probe.close((error) => error ? reject(error) : resolve())); + const app = await start({ + pluginRoot, dataDir, listen: { host: '127.0.0.1', port }, + signal: new AbortController().signal, logger: { info() {}, warn() {}, error() {} }, + }); + t.after(async () => { await app.dispose(); await rm(dataDir, { recursive: true, force: true }); }); + const { groups } = await (await fetch(`http://127.0.0.1:${port}/api/state`)).json(); + const group = groups.find((g) => g.id === 'grp_test'); + assert.equal(group.noteEntries.length, 2, 'the broken entry is dropped'); + assert.equal(group.noteEntries[0].text, 'we go with B'); + assert.ok(!group.noteEntries[1].text.includes(CONTEXT_BLOCK_END), 'the forged mark is neutralized'); +}); + +// ---------------------------------------------------------------- 8. the record stays visible + +test('switching the note off keeps the record, and turning it on hands it over again', async (t) => { + const { engine, store, team, chief, group } = await groupWith(t, { notes: true }); + await engine.sendUserMessage(`group:${group.id}`, 'What do we do?', [], 'note-switch'); + await until(() => idle(engine)); + assert.equal(store.getGroup(group.id).noteEntries.length, 1); + + await team.updateGroup(chief, group.id, { notes: false }); + assert.equal(store.getGroup(group.id).noteEntries.length, 1, 'the record is kept when the switch goes off'); + assert.ok( + !engine.buildStandingBrief(chief, `group:${group.id}`).includes(GROUP_NOTE_MARK), + 'and it is not handed over while the switch is off', + ); + // Switching it on hands the same record over again. + await team.updateGroup(chief, group.id, { notes: true }); + const brief = engine.buildStandingBrief(chief, `group:${group.id}`); + assert.ok(brief.includes(GROUP_NOTE_MARK), 'the record comes back with the switch'); + assert.ok(brief.includes('ship on Friday')); + // And the operator can still drop it. + await team.updateGroup(chief, group.id, { clearNotes: true }); + assert.deepEqual(store.getGroup(group.id).noteEntries, []); +}); From fcd373fb7ddae9bca2561e5b63f67ac5dc40662a Mon Sep 17 00:00:00 2001 From: EdgeMLHacker <126883848+Apageoflove@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:22:30 -0700 Subject: [PATCH 2/2] =?UTF-8?q?test(minimax-bot):=20=E7=BE=A4=E7=BA=AA?= =?UTF-8?q?=E8=A6=81=E7=9A=84=E8=AE=B0=E5=BD=95=E5=9C=A8=E5=BC=80=E5=85=B3?= =?UTF-8?q?=E5=85=B3=E7=9D=80=E6=97=B6=E4=BB=8D=E7=84=B6=E7=9C=8B=E5=BE=97?= =?UTF-8?q?=E8=A7=81=E3=80=81=E4=B9=9F=E6=B8=85=E5=BE=97=E6=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/panels-reliability.test.mjs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/plugins/minimax-ai/minimax-bot/tests/panels-reliability.test.mjs b/plugins/minimax-ai/minimax-bot/tests/panels-reliability.test.mjs index 92cad87f..fdb4a348 100644 --- a/plugins/minimax-ai/minimax-bot/tests/panels-reliability.test.mjs +++ b/plugins/minimax-ai/minimax-bot/tests/panels-reliability.test.mjs @@ -298,3 +298,30 @@ test('durable task cards distinguish paused, unconfirmed and waiting states with assert.match(document.querySelector('.task-list').textContent,/Research/); closeOverlay();state.tasks=[]; }); + +test('the group note record stays visible and clearable while the switch is off', async () => { + requests = []; + state.groups = [{ + id: 'g1', name: 'Team', notice: '', adminId: 'b1', memberIds: ['b1'], replyMode: 'admin', + notes: false, noteEntries: [{ speaker: 'Rob', text: 'we go with plan B' }], + }]; + state.current = 'group:g1'; + state.drawer = { open: true, view: 'overview', memTab: 'self', dirty: false }; + renderDrawer(); await tick(); + const drawer = document.getElementById('drawer'); + // The record is shown even though the switch is off: what was recorded has to stay readable. + assert.match(drawer.textContent, /we go with plan B/); + assert.match(drawer.textContent, /neither added to nor read by the Bots/); + // And it can still be cleared. + const clear = [...drawer.querySelectorAll('button')].find((button) => button.textContent.includes('Clear the note')); + assert.ok(clear, 'the record can still be cleared'); + clear.click(); await tick(); + const cleared = requests.find((r) => r.url.includes('/api/groups/g1') && r.method === 'PATCH'); + assert.ok(cleared, 'clearing goes to the group API'); + assert.deepEqual(cleared.body, { clearNotes: true }); + // With the switch on, the panel does not claim the record is kept. + state.groups[0].notes = true; + renderDrawer(); await tick(); + assert.doesNotMatch(document.getElementById('drawer').textContent, /neither added to nor read/); + state.groups = []; state.current = 'bot:b1'; +});