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