src/bot/textCancelAcknowledgement.test.ts

import assert from "node:assert/strict"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { test } from "bun:test"; import { ClankerBot } from "../bot.ts"; import { Store } from "../store/store.ts"; import { rmTempDir } from "../testHelpers.ts"; import { createTestSettingsPatch } from "../testSettings.ts"; import { buildTextReplyScopeKey } from "../tools/activeReplyRegistry.ts"; import { buildBrowserTaskScopeKey } from "../tools/browserTaskRuntime.ts";

async function withTempStore(run: (store: Store) => Promise) { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clanker-text-cancel-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); } }

function configureStore(store: Store, channelId: string) { store.patchSettings(createTestSettingsPatch({ interaction: { activity: { minSecondsBetweenMessages: 0, replyCoalesceWindowSeconds: 0, replyCoalesceMaxMessages: 1 } }, permissions: { replies: { allowReplies: true, allowUnsolicitedReplies: false, allowReactions: true, replyChannelIds: [], allowedChannelIds: [channelId], blockedChannelIds: [], blockedUserIds: [], maxMessagesPerHour: 100, maxReactionsPerHour: 100 } }, memory: { enabled: false }, agentStack: { runtimeConfig: { research: { enabled: false, maxSearchesPerHour: 0 } } }, media: { videoContext: { enabled: false, maxLookupsPerHour: 0 } }, initiative: { discovery: { allowReplyImages: false, allowReplyVideos: false, allowReplyGifs: false } } })); }

function createIncomingMessage({ guildId, channelId, content, replyPayloads, reactionCalls }: { guildId: string; channelId: string; content: string; replyPayloads: Array<Record<string, unknown>>; reactionCalls: string[]; }) { const guild = { id: guildId, members: { cache: new Map() } }; const channel = { id: channelId, guildId, guild, isTextBased() { return true; } };

return { id: "msg-1", createdTimestamp: Date.now(), guildId, channelId, guild, channel, author: { id: "user-1", username: "alice", bot: false }, member: { displayName: "alice" }, content, reference: null, attachments: new Map(), embeds: [], async reply(payload: Record<string, unknown>) { replyPayloads.push(payload); return { id: bot-reply-${replyPayloads.length}, createdTimestamp: Date.now(), guildId, channelId, content: String(payload?.content || ""), attachments: new Map(), embeds: [] }; }, async react(emoji: string) { reactionCalls.push(emoji); } }; }

test("text cancel uses a model-generated acknowledgement after aborting active work", async () => { await withTempStore(async (store) => { const guildId = "guild-1"; const channelId = "chan-1"; configureStore(store, channelId);

const llmCalls: Array<Record<string, unknown>> = [];
const replyPayloads: Array<Record<string, unknown>> = [];
const reactionCalls: string[] = [];
const bot = new ClankerBot({
  appConfig: {},
  store,
  llm: {
    async generate(payload: Record<string, unknown>) {
      llmCalls.push(payload);
      return {
        text: "Sure, stopping there.",
        provider: "test",
        model: "test-model",
        usage: null,
        costUsd: 0
      };
    }
  },
  memory: null,
  discovery: null,
  search: null,
  gifs: null,
  video: null
});
bot.client.user = {
  id: "bot-1",
  username: "clanky",
  tag: "clanky#0001"
};

const replyScopeKey = buildTextReplyScopeKey({ guildId, channelId });
const activeReply = bot.activeReplies.begin(replyScopeKey, "text-reply");
const browserScopeKey = buildBrowserTaskScopeKey({ guildId, channelId });
const activeBrowserTask = bot.activeBrowserTasks.beginTask(browserScopeKey);
bot.replyQueues.set(channelId, [
  {
    message: {
      id: "queued-1"
    }
  }
]);
bot.replyQueuedMessageIds.add("queued-1");

const message = createIncomingMessage({
  guildId,
  channelId,
  content: "stop",
  replyPayloads,
  reactionCalls
});

try {
  await bot.handleMessage(message);

  assert.equal(activeReply.abortController.signal.aborted, true);
  assert.equal(activeBrowserTask.abortController.signal.aborted, true);
  assert.equal(bot.replyQueues.has(channelId), false);
  assert.equal(bot.replyQueuedMessageIds.has("queued-1"), false);
  assert.equal(replyPayloads.length, 1);
  assert.equal(replyPayloads[0]?.content, "Sure, stopping there.");
  assert.deepEqual(replyPayloads[0]?.allowedMentions, { repliedUser: false });
  assert.deepEqual(reactionCalls, []);
  assert.equal(llmCalls.length, 1);
  assert.match(String(llmCalls[0]?.userPrompt || ""), /queued repl/i);
  assert.match(String(llmCalls[0]?.userPrompt || ""), /active browser task/i);
} finally {
  await bot.stop();
}

}); });

test("text cancel falls back to a reaction when acknowledgement generation fails", async () => { await withTempStore(async (store) => { const guildId = "guild-1"; const channelId = "chan-1"; configureStore(store, channelId);

const replyPayloads: Array<Record<string, unknown>> = [];
const reactionCalls: string[] = [];
const bot = new ClankerBot({
  appConfig: {},
  store,
  llm: {
    async generate() {
      throw new Error("llm unavailable");
    }
  },
  memory: null,
  discovery: null,
  search: null,
  gifs: null,
  video: null
});
bot.client.user = {
  id: "bot-1",
  username: "clanky",
  tag: "clanky#0001"
};

const replyScopeKey = buildTextReplyScopeKey({ guildId, channelId });
bot.activeReplies.begin(replyScopeKey, "text-reply");
const message = createIncomingMessage({
  guildId,
  channelId,
  content: "nevermind",
  replyPayloads,
  reactionCalls
});

try {
  await bot.handleMessage(message);

  assert.deepEqual(replyPayloads, []);
  assert.deepEqual(reactionCalls, ["🛑"]);
} finally {
  await bot.stop();
}

}); });