421 lines
14 KiB
TypeScript
421 lines
14 KiB
TypeScript
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';
|
|
import { buildDiscussionClosedMessage, buildDiscussionIdleReminderMessage, buildDiscussionOriginCallbackMessage } from '../plugin/core/discussion-messages.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),
|
|
getDiscussionSessionKeys: () => ['session-beta-helper'],
|
|
});
|
|
|
|
await service.initDiscussion({
|
|
discussionChannelId: 'discussion-close-1',
|
|
originChannelId: 'origin-2',
|
|
initiatorAgentId: 'agent-beta',
|
|
initiatorSessionId: 'session-beta',
|
|
initiatorWorkspaceRoot: workspace,
|
|
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.sort(), ['session-beta', 'session-beta-helper']);
|
|
});
|
|
|
|
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',
|
|
initiatorWorkspaceRoot: workspace,
|
|
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 uses the initiator workspace root instead of the process cwd', async () => {
|
|
const workspace = makeWorkspace();
|
|
const summaryRelPath = path.join('notes', 'initiator-only.md');
|
|
const summaryAbsPath = path.join(workspace, summaryRelPath);
|
|
fs.mkdirSync(path.dirname(summaryAbsPath), { recursive: true });
|
|
fs.writeFileSync(summaryAbsPath, 'initiator workspace file\n');
|
|
|
|
const differentDefaultWorkspace = makeWorkspace();
|
|
const service = createDiscussionService({
|
|
api: makeApi() as any,
|
|
workspaceRoot: differentDefaultWorkspace,
|
|
forceNoReplyForSession: () => {},
|
|
});
|
|
|
|
await service.initDiscussion({
|
|
discussionChannelId: 'discussion-workspace-root-1',
|
|
originChannelId: 'origin-6a',
|
|
initiatorAgentId: 'agent-zeta-root',
|
|
initiatorSessionId: 'session-zeta-root',
|
|
initiatorWorkspaceRoot: workspace,
|
|
discussGuide: 'Use initiator workspace root.',
|
|
});
|
|
|
|
const result = await service.handleCallback({
|
|
channelId: 'discussion-workspace-root-1',
|
|
summaryPath: summaryRelPath,
|
|
callerAgentId: 'agent-zeta-root',
|
|
callerSessionKey: 'session-zeta-root',
|
|
});
|
|
|
|
assert.equal(result.summaryPath, fs.realpathSync.native(summaryAbsPath));
|
|
});
|
|
|
|
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',
|
|
initiatorWorkspaceRoot: workspace,
|
|
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',
|
|
initiatorWorkspaceRoot: workspace,
|
|
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/,
|
|
);
|
|
});
|
|
|
|
test('maybeSendIdleReminder sends exactly one idle reminder for an active discussion', async () => {
|
|
const workspace = makeWorkspace();
|
|
|
|
const fetchCalls: Array<{ url: string; body: any }> = [];
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
|
|
fetchCalls.push({ url: String(url), body });
|
|
return new Response(JSON.stringify({ id: `msg-${fetchCalls.length}` }), { status: 200 });
|
|
}) as typeof fetch;
|
|
|
|
try {
|
|
const service = createDiscussionService({
|
|
api: makeApi() as any,
|
|
workspaceRoot: workspace,
|
|
moderatorBotToken: 'bot-token',
|
|
forceNoReplyForSession: () => {},
|
|
});
|
|
|
|
await service.initDiscussion({
|
|
discussionChannelId: 'discussion-idle-1',
|
|
originChannelId: 'origin-idle-1',
|
|
initiatorAgentId: 'agent-idle',
|
|
initiatorSessionId: 'session-idle',
|
|
discussGuide: 'Only send one reminder.',
|
|
});
|
|
|
|
await service.maybeSendIdleReminder('discussion-idle-1');
|
|
await service.maybeSendIdleReminder('discussion-idle-1');
|
|
|
|
assert.equal(fetchCalls.length, 2);
|
|
assert.equal(fetchCalls[1]?.url, 'https://discord.com/api/v10/channels/discussion-idle-1/messages');
|
|
assert.equal(fetchCalls[1]?.body?.content, buildDiscussionIdleReminderMessage());
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
test('handleCallback notifies the origin channel with the resolved summary path', async () => {
|
|
const workspace = makeWorkspace();
|
|
const summaryRelPath = path.join('plans', 'discussion-summary.md');
|
|
const summaryAbsPath = path.join(workspace, summaryRelPath);
|
|
fs.mkdirSync(path.dirname(summaryAbsPath), { recursive: true });
|
|
fs.writeFileSync(summaryAbsPath, '# done\n');
|
|
|
|
const fetchCalls: Array<{ url: string; body: any }> = [];
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
|
|
fetchCalls.push({ url: String(url), body });
|
|
return new Response(JSON.stringify({ id: `msg-${fetchCalls.length}` }), { status: 200 });
|
|
}) as typeof fetch;
|
|
|
|
try {
|
|
const service = createDiscussionService({
|
|
api: makeApi() as any,
|
|
workspaceRoot: workspace,
|
|
moderatorBotToken: 'bot-token',
|
|
forceNoReplyForSession: () => {},
|
|
});
|
|
|
|
await service.initDiscussion({
|
|
discussionChannelId: 'discussion-origin-1',
|
|
originChannelId: 'origin-8',
|
|
initiatorAgentId: 'agent-theta',
|
|
initiatorSessionId: 'session-theta',
|
|
discussGuide: 'Notify the origin channel.',
|
|
});
|
|
|
|
await service.handleCallback({
|
|
channelId: 'discussion-origin-1',
|
|
summaryPath: summaryRelPath,
|
|
callerAgentId: 'agent-theta',
|
|
callerSessionKey: 'session-theta',
|
|
});
|
|
|
|
assert.equal(fetchCalls.length, 2);
|
|
assert.equal(fetchCalls[1]?.url, 'https://discord.com/api/v10/channels/origin-8/messages');
|
|
assert.equal(
|
|
fetchCalls[1]?.body?.content,
|
|
buildDiscussionOriginCallbackMessage(fs.realpathSync.native(summaryAbsPath), 'discussion-origin-1'),
|
|
);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
test('maybeReplyClosedChannel sends the archive-only closed message for later channel activity', async () => {
|
|
const workspace = makeWorkspace();
|
|
const summaryRelPath = 'summary.md';
|
|
const summaryAbsPath = path.join(workspace, summaryRelPath);
|
|
fs.writeFileSync(summaryAbsPath, 'closed\n');
|
|
|
|
const fetchCalls: Array<{ url: string; body: any }> = [];
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
|
|
fetchCalls.push({ url: String(url), body });
|
|
return new Response(JSON.stringify({ id: `msg-${fetchCalls.length}` }), { status: 200 });
|
|
}) as typeof fetch;
|
|
|
|
try {
|
|
const service = createDiscussionService({
|
|
api: makeApi() as any,
|
|
workspaceRoot: workspace,
|
|
moderatorBotToken: 'bot-token',
|
|
moderatorUserId: 'moderator-user',
|
|
forceNoReplyForSession: () => {},
|
|
});
|
|
|
|
await service.initDiscussion({
|
|
discussionChannelId: 'discussion-closed-1',
|
|
originChannelId: 'origin-9',
|
|
initiatorAgentId: 'agent-iota',
|
|
initiatorSessionId: 'session-iota',
|
|
discussGuide: 'Close and archive.',
|
|
});
|
|
|
|
await service.handleCallback({
|
|
channelId: 'discussion-closed-1',
|
|
summaryPath: summaryRelPath,
|
|
callerAgentId: 'agent-iota',
|
|
callerSessionKey: 'session-iota',
|
|
});
|
|
|
|
const handled = await service.maybeReplyClosedChannel('discussion-closed-1', 'human-user');
|
|
assert.equal(handled, true);
|
|
assert.equal(fetchCalls.length, 3);
|
|
assert.equal(fetchCalls[2]?.url, 'https://discord.com/api/v10/channels/discussion-closed-1/messages');
|
|
assert.equal(fetchCalls[2]?.body?.content, buildDiscussionClosedMessage());
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|