src/bot/initiativeEngine.test.ts

import { test } from "bun:test"; import assert from "node:assert/strict"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path";

import { Store } from "../store/store.ts"; import { normalizeSettings } from "../store/settingsNormalization.ts"; import { rmTempDir } from "../testHelpers.ts"; import { createTestSettingsPatch } from "../testSettings.ts"; import { getEligibleInitiativeChannelIds, maybeRunInitiativeCycle } from "./initiativeEngine.ts";

async function withTempStore(run: (store: Store) => Promise) { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clanker-initiative-test-")); const dbPath = path.join(dir, "clanker.db"); const store = new Store(dbPath); store.init();

try { await run(store); } finally { store.close(); await rmTempDir(dir); } }

test("getEligibleInitiativeChannelIds uses discoveryChannelIds", () => { const rawSettings: unknown = { permissions: { replies: { discoveryChannelIds: ["discovery-1"] } } };

const settings = normalizeSettings(rawSettings);

assert.deepEqual(getEligibleInitiativeChannelIds(settings), ["discovery-1"]); });

test("getEligibleInitiativeChannelIds returns empty when only replyChannelIds are set", () => { const rawSettings: unknown = { permissions: { replies: { replyChannelIds: ["reply-1"] } } };

const settings = normalizeSettings(rawSettings);

assert.deepEqual(getEligibleInitiativeChannelIds(settings), []); });

test("maybeRunInitiativeCycle starts the min-gap cooldown after an initiative skip", async () => { await withTempStore(async (store) => { const guildId = "guild-1"; const channelId = "channel-1"; const botUserId = "bot-1"; const llmCalls: Array<Record<string, unknown>> = []; const pendingThoughts = new Map();

store.patchSettings(createTestSettingsPatch({
  permissions: {
    replies: {
      allowReplies: true,
      allowUnsolicitedReplies: true,
      allowReactions: false,
      replyChannelIds: [channelId],
      discoveryChannelIds: [channelId],
      allowedChannelIds: [channelId],
      blockedChannelIds: [],
      blockedUserIds: [],
      maxMessagesPerHour: 100,
      maxReactionsPerHour: 0
    }
  },
  memory: {
    enabled: false
  },
  initiative: {
    text: {
      enabled: true,
      eagerness: 100,
      minMinutesBetweenPosts: 60,
      maxPostsPerDay: 3,
      lookbackMessages: 12,
      allowActiveCuriosity: false,
      maxToolSteps: 0,
      maxToolCalls: 0
    }
  }
}));

store.recordMessage({
  messageId: "msg-1",
  createdAt: Date.now() - 1_000,
  guildId,
  channelId,
  authorId: "user-1",
  authorName: "alice",
  isBot: false,
  content: "anyone have a strong take on proton mail",
  referencedMessageId: null
});

const channel = {
  id: channelId,
  guildId,
  name: "general",
  guild: {
    id: guildId
  },
  isTextBased() {
    return true;
  },
  async sendTyping() {
    return true;
  },
  async send() {
    throw new Error("initiative skip should not send a message");
  }
};

const runtime = {
  appConfig: { env: "test" },
  store,
  llm: {
    async generate(payload: Record<string, unknown>) {
      llmCalls.push(payload);
      return {
        text: JSON.stringify({
          skip: true,
          reason: "too quiet to jump in naturally"
        }),
        toolCalls: [],
        rawContent: null,
        provider: "test",
        model: "test-model",
        usage: {
          inputTokens: 0,
          outputTokens: 0
        }
      };
    }
  },
  memory: {},
  client: {
    user: {
      id: botUserId,
      username: "clanky"
    },
    guilds: {
      cache: new Map()
    },
    channels: {
      cache: {
        get(id: string) {
          return id === channelId ? channel : undefined;
        }
      }
    }
  },
  botUserId,
  discovery: null,
  search: null,
  initiativeCycleRunning: false,
  getPendingInitiativeThoughts() {
    return pendingThoughts;
  },
  getPendingInitiativeThought(guildId: string) {
    return pendingThoughts.get(guildId) || null;
  },
  setPendingInitiativeThought(guildId: string, thought: unknown) {
    if (!thought) {
      pendingThoughts.delete(guildId);
      return;
    }
    pendingThoughts.set(guildId, thought);
  },
  canSendMessage() {
    return true;
  },
  canTalkNow() {
    return true;
  },
  async hydrateRecentMessages() {
    return [];
  },
  isChannelAllowed() {
    return true;
  },
  isNonPrivateReplyEligibleChannel() {
    return true;
  },
  getSimulatedTypingDelayMs() {
    return 0;
  },
  markSpoke() {},
  composeMessageContentForHistory() {
    return "";
  },
  async loadRelevantMemoryFacts() {
    return [];
  },
  buildMediaMemoryFacts() {
    return [];
  },
  getImageBudgetState() {
    return { canGenerate: false, remaining: 0 };
  },
  getVideoGenerationBudgetState() {
    return { canGenerate: false, remaining: 0 };
  },
  getGifBudgetState() {
    return { canFetch: false, remaining: 0 };
  },
  getMediaGenerationCapabilities() {
    return {
      simpleImageReady: false,
      complexImageReady: false,
      videoReady: false
    };
  },
  async resolveMediaAttachment() {
    throw new Error("initiative skip should not resolve media");
  },
  buildBrowserBrowseContext() {
    return {
      enabled: false,
      configured: false,
      budget: {
        canBrowse: false
      }
    };
  },
  async runModelRequestedBrowserBrowse() {
    return {
      used: false,
      text: "",
      steps: 0,
      hitStepLimit: false,
      error: null,
      blockedByBudget: false
    };
  }
} as Parameters<typeof maybeRunInitiativeCycle>[0];

await maybeRunInitiativeCycle(runtime);
assert.equal(llmCalls.length, 1);

const since = new Date(Date.now() - 5 * 60_000).toISOString();
assert.equal(store.countActionsSince("initiative_skip", since), 1);

await maybeRunInitiativeCycle(runtime);
assert.equal(llmCalls.length, 1);
assert.equal(store.countActionsSince("initiative_skip", since), 1);

}); });

