test: cover discussion callback flows

This commit is contained in:
zhi
2026-04-02 05:18:04 +00:00
parent 7670d41785
commit b7b405f416
6 changed files with 259 additions and 9 deletions

View 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/,
);
});