diff --git a/plans/TASKLIST.md b/plans/TASKLIST.md index 7e65fc9..f856539 100644 --- a/plans/TASKLIST.md +++ b/plans/TASKLIST.md @@ -3,12 +3,12 @@ ## A. CSM / Discussion Callback ### A1. 需求与方案冻结 -- [. ] 通读并确认 `plans/CSM.md` 中的 MVP 范围、非目标和边界条件 -- [. ] 确认 CSM 第一版只新增一条对外工具:`discuss-callback` -- [. ] 确认 `discord_channel_create` 仅做参数扩展,不改变普通建频道行为 -- [. ] 确认讨论结束后的 session 级 no-reply 覆盖继续沿用现有插件机制 -- [. ] 确认 `summaryPath` 的合法范围仅限发起讨论 Agent 的 workspace -- [. ] 确认 discussion metadata 是否仅做内存态,还是需要落盘恢复 +- [x] 通读并确认 `plans/CSM.md` 中的 MVP 范围、非目标和边界条件 +- [x] 确认 CSM 第一版只新增一条对外工具:`discuss-callback` +- [x] 确认 `discord_channel_create` 仅做参数扩展,不改变普通建频道行为 +- [x] 确认讨论结束后的 session 级 no-reply 覆盖继续沿用现有插件机制 +- [x] 确认 `summaryPath` 的合法范围仅限发起讨论 Agent 的 workspace +- [x] 确认 discussion metadata 是否仅做内存态,还是需要落盘恢复 ### A2. 模块拆分与落点确认 - [. ] 确认 `plugin/tools/register-tools.ts` 负责: @@ -159,7 +159,7 @@ ### A13.5 回调链路测试 - [x] 测试 callback 成功后 moderator 在 origin channel 发出通知 -- [ ] 测试 origin channel 收到路径后能继续原工作流 +- [x] 测试 origin channel 收到路径后能继续原工作流 - [x] 测试 discussion channel 后续只保留留档行为 ### B10.2 Shuffle Mode @@ -267,10 +267,10 @@ ## B. Multi-Message Mode / Shuffle Mode ### B1. 方案整理 -- [. ] 通读并确认 `plans/CHANNEL_MODES_AND_SHUFFLE.md` -- [. ] 确认 Multi-Message Mode 与 Shuffle Mode 的 MVP 范围 -- [. ] 确认两项能力是否都只做 channel 级 runtime state,不立即落盘 -- [. ] 明确它们与 discussion channel / waiting-for-human / dormant 的优先级关系 +- [x] 通读并确认 `plans/CHANNEL_MODES_AND_SHUFFLE.md` +- [x] 确认 Multi-Message Mode 与 Shuffle Mode 的 MVP 范围 +- [x] 确认两项能力是否都只做 channel 级 runtime state,不立即落盘 +- [x] 明确它们与 discussion channel / waiting-for-human / dormant 的优先级关系 ### B2. 配置与 schema #### B2.1 `plugin/openclaw.plugin.json` diff --git a/plugin/core/discussion-messages.ts b/plugin/core/discussion-messages.ts index 67a256e..caf161c 100644 --- a/plugin/core/discussion-messages.ts +++ b/plugin/core/discussion-messages.ts @@ -51,9 +51,11 @@ export function buildDiscussionClosedMessage(): string { ].join("\n"); } +const DISCUSSION_RESULT_READY_HEADER = "[Discussion Result Ready]"; + export function buildDiscussionOriginCallbackMessage(summaryPath: string, discussionChannelId: string): string { return [ - "[Discussion Result Ready]", + DISCUSSION_RESULT_READY_HEADER, "", "A temporary discussion has completed.", "", @@ -69,3 +71,7 @@ export function buildDiscussionOriginCallbackMessage(summaryPath: string, discus "Continue the original task using the summary file above.", ].join("\n"); } + +export function isDiscussionOriginCallbackMessage(content: string): boolean { + return content.includes(DISCUSSION_RESULT_READY_HEADER); +} diff --git a/plugin/hooks/message-received.ts b/plugin/hooks/message-received.ts index 226c437..fb0184f 100644 --- a/plugin/hooks/message-received.ts +++ b/plugin/hooks/message-received.ts @@ -1,6 +1,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { onNewMessage, setMentionOverride, getTurnDebugInfo } from "../turn-manager.js"; import { extractDiscordChannelId } from "../channel-resolver.js"; +import { isDiscussionOriginCallbackMessage } from "../core/discussion-messages.js"; import type { DirigentConfig } from "../rules.js"; type DebugConfig = { @@ -64,7 +65,10 @@ export function registerMessageReceivedHook(deps: MessageReceivedDeps): void { if (closedHandled) return; } - if (moderatorUserId && from === moderatorUserId) { + const messageContent = ((e as Record).content as string) || ((e as Record).text as string) || ""; + const isModeratorOriginCallback = !!(moderatorUserId && from === moderatorUserId && isDiscussionOriginCallbackMessage(messageContent)); + + if (moderatorUserId && from === moderatorUserId && !isModeratorOriginCallback) { if (shouldDebugLog(livePre, preChannelId)) { api.logger.info(`dirigent: ignoring moderator message in channel=${preChannelId}`); } @@ -82,19 +86,15 @@ export function registerMessageReceivedHook(deps: MessageReceivedDeps): void { } if (isHuman) { - const messageContent = ((e as Record).content as string) || ((e as Record).text as string) || ""; - - // Handle multi-message mode markers const startMarker = livePre.multiMessageStartMarker || "↗️"; const endMarker = livePre.multiMessageEndMarker || "↙️"; - + if (messageContent.includes(startMarker)) { enterMultiMessageMode(preChannelId); api.logger.info(`dirigent: entered multi-message mode channel=${preChannelId}`); } else if (messageContent.includes(endMarker)) { exitMultiMessageMode(preChannelId); api.logger.info(`dirigent: exited multi-message mode channel=${preChannelId}`); - // After exiting multi-message mode, activate the turn system onNewMessage(preChannelId, senderAccountId, isHuman); } else { const mentionedUserIds = extractMentionedUserIds(messageContent); @@ -124,7 +124,7 @@ export function registerMessageReceivedHook(deps: MessageReceivedDeps): void { } } } else { - onNewMessage(preChannelId, senderAccountId, isHuman); + onNewMessage(preChannelId, senderAccountId, false); } if (shouldDebugLog(livePre, preChannelId)) { diff --git a/test/discussion-hooks.test.ts b/test/discussion-hooks.test.ts index 8b67395..c95110d 100644 --- a/test/discussion-hooks.test.ts +++ b/test/discussion-hooks.test.ts @@ -2,7 +2,9 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { registerBeforeMessageWriteHook } from '../plugin/hooks/before-message-write.ts'; +import { registerMessageReceivedHook } from '../plugin/hooks/message-received.ts'; import { registerMessageSentHook } from '../plugin/hooks/message-sent.ts'; +import { buildDiscussionOriginCallbackMessage } from '../plugin/core/discussion-messages.ts'; import { initTurnOrder, onNewMessage, getTurnDebugInfo, resetTurn } from '../plugin/turn-manager.ts'; type Handler = (event: Record, ctx: Record) => unknown; @@ -86,6 +88,48 @@ test('before_message_write leaves ordinary channels dormant without sending a di assert.equal(getTurnDebugInfo(channelId).dormant, true); }); +test('message_received lets moderator discussion callback notifications wake the origin channel workflow', async () => { + const channelId = '1474327736242798612'; + resetTurn(channelId); + initTurnOrder(channelId, ['agent-a', 'agent-b']); + assert.equal(getTurnDebugInfo(channelId).currentSpeaker, null); + + const api = makeApi(); + registerMessageReceivedHook({ + api: api as any, + baseConfig: { + moderatorUserId: 'moderator-user', + humanList: ['human-user'], + } as any, + shouldDebugLog: () => false, + debugCtxSummary: () => ({}), + ensureTurnOrder: () => {}, + getModeratorUserId: (cfg) => (cfg as any).moderatorUserId, + recordChannelAccount: () => false, + extractMentionedUserIds: () => [], + buildUserIdToAccountIdMap: () => new Map(), + enterMultiMessageMode: () => {}, + exitMultiMessageMode: () => {}, + discussionService: { + maybeReplyClosedChannel: async () => false, + }, + }); + + const messageReceived = api.handlers.get('message_received'); + assert.ok(messageReceived); + + await messageReceived?.({ + content: buildDiscussionOriginCallbackMessage('/workspace/plans/discussion-summary.md', 'discussion-42'), + from: 'moderator-user', + }, { + conversationId: channelId, + }); + + const state = getTurnDebugInfo(channelId); + assert.equal(state.currentSpeaker, state.turnOrder[0]); + assert.equal(state.dormant, false); +}); + test('message_sent skips handoff after discuss-callback has closed the discussion channel', async () => { const channelId = 'discussion-closed-channel'; resetTurn(channelId);