Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions src/channels/__tests__/interaction-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, expect, mock, test } from "bun:test";
import {
type ChannelInteractionFactory,
type ChannelInteractionInstance,
ChannelInteractionRegistry,
} from "../interaction-adapter.ts";
import type { InboundMessage } from "../types.ts";

function makeMessage(overrides: Partial<InboundMessage> = {}): InboundMessage {
return {
id: "msg-1",
channelId: "test",
conversationId: "test:conv-1",
senderId: "user-1",
text: "hello",
timestamp: new Date(),
...overrides,
};
}

describe("ChannelInteractionRegistry", () => {
test("starts empty", () => {
const registry = new ChannelInteractionRegistry();
expect(registry.size()).toBe(0);
});

test("buildFor returns empty array when no factories registered", () => {
const registry = new ChannelInteractionRegistry();
const instances = registry.buildFor(makeMessage());
expect(instances).toEqual([]);
});

test("registers factories and reports size", () => {
const registry = new ChannelInteractionRegistry();
registry.register(() => null);
registry.register(() => null);
expect(registry.size()).toBe(2);
});

test("buildFor calls each factory with the message", () => {
const registry = new ChannelInteractionRegistry();
const factoryA = mock((_msg: InboundMessage) => null);
const factoryB = mock((_msg: InboundMessage) => null);
registry.register(factoryA);
registry.register(factoryB);

const msg = makeMessage({ channelId: "slack" });
registry.buildFor(msg);

expect(factoryA).toHaveBeenCalledWith(msg);
expect(factoryB).toHaveBeenCalledWith(msg);
});

test("buildFor returns only non-null instances in registration order", () => {
const registry = new ChannelInteractionRegistry();
const instanceA: ChannelInteractionInstance = { dispose: () => {} };
const instanceC: ChannelInteractionInstance = { dispose: () => {} };

// A returns instance, B opts out (null), C returns instance
registry.register(() => instanceA);
registry.register(() => null);
registry.register(() => instanceC);

const instances = registry.buildFor(makeMessage());
expect(instances).toEqual([instanceA, instanceC]);
});

test("factory can decide based on the message channelId", () => {
const registry = new ChannelInteractionRegistry();
const slackInstance: ChannelInteractionInstance = { dispose: () => {} };

const slackFactory: ChannelInteractionFactory = (msg) => (msg.channelId === "slack" ? slackInstance : null);
registry.register(slackFactory);

const slackMsg = makeMessage({ channelId: "slack" });
const telegramMsg = makeMessage({ channelId: "telegram" });

expect(registry.buildFor(slackMsg)).toEqual([slackInstance]);
expect(registry.buildFor(telegramMsg)).toEqual([]);
});

test("clearForTests empties the registry", () => {
const registry = new ChannelInteractionRegistry();
registry.register(() => null);
registry.register(() => null);
expect(registry.size()).toBe(2);
registry.clearForTests();
expect(registry.size()).toBe(0);
});

test("instances may have any subset of optional hooks", () => {
const registry = new ChannelInteractionRegistry();
// All hooks present
const fullInstance: ChannelInteractionInstance = {
onTurnStart: () => {},
onRuntimeEvent: () => {},
onTurnEnd: () => {},
deliverResponse: () => false,
dispose: () => {},
};
// Only one hook
const minimalInstance: ChannelInteractionInstance = {
dispose: () => {},
};
// Truly empty
const emptyInstance: ChannelInteractionInstance = {};

registry.register(() => fullInstance);
registry.register(() => minimalInstance);
registry.register(() => emptyInstance);

const instances = registry.buildFor(makeMessage());
expect(instances.length).toBe(3);
expect(instances[0].onTurnStart).toBeDefined();
expect(instances[1].onTurnStart).toBeUndefined();
expect(instances[2].dispose).toBeUndefined();
});
});
207 changes: 207 additions & 0 deletions src/channels/__tests__/slack-interaction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { describe, expect, mock, test } from "bun:test";
import { createSlackInteractionFactory } from "../slack-interaction.ts";
import type { InboundMessage } from "../types.ts";

// Minimal SlackChannel mock — only the methods slack-interaction.ts touches.
function makeMockSlackChannel() {
const calls = {
addReaction: [] as Array<{ ch: string; ts: string; emoji: string }>,
removeReaction: [] as Array<{ ch: string; ts: string; emoji: string }>,
postThinking: [] as Array<{ ch: string; threadTs: string }>,
updateMessage: [] as Array<{ ch: string; ts: string; text: string }>,
updateWithFeedback: [] as Array<{ ch: string; ts: string; text: string }>,
};

let nextThinkingTs: string | null = "thinking-ts";

const channel = {
addReaction: mock(async (ch: string, ts: string, emoji: string) => {
calls.addReaction.push({ ch, ts, emoji });
}),
removeReaction: mock(async (ch: string, ts: string, emoji: string) => {
calls.removeReaction.push({ ch, ts, emoji });
}),
postThinking: mock(async (ch: string, threadTs: string) => {
calls.postThinking.push({ ch, threadTs });
return nextThinkingTs;
}),
updateMessage: mock(async (ch: string, ts: string, text: string) => {
calls.updateMessage.push({ ch, ts, text });
}),
updateWithFeedback: mock(async (ch: string, ts: string, text: string) => {
calls.updateWithFeedback.push({ ch, ts, text });
}),
};

return {
channel: channel as unknown as Parameters<typeof createSlackInteractionFactory>[0],
calls,
setNextThinkingTs(value: string | null): void {
nextThinkingTs = value;
},
};
}

