diff --git a/plans/CSM.md b/plans/CSM.md index 748d467..ac42cb6 100644 --- a/plans/CSM.md +++ b/plans/CSM.md @@ -475,6 +475,8 @@ moderator bot 在新 channel 中发布 kickoff message。 ### 12.2 展开讨论 参与 Agent 在该 channel 中按现有顺序讨论机制发言。 +讨论 channel 的 participant 集合继续复用现有 channel member bootstrap 逻辑:由 `plugin/core/turn-bootstrap.ts` 调用 `fetchVisibleChannelBotAccountIds(...)` 基于 Discord 可见成员与已有 account 映射发现可参与的 bot account,再交给 `initTurnOrder(...)` 建立轮转状态,而不是为 discussion 模式额外维护一套成员发现流程。 + 如果轮转一圈无人发言,则 moderator 提醒发起者: - 若已达成目标,请写总结文档并 callback diff --git a/plans/TASKLIST.md b/plans/TASKLIST.md index 08b8166..7d1c018 100644 --- a/plans/TASKLIST.md +++ b/plans/TASKLIST.md @@ -136,7 +136,7 @@ - [x] 梳理 initiator identity 的可获取路径 - [x] 确认 callback 时如何稳定识别 initiator account/session - [x] 确认 discussion channel 创建后 turn order 是否需立即 bootstrap -- [ ] 确认 discussion participant 的成员集合获取方式是否可直接复用现有逻辑 +- [x] 确认 discussion participant 的成员集合获取方式是否可直接复用现有逻辑 ### A9. `plugin/index.ts` - [x] 注入新增 discussion metadata/service 模块依赖 @@ -238,8 +238,8 @@ #### A13.3 turn / hook 测试 - [x] 测试 discussion channel 空转后发送 idle reminder -- [ ] 测试普通 channel 空转逻辑不受影响 -- [ ] 测试 callback 成功后 discussion channel 不再 handoff +- [x] 测试普通 channel 空转逻辑不受影响 +- [x] 测试 callback 成功后 discussion channel 不再 handoff - [x] 测试 closed discussion channel 新消息不会继续唤醒 Agent #### A13.4 路径校验测试 diff --git a/plugin/channel-resolver.js b/plugin/channel-resolver.js new file mode 100644 index 0000000..9445e26 --- /dev/null +++ b/plugin/channel-resolver.js @@ -0,0 +1 @@ +export * from './channel-resolver.ts'; diff --git a/plugin/rules.js b/plugin/rules.js new file mode 100644 index 0000000..c7e15e6 --- /dev/null +++ b/plugin/rules.js @@ -0,0 +1 @@ +export * from './rules.ts'; diff --git a/plugin/turn-manager.js b/plugin/turn-manager.js new file mode 100644 index 0000000..5a9f704 --- /dev/null +++ b/plugin/turn-manager.js @@ -0,0 +1 @@ +export * from './turn-manager.ts'; diff --git a/test/discussion-hooks.test.ts b/test/discussion-hooks.test.ts new file mode 100644 index 0000000..8b67395 --- /dev/null +++ b/test/discussion-hooks.test.ts @@ -0,0 +1,131 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { registerBeforeMessageWriteHook } from '../plugin/hooks/before-message-write.ts'; +import { registerMessageSentHook } from '../plugin/hooks/message-sent.ts'; +import { initTurnOrder, onNewMessage, getTurnDebugInfo, resetTurn } from '../plugin/turn-manager.ts'; + +type Handler = (event: Record, ctx: Record) => unknown; + +function makeApi() { + const handlers = new Map(); + return { + handlers, + logger: { + info: (_msg: string) => {}, + warn: (_msg: string) => {}, + }, + on(name: string, handler: Handler) { + handlers.set(name, handler); + }, + }; +} + +test('before_message_write leaves ordinary channels dormant without sending a discussion idle reminder', async () => { + const channelId = 'normal-channel'; + resetTurn(channelId); + initTurnOrder(channelId, ['agent-a', 'agent-b']); + onNewMessage(channelId, 'human-user', true); + + const state = getTurnDebugInfo(channelId); + const [firstSpeaker, secondSpeaker] = state.turnOrder as string[]; + assert.ok(firstSpeaker); + assert.ok(secondSpeaker); + + const sessionChannelId = new Map([ + ['sess-a', channelId], + ['sess-b', channelId], + ]); + const sessionAccountId = new Map([ + ['sess-a', firstSpeaker], + ['sess-b', secondSpeaker], + ]); + const sessionAllowed = new Map([ + ['sess-a', true], + ['sess-b', true], + ]); + const sessionTurnHandled = new Set(); + + const idleReminderCalls: string[] = []; + const moderatorMessages: string[] = []; + const api = makeApi(); + + registerBeforeMessageWriteHook({ + api: api as any, + baseConfig: { endSymbols: ['🔚'], moderatorBotToken: 'bot-token' } as any, + policyState: { channelPolicies: {} }, + sessionAllowed, + sessionChannelId, + sessionAccountId, + sessionTurnHandled, + ensurePolicyStateLoaded: () => {}, + shouldDebugLog: () => false, + ensureTurnOrder: () => {}, + resolveDiscordUserId: () => undefined, + isMultiMessageMode: () => false, + sendModeratorMessage: async (_token, _channelId, content) => { + moderatorMessages.push(content); + return { ok: true }; + }, + discussionService: { + maybeSendIdleReminder: async (id) => { + idleReminderCalls.push(id); + }, + getDiscussion: () => undefined, + }, + }); + + const beforeMessageWrite = api.handlers.get('before_message_write'); + assert.ok(beforeMessageWrite); + + await beforeMessageWrite?.({ message: { role: 'assistant', content: 'NO_REPLY' } }, { sessionKey: 'sess-a' }); + await beforeMessageWrite?.({ message: { role: 'assistant', content: 'NO_REPLY' } }, { sessionKey: 'sess-b' }); + + assert.deepEqual(idleReminderCalls, []); + assert.deepEqual(moderatorMessages, []); + assert.equal(getTurnDebugInfo(channelId).dormant, true); +}); + +test('message_sent skips handoff after discuss-callback has closed the discussion channel', async () => { + const channelId = 'discussion-closed-channel'; + resetTurn(channelId); + initTurnOrder(channelId, ['agent-a', 'agent-b']); + onNewMessage(channelId, 'human-user', true); + + const state = getTurnDebugInfo(channelId); + const currentSpeaker = state.currentSpeaker as string; + assert.ok(currentSpeaker); + + const moderatorMessages: string[] = []; + const api = makeApi(); + + registerMessageSentHook({ + api: api as any, + baseConfig: { + endSymbols: ['🔚'], + moderatorBotToken: 'bot-token', + schedulingIdentifier: '➡️', + } as any, + policyState: { channelPolicies: {} }, + sessionChannelId: new Map([['sess-closed', channelId]]), + sessionAccountId: new Map([['sess-closed', currentSpeaker]]), + sessionTurnHandled: new Set(), + ensurePolicyStateLoaded: () => {}, + resolveDiscordUserId: () => 'discord-user-next', + sendModeratorMessage: async (_token, _channelId, content) => { + moderatorMessages.push(content); + return { ok: true }; + }, + discussionService: { + isClosedDiscussion: (id) => id === channelId, + }, + }); + + const messageSent = api.handlers.get('message_sent'); + assert.ok(messageSent); + + await messageSent?.({ content: 'NO_REPLY' }, { sessionKey: 'sess-closed', accountId: currentSpeaker, channelId }); + + assert.deepEqual(moderatorMessages, []); + assert.equal(getTurnDebugInfo(channelId).currentSpeaker, currentSpeaker); +});