test("maybeRunInitiativeCycle revisits a pending thought even during fresh-thought cooldown", async () => { await withTempStore(async (store) => { const guildId = "guild-1"; const channelId = "channel-1"; const botUserId = "bot-1"; const pendingThoughts = new Map(); let sendCount = 0; let llmCalls = 0;

store.patchSettings(createTestSettingsPatch({
  permissions: {
    replies: {
      allowReplies: true,
      allowUnsolicitedReplies: true,
      allowReactions: false,
      replyChannelIds: [channelId],
      discoveryChannelIds: [channelId],
      allowedChannelIds: [channelId],
      blockedChannelIds: [],
      blockedUserIds: [],
      maxMessagesPerHour: 100,
      maxReactionsPerHour: 0
    }
  },
  memory: {
    enabled: false
  },
  initiative: {
    text: {
      enabled: true,
      eagerness: 100,
      minMinutesBetweenPosts: 60,
      maxPostsPerDay: 3,
      lookbackMessages: 12,
      allowActiveCuriosity: false,
      maxToolSteps: 0,
      maxToolCalls: 0
    }
  }
}));

store.recordMessage({
  messageId: "msg-1",
  createdAt: Date.now() - 1_000,
  guildId,
  channelId,
  authorId: "user-1",
  authorName: "alice",
  isBot: false,
  content: "proton mail is interesting again",
  referencedMessageId: null
});
store.logAction({
  kind: "initiative_skip",
  guildId,
  channelId,
  userId: botUserId,
  content: "fresh_thought_skip"
});

pendingThoughts.set(guildId, {
  id: "thought-1",
  guildId,
  channelId,
  channelName: "general",
  trigger: "timer",
  draftText: "maybe proton mail is worth revisiting",
  currentText: "maybe proton mail is worth revisiting",
  createdAt: Date.now() - 120_000,
  updatedAt: Date.now() - 120_000,
  basisAt: Date.now() - 120_000,
  notBeforeAt: 0,
  expiresAt: Date.now() + 120_000,
  revision: 1,
  status: "queued",
  lastDecisionReason: "felt half-baked",
  lastDecisionAction: "hold",
  mediaDirective: "none",
  mediaPrompt: null
});

const channel = {
  id: channelId,
  guildId,
  name: "general",
  guild: {
    id: guildId
  },
  isTextBased() {
    return true;
  },
  async sendTyping() {
    return true;
  },
  async send(payload: { content: string }) {
    sendCount += 1;
    return {
      id: `sent-${sendCount}`,
      createdTimestamp: Date.now(),
      guildId,
      channelId,
      content: payload.content
    };
  }
};

const runtime = {
  appConfig: { env: "test" },
  store,
  llm: {
    async generate() {
      llmCalls += 1;
      return {
        text: JSON.stringify({
          action: "post_now",
          channelId,
          text: "actually proton mail discourse is back in style",
          mediaDirective: "none",
          mediaPrompt: null,
          reason: "ready_now"
        }),
        toolCalls: [],
        rawContent: null,
        provider: "test",
        model: "test-model",
        usage: {
          inputTokens: 0,
          outputTokens: 0
        }
      };
    }
  },
  memory: {},
  client: {
    user: {
      id: botUserId,
      username: "clanky"
    },
    guilds: {
      cache: new Map()
    },
    channels: {
      cache: {
        get(id: string) {
          return id === channelId ? channel : undefined;
        }
      }
    }
  },
  botUserId,
  discovery: null,
  search: null,
  initiativeCycleRunning: false,
  getPendingInitiativeThoughts() {
    return pendingThoughts;
  },
  getPendingInitiativeThought(guildId: string) {
    return pendingThoughts.get(guildId) || null;
  },
  setPendingInitiativeThought(guildId: string, thought: unknown) {
    if (!thought) {
      pendingThoughts.delete(guildId);
      return;
    }
    pendingThoughts.set(guildId, thought);
  },
  canSendMessage() {
    return true;
  },
  canTalkNow() {
    return true;
  },
  async hydrateRecentMessages() {
    return [];
  },
  isChannelAllowed() {
    return true;
  },
  isNonPrivateReplyEligibleChannel() {
    return true;
  },
  getSimulatedTypingDelayMs() {
    return 0;
  },
  markSpoke() {},
  composeMessageContentForHistory() {
    return "";
  },
  async loadRelevantMemoryFacts() {
    return [];
  },
  buildMediaMemoryFacts() {
    return [];
  },
  getImageBudgetState() {
    return { canGenerate: false, remaining: 0 };
  },
  getVideoGenerationBudgetState() {
    return { canGenerate: false, remaining: 0 };
  },
  getGifBudgetState() {
    return { canFetch: false, remaining: 0 };
  },
  getMediaGenerationCapabilities() {
    return {
      simpleImageReady: false,
      complexImageReady: false,
      videoReady: false
    };
  },
  async resolveMediaAttachment(payload: { text: string }) {
    return {
      payload: {
        content: payload.text
      },
      media: null
    };
  },
  buildBrowserBrowseContext() {
    return {
      enabled: false,
      configured: false,
      budget: {
        canBrowse: false
      }
    };
  },
  async runModelRequestedBrowserBrowse() {
    return {
      used: false,
      text: "",
      steps: 0,
      hitStepLimit: false,
      error: null,
      blockedByBudget: false
    };
  }
} as Parameters<typeof maybeRunInitiativeCycle>[0];

await maybeRunInitiativeCycle(runtime);

assert.equal(llmCalls, 1);
assert.equal(sendCount, 1);
assert.equal(pendingThoughts.size, 0);
const since = new Date(Date.now() - 5 * 60_000).toISOString();
assert.equal(store.countActionsSince("initiative_post", since), 1);

}); });

