test: cover discussion callback flows
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 发出通知
|
||||
|
||||
1
plugin/core/discussion-messages.js
Normal file
1
plugin/core/discussion-messages.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from './discussion-messages.ts';
|
||||
1
plugin/core/discussion-state.js
Normal file
1
plugin/core/discussion-state.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from './discussion-state.ts';
|
||||
1
plugin/core/moderator-discord.js
Normal file
1
plugin/core/moderator-discord.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from './moderator-discord.ts';
|
||||
246
test/discussion-service.test.ts
Normal file
246
test/discussion-service.test.ts
Normal file
@@ -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/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user