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
5 changes: 5 additions & 0 deletions .changeset/slack-thread-last-agent-reply-own-bot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Fix Slack `threadContext: { since: "last-agent-reply" }` treating every bot's message as the agent's own. In a thread where another Slack app also posts, the boundary was cut at the other bot's last message, silently dropping context (and sometimes leaving the agent with none). The channel now resolves its own bot id via `auth.test` and only marks the agent's own replies as the boundary.
50 changes: 50 additions & 0 deletions packages/eve/src/public/channels/slack/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ function buildFetchMock(): { fetch: ReturnType<typeof vi.fn>; calls: FetchCall[]
{ headers: { "content-type": "application/json" } },
);
}
if (url === "https://slack.com/api/auth.test") {
return new Response(JSON.stringify({ ok: true, bot_id: "B01", user_id: "U_BOT" }), {
headers: { "content-type": "application/json" },
});
}
if (url === "https://slack.com/api/conversations.replies") {
return new Response(
JSON.stringify({
Expand Down Expand Up @@ -446,6 +451,51 @@ describe("SlackThread.refresh", () => {
expect("author" in firstMessage).toBe(false);
expect("metadata" in firstMessage).toBe(false);
});

it("marks only the agent's own bot messages as isMe, not other apps", async () => {
// auth.test resolves our own bot id; a different bot in the thread must
// not count as the agent so "last-agent-reply" context isn't cut short.
const fetch = vi.fn(async (input: string | URL | Request) => {
const url = String(input);
if (url === "https://slack.com/api/auth.test") {
return new Response(JSON.stringify({ ok: true, bot_id: "B_SELF", user_id: "U_SELF" }), {
headers: { "content-type": "application/json" },
});
}
if (url === "https://slack.com/api/conversations.replies") {
return new Response(
JSON.stringify({
ok: true,
messages: [
{ text: "hi", ts: "1.000001", thread_ts: "1.000001", user: "U01" },
{ text: "our reply", ts: "1.000002", thread_ts: "1.000001", bot_id: "B_SELF" },
{ text: "other bot", ts: "1.000003", thread_ts: "1.000001", bot_id: "B_OTHER" },
],
}),
{ headers: { "content-type": "application/json" } },
);
}
return new Response(JSON.stringify({ ok: true }), {
headers: { "content-type": "application/json" },
});
});
vi.stubGlobal("fetch", fetch);

const { thread } = buildSlackBinding({
botToken: "xoxb-test",
channelId: "C01",
threadTs: "1.000001",
teamId: undefined,
});

await thread.refresh();

expect(thread.recentMessages.map((m) => [m.botId, m.isMe])).toEqual([
[undefined, false],
["B_SELF", true],
["B_OTHER", false],
]);
});
});

describe("SlackThread.postEphemeral", () => {
Expand Down
48 changes: 40 additions & 8 deletions packages/eve/src/public/channels/slack/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,10 @@ export interface SlackThreadMessage {
readonly botId: string | undefined;
readonly ts: string;
readonly threadTs: string;
/**
* True only when this message was posted by the agent's own Slack app.
* Messages from other bots have `botId` set but are not `isMe`.
*/
readonly isMe: boolean;
readonly raw: Record<string, unknown>;
}
Expand Down Expand Up @@ -343,6 +347,30 @@ export function buildSlackBinding(input: {
input.onThreadTsChanged?.(ts);
}

// Slack thread replies expose only a `bot_id` per message, so telling the
// agent's own replies apart from other apps' bots requires knowing our own
// bot id. Resolve it once per binding via `auth.test`; on failure we treat
// no message as agent-authored (safer to over-include thread context than to
// silently cut it at another bot's message).
let ownBotIdPromise: Promise<string | undefined> | undefined;
function resolveOwnBotId(): Promise<string | undefined> {
if (ownBotIdPromise) return ownBotIdPromise;
const promise = (async () => {
try {
const response = await request("auth.test", {});
return typeof response.bot_id === "string" ? response.bot_id : undefined;
} catch (error) {
logError(log, "auth.test threw — cannot identify own bot for thread context", error, {
channelId: input.channelId,
});
ownBotIdPromise = undefined;
return undefined;
}
})();
ownBotIdPromise = promise;
return promise;
}

async function uploadFiles(
files: readonly FileUpload[],
options?: SlackUploadFilesOptions,
Expand Down Expand Up @@ -441,14 +469,17 @@ export function buildSlackBinding(input: {
messages.length = 0;
if (!input.channelId || !currentThreadTs) return;
try {
const response = await fetchSlackThreadReplies({
...createSlackApiOptions(input.botToken),
channel: input.channelId,
limit: 50,
ts: currentThreadTs,
});
const [response, ownBotId] = await Promise.all([
fetchSlackThreadReplies({
...createSlackApiOptions(input.botToken),
channel: input.channelId,
limit: 50,
ts: currentThreadTs,
}),
resolveOwnBotId(),
]);
for (const raw of response.messages as Record<string, unknown>[]) {
messages.push(parseThreadMessage(raw, currentThreadTs));
messages.push(parseThreadMessage(raw, currentThreadTs, ownBotId));
}
} catch (error) {
logError(log, "refresh threw — swallowed", error, { channelId: input.channelId });
Expand Down Expand Up @@ -552,6 +583,7 @@ function normalizeFileData(data: FileUpload["data"]): SlackFileUpload["data"] {
function parseThreadMessage(
raw: Record<string, unknown>,
threadRootTs: string,
ownBotId: string | undefined,
): SlackThreadMessage {
const text = typeof raw.text === "string" ? raw.text : "";
const ts = typeof raw.ts === "string" ? raw.ts : "";
Expand All @@ -565,7 +597,7 @@ function parseThreadMessage(
botId,
ts,
threadTs,
isMe: botId !== undefined,
isMe: ownBotId !== undefined && botId === ownBotId,
raw,
};
}
Loading