swarmfeed-runtime.test.ts
1 import assert from 'node:assert/strict' 2 import fs from 'node:fs' 3 import os from 'node:os' 4 import path from 'node:path' 5 import { after, before, describe, it } from 'node:test' 6 import type { Agent } from '@/types' 7 8 const originalEnv = { 9 DATA_DIR: process.env.DATA_DIR, 10 WORKSPACE_DIR: process.env.WORKSPACE_DIR, 11 SWARMCLAW_BUILD_MODE: process.env.SWARMCLAW_BUILD_MODE, 12 } 13 14 let tempDir = '' 15 let workspaceDir = '' 16 let runtimeMod: typeof import('./swarmfeed-runtime') 17 let agentRepoMod: typeof import('./agents/agent-repository') 18 19 function makeAgent(overrides: Partial<Agent> = {}): Agent { 20 return { 21 id: 'agent-1', 22 name: 'Test Agent', 23 description: 'Social test agent', 24 systemPrompt: 'Be useful', 25 provider: 'openai', 26 model: 'gpt-4o-mini', 27 createdAt: Date.now(), 28 updatedAt: Date.now(), 29 swarmfeedEnabled: true, 30 heartbeatEnabled: true, 31 swarmfeedAutoPostChannels: ['builders'], 32 swarmfeedHeartbeat: { 33 enabled: true, 34 browseFeed: true, 35 postFrequency: 'manual_only', 36 autoReply: false, 37 autoFollow: false, 38 channelsToMonitor: ['builders'], 39 }, 40 ...overrides, 41 } 42 } 43 44 before(async () => { 45 tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-swarmfeed-runtime-')) 46 workspaceDir = path.join(tempDir, 'workspace') 47 fs.mkdirSync(workspaceDir, { recursive: true }) 48 process.env.DATA_DIR = path.join(tempDir, 'data') 49 process.env.WORKSPACE_DIR = workspaceDir 50 process.env.SWARMCLAW_BUILD_MODE = '1' 51 52 runtimeMod = await import('./swarmfeed-runtime') 53 agentRepoMod = await import('./agents/agent-repository') 54 }) 55 56 after(() => { 57 if (originalEnv.DATA_DIR === undefined) delete process.env.DATA_DIR 58 else process.env.DATA_DIR = originalEnv.DATA_DIR 59 if (originalEnv.WORKSPACE_DIR === undefined) delete process.env.WORKSPACE_DIR 60 else process.env.WORKSPACE_DIR = originalEnv.WORKSPACE_DIR 61 if (originalEnv.SWARMCLAW_BUILD_MODE === undefined) delete process.env.SWARMCLAW_BUILD_MODE 62 else process.env.SWARMCLAW_BUILD_MODE = originalEnv.SWARMCLAW_BUILD_MODE 63 fs.rmSync(tempDir, { recursive: true, force: true }) 64 }) 65 66 describe('buildSwarmFeedHeartbeatGuidance', () => { 67 it('returns empty string when SwarmFeed social heartbeat is disabled', () => { 68 const guidance = runtimeMod.buildSwarmFeedHeartbeatGuidance( 69 makeAgent({ swarmfeedEnabled: false }), 70 ) 71 assert.equal(guidance, '') 72 }) 73 74 it('explains that social automation is inactive when the main heartbeat is disabled', () => { 75 const guidance = runtimeMod.buildSwarmFeedHeartbeatGuidance( 76 makeAgent({ heartbeatEnabled: false }), 77 ) 78 assert.match(guidance, /currently inactive/i) 79 assert.match(guidance, /heartbeat is disabled/i) 80 }) 81 82 it('includes the manual-only guardrail for autonomous posting', () => { 83 const guidance = runtimeMod.buildSwarmFeedHeartbeatGuidance( 84 makeAgent({ 85 swarmfeedHeartbeat: { 86 enabled: true, 87 browseFeed: false, 88 postFrequency: 'manual_only', 89 autoReply: false, 90 autoFollow: false, 91 channelsToMonitor: [], 92 }, 93 }), 94 ) 95 assert.match(guidance, /manual only/i) 96 assert.match(guidance, /Do not author new SwarmFeed posts or replies/i) 97 }) 98 99 it('mentions the recent-post limit for daily posting', () => { 100 const guidance = runtimeMod.buildSwarmFeedHeartbeatGuidance( 101 makeAgent({ 102 swarmfeedLastAutoPostAt: Date.now() - 60_000, 103 swarmfeedHeartbeat: { 104 enabled: true, 105 browseFeed: true, 106 postFrequency: 'daily', 107 autoReply: true, 108 autoFollow: true, 109 channelsToMonitor: ['builders'], 110 }, 111 }), 112 ) 113 assert.match(guidance, /daily auto-post already happened/i) 114 }) 115 116 it('describes the on-task-completion policy explicitly', () => { 117 const guidance = runtimeMod.buildSwarmFeedHeartbeatGuidance( 118 makeAgent({ 119 swarmfeedHeartbeat: { 120 enabled: true, 121 browseFeed: true, 122 postFrequency: 'on_task_completion', 123 autoReply: false, 124 autoFollow: false, 125 channelsToMonitor: ['builders'], 126 }, 127 }), 128 ) 129 assert.match(guidance, /on task completion/i) 130 assert.match(guidance, /completed task/i) 131 }) 132 }) 133 134 describe('canAutoPostToSwarmFeed', () => { 135 it('blocks autonomous posting when manual_only is configured', () => { 136 const result = runtimeMod.canAutoPostToSwarmFeed(makeAgent()) 137 assert.equal(result.allowed, false) 138 assert.match(result.reason || '', /manual_only/i) 139 }) 140 141 it('blocks autonomous posting after a recent daily post', () => { 142 const result = runtimeMod.canAutoPostToSwarmFeed( 143 makeAgent({ 144 swarmfeedLastAutoPostAt: Date.now() - 60_000, 145 swarmfeedHeartbeat: { 146 enabled: true, 147 browseFeed: true, 148 postFrequency: 'daily', 149 autoReply: false, 150 autoFollow: false, 151 channelsToMonitor: [], 152 }, 153 }), 154 ) 155 assert.equal(result.allowed, false) 156 assert.match(result.reason || '', /daily autonomous SwarmFeed post/i) 157 }) 158 159 it('allows daily posting when the last automatic post is older than 24 hours', () => { 160 const result = runtimeMod.canAutoPostToSwarmFeed( 161 makeAgent({ 162 swarmfeedLastAutoPostAt: Date.now() - (25 * 60 * 60 * 1000), 163 swarmfeedHeartbeat: { 164 enabled: true, 165 browseFeed: true, 166 postFrequency: 'daily', 167 autoReply: false, 168 autoFollow: false, 169 channelsToMonitor: [], 170 }, 171 }), 172 ) 173 assert.equal(result.allowed, true) 174 }) 175 }) 176 177 describe('markSwarmFeedAutoPost', () => { 178 it('persists the last auto-post timestamp on the agent record', () => { 179 const agent = makeAgent({ id: 'agent-mark-post' }) 180 agentRepoMod.saveAgent(agent.id, agent) 181 182 runtimeMod.markSwarmFeedAutoPost(agent.id) 183 184 const updated = agentRepoMod.getAgent(agent.id) 185 assert.equal(typeof updated?.swarmfeedLastAutoPostAt, 'number') 186 assert.ok((updated?.swarmfeedLastAutoPostAt || 0) >= agent.createdAt) 187 }) 188 })