test("maybeRunInitiativeCycle can post a fresh thought in another guild while a pending thought exists elsewhere", async () => { await withTempStore(async (store) => { const guildOneId = "guild-1"; const guildTwoId = "guild-2"; const channelOneId = "channel-1"; const channelTwoId = "channel-2"; const botUserId = "bot-1"; const pendingThoughts = new Map(); const sentChannelIds: string[] = [];

store.patchSettings(createTestSettingsPatch({
  permissions: {
    replies: {
      allowReplies: true,
      allowUnsolicitedReplies: true,
      allowReactions: false,
      replyChannelIds: [channelOneId, channelTwoId],
      discoveryChannelIds: [channelOneId, channelTwoId],
      allowedChannelIds: [channelOneId, channelTwoId],
      blockedChannelIds: [],
      blockedUserIds: [],
      maxMessagesPerHour: 100,
      maxReactionsPerHour: 0
    }
  },
  memory: {
    enabled: false
  },
  initiative: {
    text: {
      enabled: true,
      eagerness: 100,
      minMinutesBetweenPosts: 60,
      maxPostsPerDay: 3,
      lookbackMessages: 12,
      allowActiveCuriosity: false,
      maxToolSteps: 0,
      maxToolCalls: 0
    }
  }
}));

store.recordMessage({
  messageId: "msg-1",
  createdAt: Date.now() - 2_000,
  guildId: guildOneId,
  channelId: channelOneId,
  authorId: "user-1",
  authorName: "alice",
  isBot: false,
  content: "still chewing on the earlier topic",
  referencedMessageId: null
});
store.recordMessage({
  messageId: "msg-2",
  createdAt: Date.now() - 1_000,
  guildId: guildTwoId,
  channelId: channelTwoId,
  authorId: "user-2",
  authorName: "bob",
  isBot: false,
  content: "yo does anyone have a weird fact",
  referencedMessageId: null
});

pendingThoughts.set(guildOneId, {
  id: "thought-1",
  guildId: guildOneId,
  channelId: channelOneId,
  channelName: "general-one",
  trigger: "timer",
  draftText: "maybe bring back the earlier topic",
  currentText: "maybe bring back the earlier topic",
  createdAt: Date.now() - 60_000,
  updatedAt: Date.now() - 60_000,
  basisAt: Date.now() - 60_000,
  notBeforeAt: 0,
  expiresAt: Date.now() + 60_000,
  revision: 1,
  status: "queued",
  lastDecisionReason: "timing felt off",
  lastDecisionAction: "hold",
  mediaDirective: "none",
  mediaPrompt: null
});

const channelOne = {
  id: channelOneId,
  guildId: guildOneId,
  name: "general-one",
  guild: {
    id: guildOneId
  },
  isTextBased() {
    return true;
  },
  async sendTyping() {
    return true;
  },
  async send(payload: { content: string }) {
    sentChannelIds.push(`${channelOneId}:${payload.content}`);
    return {
      id: "sent-1",
      createdTimestamp: Date.now(),
      guildId: guildOneId,
      channelId: channelOneId,
      content: payload.content
    };
  }
};
const channelTwo = {
  id: channelTwoId,
  guildId: guildTwoId,
  name: "general-two",
  guild: {
    id: guildTwoId
  },
  isTextBased() {
    return true;
  },
  async sendTyping() {
    return true;
  },
  async send(payload: { content: string }) {
    sentChannelIds.push(`${channelTwoId}:${payload.content}`);
    return {
      id: "sent-2",
      createdTimestamp: Date.now(),
      guildId: guildTwoId,
      channelId: channelTwoId,
      content: payload.content
    };
  }
};

const runtime = {
  appConfig: { env: "test" },
  store,
  llm: {
    async generate() {
      return {
        text: JSON.stringify({
          action: "post_now",
          channelId: channelTwoId,
          text: "weird fact drop: wombats have cube poop",
          mediaDirective: "none",
          mediaPrompt: null,
          reason: "fresh_room"
        }),
        toolCalls: [],
        rawContent: null,
        provider: "test",
        model: "test-model",
        usage: {
          inputTokens: 0,
          outputTokens: 0
        }
      };
    }
  },
  memory: {},
  client: {
    user: {
      id: botUserId,
      username: "clanky"
    },
    guilds: {
      cache: new Map()
    },
    channels: {
      cache: {
        get(id: string) {
          if (id === channelOneId) return channelOne;
          if (id === channelTwoId) return channelTwo;
          return undefined;
        }
      }
    }
  },
  botUserId,
  discovery: null,
  search: null,
  initiativeCycleRunning: false,
  getPendingInitiativeThoughts() {
    return pendingThoughts;
  },
  getPendingInitiativeThought(guildId: string) {
    return pendingThoughts.get(guildId) || null;
  },
  setPendingInitiativeThought(guildId: string, thought: unknown) {
    if (!thought) {
      pendingThoughts.delete(guildId);
      return;
    }
    pendingThoughts.set(guildId, thought);
  },
  canSendMessage() {
    return true;
  },
  canTalkNow() {
    return true;
  },
  async hydrateRecentMessages() {
    return [];
  },
  isChannelAllowed() {
    return true;
  },
  isNonPrivateReplyEligibleChannel() {
    return true;
  },
  getSimulatedTypingDelayMs() {
    return 0;
  },
  markSpoke() {},
  composeMessageContentForHistory() {
    return "";
  },
  async loadRelevantMemoryFacts() {
    return [];
  },
  buildMediaMemoryFacts() {
    return [];
  },
  getImageBudgetState() {
    return { canGenerate: false, remaining: 0 };
  },
  getVideoGenerationBudgetState() {
    return { canGenerate: false, remaining: 0 };
  },
  getGifBudgetState() {
    return { canFetch: false, remaining: 0 };
  },
  getMediaGenerationCapabilities() {
    return {
      simpleImageReady: false,
      complexImageReady: false,
      videoReady: false
    };
  },
  async resolveMediaAttachment(payload: { text: string }) {
    return {
      payload: {
        content: payload.text
      },
      media: null
    };
  },
  buildBrowserBrowseContext() {
    return {
      enabled: false,
      configured: false,
      budget: {
        canBrowse: false
      }
    };
  },
  async runModelRequestedBrowserBrowse() {
    return {
      used: false,
      text: "",
      steps: 0,
      hitStepLimit: false,
      error: null,
      blockedByBudget: false
    };
  }
} as Parameters<typeof maybeRunInitiativeCycle>[0];

await maybeRunInitiativeCycle(runtime);

assert.deepEqual(sentChannelIds, [`${channelTwoId}:weird fact drop: wombats have cube poop`]);
assert.equal(pendingThoughts.has(guildOneId), true);
assert.equal(pendingThoughts.has(guildTwoId), false);

}); });