function makeSlackMessage(overrides: Partial<InboundMessage["metadata"]> = {}): InboundMessage {
return {
id: "msg-id",
channelId: "slack",
conversationId: "slack:C123:ts-1",
senderId: "U123",
text: "hello",
timestamp: new Date(),
metadata: {
slackChannel: "C123",
slackThreadTs: "ts-1",
slackMessageTs: "ts-1",
...overrides,
},
};
}

describe("createSlackInteractionFactory", () => {
test("returns null when slackChannel is null", () => {
const factory = createSlackInteractionFactory(null);
expect(factory(makeSlackMessage())).toBeNull();
});

test("returns null for non-slack messages", () => {
const { channel } = makeMockSlackChannel();
const factory = createSlackInteractionFactory(channel);

const nonSlack: InboundMessage = {
id: "x",
channelId: "telegram",
conversationId: "telegram:1",
senderId: "u",
text: "hi",
timestamp: new Date(),
metadata: { telegramChatId: 42 },
};

expect(factory(nonSlack)).toBeNull();
});

test("returns null for slack messages without metadata", () => {
const { channel } = makeMockSlackChannel();
const factory = createSlackInteractionFactory(channel);

const noMeta: InboundMessage = {
id: "x",
channelId: "slack",
conversationId: "slack:C:ts",
senderId: "u",
text: "hi",
timestamp: new Date(),
};
expect(factory(noMeta)).toBeNull();
});

test("creates an instance with both statusReactions and progressStream when metadata is complete", () => {
const { channel } = makeMockSlackChannel();
const factory = createSlackInteractionFactory(channel);

const instance = factory(makeSlackMessage());
expect(instance).not.toBeNull();
expect(instance?.statusReactions).toBeDefined();
expect(instance?.progressStream).toBeDefined();
});

test("setQueued is fired immediately on instance creation", async () => {
const { channel, calls } = makeMockSlackChannel();
const factory = createSlackInteractionFactory(channel);

factory(makeSlackMessage());
// Allow the setQueued microtask + adapter promise chain to flush
await new Promise((r) => setTimeout(r, 50));
expect(calls.addReaction.some((c) => c.emoji === "eyes")).toBe(true);
});

test("onTurnStart starts the progress stream", async () => {
const { channel, calls } = makeMockSlackChannel();
const factory = createSlackInteractionFactory(channel);

const instance = factory(makeSlackMessage());
await instance?.onTurnStart?.();
expect(calls.postThinking.length).toBe(1);
expect(calls.postThinking[0]).toEqual({ ch: "C123", threadTs: "ts-1" });
});

test("onRuntimeEvent thinking sets thinking emoji on the user message", async () => {
const { channel, calls } = makeMockSlackChannel();
const factory = createSlackInteractionFactory(channel);

const instance = factory(makeSlackMessage());
instance?.onRuntimeEvent?.({ type: "thinking" });
await new Promise((r) => setTimeout(r, 600)); // debounce is 500ms
expect(calls.addReaction.some((c) => c.emoji === "brain")).toBe(true);
});

test("onRuntimeEvent tool_use updates both reactions and progress activity", async () => {
const { channel, calls } = makeMockSlackChannel();
const factory = createSlackInteractionFactory(channel);

const instance = factory(makeSlackMessage());
await instance?.onTurnStart?.();
instance?.onRuntimeEvent?.({
type: "tool_use",
tool: "Read",
input: { file_path: "/x.ts" },
});
await new Promise((r) => setTimeout(r, 1200)); // progress throttle is 1000ms
expect(calls.updateMessage.length).toBeGreaterThanOrEqual(1);
const wroteActivity = calls.updateMessage.some((c) => c.text.includes("Reading /x.ts"));
expect(wroteActivity).toBe(true);
});

test("onRuntimeEvent error sets error reaction", async () => {
const { channel, calls } = makeMockSlackChannel();
const factory = createSlackInteractionFactory(channel);

const instance = factory(makeSlackMessage());
instance?.onRuntimeEvent?.({ type: "error", message: "boom" });
await new Promise((r) => setTimeout(r, 50));
expect(calls.addReaction.some((c) => c.emoji === "warning")).toBe(true);
});

test("deliverResponse uses progressStream.finish path when stream is active", async () => {
const { channel, calls } = makeMockSlackChannel();
const factory = createSlackInteractionFactory(channel);

const instance = factory(makeSlackMessage());
await instance?.onTurnStart?.();
const claimed = await instance?.deliverResponse?.({ text: "Final answer", isError: false });
expect(claimed).toBe(true);
expect(calls.updateWithFeedback.length).toBe(1);
expect(calls.updateWithFeedback[0].text).toBe("Final answer");
});

test("deliverResponse uses post-then-update fallback when no progress stream", async () => {
const { channel, calls } = makeMockSlackChannel();
const factory = createSlackInteractionFactory(channel);

// Message with no threadTs in metadata still has slackChannel + messageTs
// but progress stream requires both channel and threadTs to be set.
// We need a case where progressStream is undefined but the fallback path works.
// In practice this happens when slackThreadTs is missing.
const msgWithoutThread = makeSlackMessage({ slackThreadTs: undefined });
const instance = factory(msgWithoutThread);
expect(instance?.progressStream).toBeUndefined();

// The fallback path requires slackThreadTs to be defined, so without it,
// deliverResponse should not be able to claim. Verify: factory with no
// thread does not fall back through Slack delivery.
const claimed = await instance?.deliverResponse?.({ text: "F", isError: false });
expect(claimed).toBe(false);
expect(calls.updateWithFeedback.length).toBe(0);
});

test("dispose disposes the status reactions controller", () => {
const { channel } = makeMockSlackChannel();
const factory = createSlackInteractionFactory(channel);

const instance = factory(makeSlackMessage());
// dispose should not throw
expect(() => instance?.dispose?.()).not.toThrow();
});
});
Loading