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; } });