test("maybeRunInitiativeCycle preserves a pending thought on structured contract violations", async () => { await withTempStore(async (store) => { const guildId = "guild-1"; const channelId = "channel-1"; const botUserId = "bot-1"; const pendingThoughts = new Map();

store.patchSettings(createTestSettingsPatch({
  permissions: {
    replies: {
      allowReplies: true,
      allowUnsolicitedReplies: true,
      allowReactions: false,
      replyChannelIds: [channelId],
      discoveryChannelIds: [channelId],
      allowedChannelIds: [channelId],
      blockedChannelIds: [],
      blockedUserIds: [],
      maxMessagesPerHour: 100,
      maxReactionsPerHour: 0
    }
  },
  memory: {
    enabled: false
  },
  initiative: {
    text: {
      enabled: true,
      eagerness: 100,
      minMinutesBetweenPosts: 60,
      maxPostsPerDay: 3,
      lookbackMessages: 12,
      allowActiveCuriosity: false,
      maxToolSteps: 0,
      maxToolCalls: 0
    }
  }
}));

store.recordMessage({
  messageId: "msg-1",
  createdAt: Date.now() - 1_000,
  guildId,
  channelId,
  authorId: "user-1",
  authorName: "alice",
  isBot: false,
  content: "maybe bring that bit back later",
  referencedMessageId: null
});

pendingThoughts.set(guildId, {
  id: "thought-1",
  guildId,
  channelId,
  channelName: "general",
  trigger: "timer",
  draftText: "the bit might work later",
  currentText: "the bit might work later",
  createdAt: Date.now() - 30_000,
  updatedAt: Date.now() - 30_000,
  basisAt: Date.now() - 30_000,
  notBeforeAt: 0,
  expiresAt: Date.now() + 60_000,
  revision: 1,
  status: "queued",
  lastDecisionReason: "timing felt off",
  lastDecisionAction: "hold",
  mediaDirective: "none",
  mediaPrompt: null
});

const channel = {
  id: channelId,
  guildId,
  name: "general",
  guild: {
    id: guildId
  },
  isTextBased() {
    return true;
  },
  async sendTyping() {
    return true;
  },
  async send() {
    throw new Error("contract-violation revisit should not send a message");
  }
};

const runtime = {
  appConfig: { env: "test" },
  store,
  llm: {
    async generate() {
      return {
        text: JSON.stringify({
          action: "hold",
          channelId,
          reason: "later"
        }),
        toolCalls: [],
        rawContent: null,
        provider: "test",
        model: "test-model",
        usage: {
          inputTokens: 0,
          outputTokens: 0
        }
      };
    }
  },
  memory: {},
  client: {
    user: {
      id: botUserId,
      username: "clanky"
    },
    guilds: {
      cache: new Map()
    },
    channels: {
      cache: {
        get(id: string) {
          return id === channelId ? channel : undefined;
        }
      }
    }
  },
  botUserId,
  discovery: null,
  search: null,
  initiativeCycleRunning: false,
  getPendingInitiativeThoughts() {
    return pendingThoughts;
  },
  getPendingInitiativeThought(guildId: string) {
    return pendingThoughts.get(guildId) || null;
  },
  setPendingInitiativeThought(guildId: string, thought: unknown) {
    if (!thought) {
      pendingThoughts.delete(guildId);
      return;
    }
    pendingThoughts.set(guildId, thought);
  },
  canSendMessage() {
    return true;
  },
  canTalkNow() {
    return true;
  },
  async hydrateRecentMessages() {
    return [];
  },
  isChannelAllowed() {
    return true;
  },
  isNonPrivateReplyEligibleChannel() {
    return true;
  },
  getSimulatedTypingDelayMs() {
    return 0;
  },
  markSpoke() {},
  composeMessageContentForHistory() {
    return "";
  },
  async loadRelevantMemoryFacts() {
    return [];
  },
  buildMediaMemoryFacts() {
    return [];
  },
  getImageBudgetState() {
    return { canGenerate: false, remaining: 0 };
  },
  getVideoGenerationBudgetState() {
    return { canGenerate: false, remaining: 0 };
  },
  getGifBudgetState() {
    return { canFetch: false, remaining: 0 };
  },
  getMediaGenerationCapabilities() {
    return {
      simpleImageReady: false,
      complexImageReady: false,
      videoReady: false
    };
  },
  async resolveMediaAttachment() {
    throw new Error("contract-violation revisit should not resolve media");
  },
  buildBrowserBrowseContext() {
    return {
      enabled: false,
      configured: false,
      budget: {
        canBrowse: false
      }
    };
  },
  async runModelRequestedBrowserBrowse() {
    return {
      used: false,
      text: "",
      steps: 0,
      hitStepLimit: false,
      error: null,
      blockedByBudget: false
    };
  }
} as Parameters<typeof maybeRunInitiativeCycle>[0];

await maybeRunInitiativeCycle(runtime);

assert.equal(pendingThoughts.size, 1);
assert.equal(pendingThoughts.get(guildId)?.id, "thought-1");
assert.equal(pendingThoughts.get(guildId)?.revision, 1);
const since = new Date(Date.now() - 5 * 60_000).toISOString();
assert.equal(store.countActionsSince("initiative_skip", since), 1);

}); });