Skip to content
Closed
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
72 changes: 72 additions & 0 deletions app/channel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,31 @@ function makeChannel(options: { agent?: FakeAgent } = {}) {
return { adapter, agent, channel };
}

/** Turn dedup keys off `logicalMessageId`/`revisionId`, so each turn needs its own id. */
function messageOperation(id: string, mentioned: boolean) {
return {
kind: "created" as const,
logicalMessageId: id,
revisionId: id,
mentioned,
};
}

/**
* Count agent runs from inside the script rather than from a field on the agent:
* every turn runs on a *clone*, and `FakeAgent.clone()` copies the remaining
* script but resets its own counters, so instance state under-reports.
*/
function countingAgent(maxRuns = 5) {
const runs: string[] = [];
const agent = new FakeAgent(
Array.from({ length: maxRuns }, () => async () => {
runs.push("run");
}),
);
return { agent, runs };
}

describe("createOpenTagChannel", () => {
it("declares one managed Channel and retains app commands", () => {
const channel = createOpenTagChannel("custom-channel", new FakeAgent());
Expand Down Expand Up @@ -196,6 +221,53 @@ describe("createOpenTagChannel", () => {
]);
});

it("stays quiet in a conversation that never addressed it", async () => {
const { agent, runs } = countingAgent();
const { adapter, channel } = makeChannel({ agent });

await channel.ɵruntime.start();
await adapter.getSink().onTurn({
conversationKey: "c1",
replyTarget: {},
userText: "chatter between two humans",
platform: "slack",
operation: messageOperation("m1", false),
});

expect(runs).toHaveLength(0);
});

it("answers follow-ups once a mention subscribes that conversation", async () => {
const { agent, runs } = countingAgent();
const { adapter, channel } = makeChannel({ agent });

await channel.ɵruntime.start();
await adapter.getSink().onTurn({
conversationKey: "c1",
replyTarget: {},
userText: "@opentag triage my issues",
platform: "slack",
operation: messageOperation("m1", true),
});
await adapter.getSink().onTurn({
conversationKey: "c1",
replyTarget: {},
userText: "and the second one?",
platform: "slack",
operation: messageOperation("m2", false),
});
// Subscriptions are per conversation, so an unrelated one stays quiet.
await adapter.getSink().onTurn({
conversationKey: "c2",
replyTarget: {},
userText: "unrelated chatter",
platform: "slack",
operation: messageOperation("m3", false),
});

expect(runs).toHaveLength(2);
});

it("injects managed content parts as the current agent prompt", async () => {
const { adapter, agent, channel } = makeChannel();
const parts = [{ type: "text" as const, text: "from content parts" }];
Expand Down
17 changes: 16 additions & 1 deletion app/channel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
createChannel,
type Channel,
type ChannelHandler,
type CreateChannelOptions,
} from "@copilotkit/channels";
import {
Expand Down Expand Up @@ -33,7 +34,7 @@ export function createOpenTagChannel(
components: [IssueCard, IssueList, PageList, IncidentCard, ConfirmWrite],
});

channel.onMention(async ({ thread, message }) => {
const runTurn: ChannelHandler = async ({ thread, message }) => {
try {
await thread.runAgent(managedRunInput(message));
} catch (error) {
Expand All @@ -55,6 +56,20 @@ export function createOpenTagChannel(
recovery: "posted_user_facing_error",
});
}
};

// Being addressed joins the conversation; from then on OpenTag answers every
// message in it. Dispatch is exclusive — a mentioned turn goes to `onMention`
// whenever any is registered — so the mention that subscribes runs once.
channel.onMention(async (ctx) => {
await ctx.thread.subscribe();
await runTurn(ctx);
});

// Without the `isSubscribed` guard this answers every message in every
// conversation the bot can see, including ones it was never invited into.
channel.onMessage(async (ctx) => {
if (await ctx.thread.isSubscribed()) await runTurn(ctx);
});

channel.onModalSubmit(FILE_ISSUE_CALLBACK, fileIssueSubmit);
Expand Down