diff --git a/package.json b/package.json index cc009cf..aec4f9e 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ ], "scripts": { "prepare": "mkdir -p dist/dirigent && cp -r plugin/* dist/dirigent/", + "test": "node --test --experimental-strip-types test/**/*.test.ts", "postinstall": "node scripts/install.mjs --install", "uninstall": "node scripts/install.mjs --uninstall", "update": "node scripts/install.mjs --update" diff --git a/plans/TASKLIST.md b/plans/TASKLIST.md index 2a39f8f..701ad59 100644 --- a/plans/TASKLIST.md +++ b/plans/TASKLIST.md @@ -193,10 +193,10 @@ - [ ] 测试 `discuss-callback` 注册成功并可调用 #### A13.2 metadata / service 测试 -- [ ] 测试 discussion metadata 创建成功 -- [ ] 测试按 channelId 查询 metadata 成功 -- [ ] 测试状态流转 `active -> completed/closed` 成功 -- [ ] 测试重复 callback 被拒绝 +- [x] 测试 discussion metadata 创建成功 +- [x] 测试按 channelId 查询 metadata 成功 +- [x] 测试状态流转 `active -> completed/closed` 成功 +- [x] 测试重复 callback 被拒绝 #### A13.3 turn / hook 测试 - [ ] 测试 discussion channel 空转后发送 idle reminder @@ -205,11 +205,11 @@ - [ ] 测试 closed discussion channel 新消息不会继续唤醒 Agent #### A13.4 路径校验测试 -- [ ] 测试合法 `summaryPath` 通过 -- [ ] 测试不存在文件失败 -- [ ] 测试 workspace 外路径失败 -- [ ] 测试 `..` 路径逃逸失败 -- [ ] 测试绝对路径越界失败 +- [x] 测试合法 `summaryPath` 通过 +- [x] 测试不存在文件失败 +- [x] 测试 workspace 外路径失败 +- [x] 测试 `..` 路径逃逸失败 +- [x] 测试绝对路径越界失败 #### A13.5 回调链路测试 - [ ] 测试 callback 成功后 moderator 在 origin channel 发出通知 diff --git a/plugin/core/discussion-messages.js b/plugin/core/discussion-messages.js new file mode 100644 index 0000000..5fa6c5d --- /dev/null +++ b/plugin/core/discussion-messages.js @@ -0,0 +1 @@ +export * from './discussion-messages.ts'; diff --git a/plugin/core/discussion-state.js b/plugin/core/discussion-state.js new file mode 100644 index 0000000..046895f --- /dev/null +++ b/plugin/core/discussion-state.js @@ -0,0 +1 @@ +export * from './discussion-state.ts'; diff --git a/plugin/core/moderator-discord.js b/plugin/core/moderator-discord.js new file mode 100644 index 0000000..a0ba77d --- /dev/null +++ b/plugin/core/moderator-discord.js @@ -0,0 +1 @@ +export * from './moderator-discord.ts'; diff --git a/test/discussion-service.test.ts b/test/discussion-service.test.ts new file mode 100644 index 0000000..35cc742 --- /dev/null +++ b/test/discussion-service.test.ts @@ -0,0 +1,246 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { createDiscussionService } from '../plugin/core/discussion-service.ts'; + +function makeLogger() { + return { + info: (_msg: string) => {}, + warn: (_msg: string) => {}, + }; +} + +function makeApi() { + return { + logger: makeLogger(), + }; +} + +function makeWorkspace(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'dirigent-discussion-test-')); +} + +test('initDiscussion stores metadata and getDiscussion retrieves it by channel id', async () => { + const service = createDiscussionService({ + api: makeApi() as any, + workspaceRoot: makeWorkspace(), + forceNoReplyForSession: () => {}, + }); + + const metadata = await service.initDiscussion({ + discussionChannelId: 'discussion-init-1', + originChannelId: 'origin-1', + initiatorAgentId: 'agent-alpha', + initiatorSessionId: 'session-alpha', + discussGuide: 'Settle the callback contract.', + }); + + assert.equal(metadata.mode, 'discussion'); + assert.equal(metadata.status, 'active'); + assert.equal(metadata.originChannelId, 'origin-1'); + + const stored = service.getDiscussion('discussion-init-1'); + assert.ok(stored); + assert.equal(stored?.discussionChannelId, 'discussion-init-1'); + assert.equal(stored?.initiatorAgentId, 'agent-alpha'); + assert.equal(stored?.initiatorSessionId, 'session-alpha'); +}); + +test('handleCallback closes an active discussion and records the resolved summary path', async () => { + const workspace = makeWorkspace(); + const summaryRelPath = path.join('plans', 'summary.md'); + const summaryAbsPath = path.join(workspace, summaryRelPath); + fs.mkdirSync(path.dirname(summaryAbsPath), { recursive: true }); + fs.writeFileSync(summaryAbsPath, '# summary\n'); + + const forcedSessions: string[] = []; + const service = createDiscussionService({ + api: makeApi() as any, + workspaceRoot: workspace, + forceNoReplyForSession: (sessionKey) => forcedSessions.push(sessionKey), + }); + + await service.initDiscussion({ + discussionChannelId: 'discussion-close-1', + originChannelId: 'origin-2', + initiatorAgentId: 'agent-beta', + initiatorSessionId: 'session-beta', + discussGuide: 'Write the wrap-up.', + }); + + const result = await service.handleCallback({ + channelId: 'discussion-close-1', + summaryPath: summaryRelPath, + callerAgentId: 'agent-beta', + callerSessionKey: 'session-beta', + }); + + assert.equal(result.ok, true); + assert.equal(result.summaryPath, fs.realpathSync.native(summaryAbsPath)); + assert.equal(result.discussion.status, 'closed'); + assert.equal(result.discussion.summaryPath, fs.realpathSync.native(summaryAbsPath)); + assert.ok(result.discussion.completedAt); + assert.deepEqual(forcedSessions, ['session-beta']); +}); + +test('handleCallback rejects duplicate callback after the discussion is already closed', async () => { + const workspace = makeWorkspace(); + const summaryRelPath = 'summary.md'; + fs.writeFileSync(path.join(workspace, summaryRelPath), 'done\n'); + + const service = createDiscussionService({ + api: makeApi() as any, + workspaceRoot: workspace, + forceNoReplyForSession: () => {}, + }); + + await service.initDiscussion({ + discussionChannelId: 'discussion-duplicate-1', + originChannelId: 'origin-3', + initiatorAgentId: 'agent-gamma', + initiatorSessionId: 'session-gamma', + discussGuide: 'One close only.', + }); + + await service.handleCallback({ + channelId: 'discussion-duplicate-1', + summaryPath: summaryRelPath, + callerAgentId: 'agent-gamma', + callerSessionKey: 'session-gamma', + }); + + await assert.rejects( + () => service.handleCallback({ + channelId: 'discussion-duplicate-1', + summaryPath: summaryRelPath, + callerAgentId: 'agent-gamma', + callerSessionKey: 'session-gamma', + }), + /discussion is already closed/, + ); +}); + +test('handleCallback accepts a valid summaryPath inside the initiator workspace', async () => { + const workspace = makeWorkspace(); + const summaryRelPath = path.join('notes', 'nested', 'summary.md'); + const summaryAbsPath = path.join(workspace, summaryRelPath); + fs.mkdirSync(path.dirname(summaryAbsPath), { recursive: true }); + fs.writeFileSync(summaryAbsPath, 'nested summary\n'); + + const service = createDiscussionService({ + api: makeApi() as any, + workspaceRoot: workspace, + forceNoReplyForSession: () => {}, + }); + + await service.initDiscussion({ + discussionChannelId: 'discussion-path-ok-1', + originChannelId: 'origin-4', + initiatorAgentId: 'agent-delta', + initiatorSessionId: 'session-delta', + discussGuide: 'Path validation.', + }); + + const result = await service.handleCallback({ + channelId: 'discussion-path-ok-1', + summaryPath: summaryRelPath, + callerAgentId: 'agent-delta', + callerSessionKey: 'session-delta', + }); + + assert.equal(result.summaryPath, fs.realpathSync.native(summaryAbsPath)); +}); + +test('handleCallback rejects a missing summary file', async () => { + const workspace = makeWorkspace(); + const service = createDiscussionService({ + api: makeApi() as any, + workspaceRoot: workspace, + forceNoReplyForSession: () => {}, + }); + + await service.initDiscussion({ + discussionChannelId: 'discussion-missing-1', + originChannelId: 'origin-5', + initiatorAgentId: 'agent-epsilon', + initiatorSessionId: 'session-epsilon', + discussGuide: 'Expect missing file failure.', + }); + + await assert.rejects( + () => service.handleCallback({ + channelId: 'discussion-missing-1', + summaryPath: 'missing.md', + callerAgentId: 'agent-epsilon', + callerSessionKey: 'session-epsilon', + }), + ); +}); + +test('handleCallback rejects .. path traversal outside the initiator workspace', async () => { + const workspace = makeWorkspace(); + const outsideDir = makeWorkspace(); + const outsideFile = path.join(outsideDir, 'outside.md'); + fs.writeFileSync(outsideFile, 'outside\n'); + + const service = createDiscussionService({ + api: makeApi() as any, + workspaceRoot: workspace, + forceNoReplyForSession: () => {}, + }); + + await service.initDiscussion({ + discussionChannelId: 'discussion-traversal-1', + originChannelId: 'origin-6', + initiatorAgentId: 'agent-zeta', + initiatorSessionId: 'session-zeta', + discussGuide: 'Reject traversal.', + }); + + const traversalPath = path.relative(workspace, outsideFile); + assert.match(traversalPath, /^\.\./); + + await assert.rejects( + () => service.handleCallback({ + channelId: 'discussion-traversal-1', + summaryPath: traversalPath, + callerAgentId: 'agent-zeta', + callerSessionKey: 'session-zeta', + }), + /summaryPath must stay inside the initiator workspace/, + ); +}); + +test('handleCallback rejects an absolute path outside the initiator workspace', async () => { + const workspace = makeWorkspace(); + const outsideDir = makeWorkspace(); + const outsideFile = path.join(outsideDir, 'absolute-outside.md'); + fs.writeFileSync(outsideFile, 'absolute outside\n'); + + const service = createDiscussionService({ + api: makeApi() as any, + workspaceRoot: workspace, + forceNoReplyForSession: () => {}, + }); + + await service.initDiscussion({ + discussionChannelId: 'discussion-absolute-1', + originChannelId: 'origin-7', + initiatorAgentId: 'agent-eta', + initiatorSessionId: 'session-eta', + discussGuide: 'Reject absolute outside path.', + }); + + await assert.rejects( + () => service.handleCallback({ + channelId: 'discussion-absolute-1', + summaryPath: outsideFile, + callerAgentId: 'agent-eta', + callerSessionKey: 'session-eta', + }), + /summaryPath must stay inside the initiator workspace/, + ); +});