fix: scope thread sessions and discord starter fetch
This commit is contained in:
@@ -33,6 +33,7 @@
|
|||||||
- Gateway/CLI: add daemon runtime selection (Node recommended; Bun optional) and document WhatsApp/Baileys Bun WebSocket instability on reconnect.
|
- Gateway/CLI: add daemon runtime selection (Node recommended; Bun optional) and document WhatsApp/Baileys Bun WebSocket instability on reconnect.
|
||||||
- CLI: add `clawdbot docs` live docs search with pretty output.
|
- CLI: add `clawdbot docs` live docs search with pretty output.
|
||||||
- CLI: add `clawdbot agents` (list/add/delete) with wizarded workspace/setup, provider login, and full prune on delete.
|
- CLI: add `clawdbot agents` (list/add/delete) with wizarded workspace/setup, provider login, and full prune on delete.
|
||||||
|
- Discord/Slack: fork thread sessions and inject thread starters for context. Thanks @thewilloftheshadow for PR #400.
|
||||||
- Agent: treat compaction retry AbortError as a fallback trigger without swallowing non-abort errors. Thanks @erikpr1994 for PR #341.
|
- Agent: treat compaction retry AbortError as a fallback trigger without swallowing non-abort errors. Thanks @erikpr1994 for PR #341.
|
||||||
- Agent: add opt-in session pruning for tool results to reduce context bloat. Thanks @maxsumrall for PR #381.
|
- Agent: add opt-in session pruning for tool results to reduce context bloat. Thanks @maxsumrall for PR #381.
|
||||||
- Agent: protect bootstrap prefix from context pruning. Thanks @maxsumrall for PR #381.
|
- Agent: protect bootstrap prefix from context pruning. Thanks @maxsumrall for PR #381.
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ describe("initSessionState thread forking", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const storePath = path.join(root, "sessions.json");
|
const storePath = path.join(root, "sessions.json");
|
||||||
const parentSessionKey = "slack:channel:C1";
|
const parentSessionKey = "agent:main:slack:channel:C1";
|
||||||
await saveSessionStore(storePath, {
|
await saveSessionStore(storePath, {
|
||||||
[parentSessionKey]: {
|
[parentSessionKey]: {
|
||||||
sessionId: parentSessionId,
|
sessionId: parentSessionId,
|
||||||
@@ -52,7 +52,7 @@ describe("initSessionState thread forking", () => {
|
|||||||
session: { store: storePath },
|
session: { store: storePath },
|
||||||
} as ClawdbotConfig;
|
} as ClawdbotConfig;
|
||||||
|
|
||||||
const threadSessionKey = "slack:thread:C1:123";
|
const threadSessionKey = "agent:main:slack:channel:C1:thread:123";
|
||||||
const threadLabel = "Slack thread #general: starter";
|
const threadLabel = "Slack thread #general: starter";
|
||||||
const result = await initSessionState({
|
const result = await initSessionState({
|
||||||
ctx: {
|
ctx: {
|
||||||
@@ -70,7 +70,10 @@ describe("initSessionState thread forking", () => {
|
|||||||
expect(result.sessionEntry.sessionFile).toBeTruthy();
|
expect(result.sessionEntry.sessionFile).toBeTruthy();
|
||||||
expect(result.sessionEntry.displayName).toBe(threadLabel);
|
expect(result.sessionEntry.displayName).toBe(threadLabel);
|
||||||
|
|
||||||
const newSessionFile = result.sessionEntry.sessionFile!;
|
const newSessionFile = result.sessionEntry.sessionFile;
|
||||||
|
if (!newSessionFile) {
|
||||||
|
throw new Error("Missing session file for forked thread");
|
||||||
|
}
|
||||||
const [headerLine] = (await fs.readFile(newSessionFile, "utf-8"))
|
const [headerLine] = (await fs.readFile(newSessionFile, "utf-8"))
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.filter((line) => line.trim().length > 0);
|
.filter((line) => line.trim().length > 0);
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ import {
|
|||||||
DEFAULT_IDLE_MINUTES,
|
DEFAULT_IDLE_MINUTES,
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
resolveAgentIdFromSessionKey,
|
resolveAgentIdFromSessionKey,
|
||||||
resolveSessionKey,
|
|
||||||
resolveSessionFilePath,
|
resolveSessionFilePath,
|
||||||
|
resolveSessionKey,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
saveSessionStore,
|
saveSessionStore,
|
||||||
|
|||||||
@@ -168,13 +168,8 @@ describe("discord tool result dispatch", () => {
|
|||||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||||
}, 10000);
|
}, 10000);
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
it("forks thread sessions and injects starter context", async () => {
|
it("forks thread sessions and injects starter context", async () => {
|
||||||
const { createDiscordMessageHandler } = await import("./monitor.js");
|
const { createDiscordMessageHandler } = await import("./monitor.js");
|
||||||
const { resolveSessionKey } = await import("../config/sessions.js");
|
|
||||||
vi.mocked(resolveSessionKey).mockReturnValue("discord:parent:p1");
|
|
||||||
|
|
||||||
let capturedCtx:
|
let capturedCtx:
|
||||||
| {
|
| {
|
||||||
SessionKey?: string;
|
SessionKey?: string;
|
||||||
@@ -239,6 +234,13 @@ describe("discord tool result dispatch", () => {
|
|||||||
type: ChannelType.GuildText,
|
type: ChannelType.GuildText,
|
||||||
name: "thread-name",
|
name: "thread-name",
|
||||||
}),
|
}),
|
||||||
|
rest: {
|
||||||
|
get: vi.fn().mockResolvedValue({
|
||||||
|
content: "starter message",
|
||||||
|
author: { id: "u1", username: "Alice", discriminator: "0001" },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
} as unknown as Client;
|
} as unknown as Client;
|
||||||
|
|
||||||
await handler(
|
await handler(
|
||||||
@@ -265,8 +267,8 @@ describe("discord tool result dispatch", () => {
|
|||||||
client,
|
client,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(capturedCtx?.SessionKey).toBe("discord:thread:t1");
|
expect(capturedCtx?.SessionKey).toBe("agent:main:discord:channel:t1");
|
||||||
expect(capturedCtx?.ParentSessionKey).toBe("discord:parent:p1");
|
expect(capturedCtx?.ParentSessionKey).toBe("agent:main:discord:channel:p1");
|
||||||
expect(capturedCtx?.ThreadStarterBody).toContain("starter message");
|
expect(capturedCtx?.ThreadStarterBody).toContain("starter message");
|
||||||
expect(capturedCtx?.ThreadLabel).toContain("Discord thread #general");
|
expect(capturedCtx?.ThreadLabel).toContain("Discord thread #general");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,11 +11,6 @@ import {
|
|||||||
MessageReactionRemoveListener,
|
MessageReactionRemoveListener,
|
||||||
MessageType,
|
MessageType,
|
||||||
type RequestClient,
|
type RequestClient,
|
||||||
type PartialMessage,
|
|
||||||
type PartialMessageReaction,
|
|
||||||
Partials,
|
|
||||||
type ThreadChannel,
|
|
||||||
type PartialUser,
|
|
||||||
type User,
|
type User,
|
||||||
} from "@buape/carbon";
|
} from "@buape/carbon";
|
||||||
import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway";
|
import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway";
|
||||||
@@ -56,7 +51,10 @@ import {
|
|||||||
readProviderAllowFromStore,
|
readProviderAllowFromStore,
|
||||||
upsertProviderPairingRequest,
|
upsertProviderPairingRequest,
|
||||||
} from "../pairing/pairing-store.js";
|
} from "../pairing/pairing-store.js";
|
||||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
import {
|
||||||
|
buildAgentSessionKey,
|
||||||
|
resolveAgentRoute,
|
||||||
|
} from "../routing/resolve-route.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { loadWebMedia } from "../web/media.js";
|
import { loadWebMedia } from "../web/media.js";
|
||||||
import { fetchDiscordApplicationId } from "./probe.js";
|
import { fetchDiscordApplicationId } from "./probe.js";
|
||||||
@@ -86,6 +84,12 @@ type DiscordHistoryEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type DiscordReactionEvent = Parameters<MessageReactionAddListener["handle"]>[0];
|
type DiscordReactionEvent = Parameters<MessageReactionAddListener["handle"]>[0];
|
||||||
|
type DiscordThreadChannel = {
|
||||||
|
id: string;
|
||||||
|
name?: string | null;
|
||||||
|
parentId?: string | null;
|
||||||
|
parent?: { id?: string; name?: string };
|
||||||
|
};
|
||||||
type DiscordThreadStarter = {
|
type DiscordThreadStarter = {
|
||||||
text: string;
|
text: string;
|
||||||
author: string;
|
author: string;
|
||||||
@@ -94,29 +98,46 @@ type DiscordThreadStarter = {
|
|||||||
|
|
||||||
const DISCORD_THREAD_STARTER_CACHE = new Map<string, DiscordThreadStarter>();
|
const DISCORD_THREAD_STARTER_CACHE = new Map<string, DiscordThreadStarter>();
|
||||||
|
|
||||||
async function resolveDiscordThreadStarter(
|
async function resolveDiscordThreadStarter(params: {
|
||||||
channel: ThreadChannel,
|
channel: DiscordThreadChannel;
|
||||||
): Promise<DiscordThreadStarter | null> {
|
client: Client;
|
||||||
const cacheKey = channel.id;
|
parentId?: string;
|
||||||
|
}): Promise<DiscordThreadStarter | null> {
|
||||||
|
const cacheKey = params.channel.id;
|
||||||
const cached = DISCORD_THREAD_STARTER_CACHE.get(cacheKey);
|
const cached = DISCORD_THREAD_STARTER_CACHE.get(cacheKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
try {
|
try {
|
||||||
const starter = await channel.fetchStarterMessage();
|
if (!params.parentId) return null;
|
||||||
|
const starter = (await params.client.rest.get(
|
||||||
|
Routes.channelMessage(params.parentId, params.channel.id),
|
||||||
|
)) as {
|
||||||
|
content?: string | null;
|
||||||
|
embeds?: Array<{ description?: string | null }>;
|
||||||
|
member?: { nick?: string | null; displayName?: string | null };
|
||||||
|
author?: {
|
||||||
|
id?: string | null;
|
||||||
|
username?: string | null;
|
||||||
|
discriminator?: string | null;
|
||||||
|
};
|
||||||
|
timestamp?: string | null;
|
||||||
|
};
|
||||||
if (!starter) return null;
|
if (!starter) return null;
|
||||||
const text =
|
const text =
|
||||||
starter.content?.trim() ??
|
starter.content?.trim() ?? starter.embeds?.[0]?.description?.trim() ?? "";
|
||||||
starter.embeds?.[0]?.description?.trim() ??
|
|
||||||
"";
|
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
const author =
|
const author =
|
||||||
|
starter.member?.nick ??
|
||||||
starter.member?.displayName ??
|
starter.member?.displayName ??
|
||||||
starter.author?.tag ??
|
(starter.author
|
||||||
starter.author?.username ??
|
? starter.author.discriminator && starter.author.discriminator !== "0"
|
||||||
"Unknown";
|
? `${starter.author.username ?? "Unknown"}#${starter.author.discriminator}`
|
||||||
|
: (starter.author.username ?? starter.author.id ?? "Unknown")
|
||||||
|
: "Unknown");
|
||||||
|
const timestamp = resolveTimestampMs(starter.timestamp);
|
||||||
const payload: DiscordThreadStarter = {
|
const payload: DiscordThreadStarter = {
|
||||||
text,
|
text,
|
||||||
author,
|
author,
|
||||||
timestamp: starter.createdTimestamp ?? undefined,
|
timestamp: timestamp ?? undefined,
|
||||||
};
|
};
|
||||||
DISCORD_THREAD_STARTER_CACHE.set(cacheKey, payload);
|
DISCORD_THREAD_STARTER_CACHE.set(cacheKey, payload);
|
||||||
return payload;
|
return payload;
|
||||||
@@ -554,15 +575,18 @@ export function createDiscordMessageHandler(params: {
|
|||||||
|
|
||||||
const channelName =
|
const channelName =
|
||||||
channelInfo?.name ??
|
channelInfo?.name ??
|
||||||
((isGuildMessage || isGroupDm) && "name" in message.channel
|
((isGuildMessage || isGroupDm) &&
|
||||||
|
message.channel &&
|
||||||
|
"name" in message.channel
|
||||||
? message.channel.name
|
? message.channel.name
|
||||||
: undefined);
|
: undefined);
|
||||||
const isThreadChannel =
|
const isThreadChannel =
|
||||||
isGuildMessage &&
|
isGuildMessage &&
|
||||||
|
message.channel &&
|
||||||
"isThread" in message.channel &&
|
"isThread" in message.channel &&
|
||||||
message.channel.isThread();
|
message.channel.isThread();
|
||||||
const threadChannel = isThreadChannel
|
const threadChannel = isThreadChannel
|
||||||
? (message.channel as ThreadChannel)
|
? (message.channel as DiscordThreadChannel)
|
||||||
: null;
|
: null;
|
||||||
const threadParentId =
|
const threadParentId =
|
||||||
threadChannel?.parentId ?? threadChannel?.parent?.id ?? undefined;
|
threadChannel?.parentId ?? threadChannel?.parent?.id ?? undefined;
|
||||||
@@ -576,7 +600,6 @@ export function createDiscordMessageHandler(params: {
|
|||||||
const displayChannelSlug = displayChannelName
|
const displayChannelSlug = displayChannelName
|
||||||
? normalizeDiscordSlug(displayChannelName)
|
? normalizeDiscordSlug(displayChannelName)
|
||||||
: "";
|
: "";
|
||||||
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
|
|
||||||
const guildSlug =
|
const guildSlug =
|
||||||
guildInfo?.slug ||
|
guildInfo?.slug ||
|
||||||
(data.guild?.name ? normalizeDiscordSlug(data.guild.name) : "");
|
(data.guild?.name ? normalizeDiscordSlug(data.guild.name) : "");
|
||||||
@@ -590,6 +613,7 @@ export function createDiscordMessageHandler(params: {
|
|||||||
id: isDirectMessage ? author.id : message.channelId,
|
id: isDirectMessage ? author.id : message.channelId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const baseSessionKey = route.sessionKey;
|
||||||
const channelConfig = isGuildMessage
|
const channelConfig = isGuildMessage
|
||||||
? resolveDiscordChannelConfig({
|
? resolveDiscordChannelConfig({
|
||||||
guildInfo,
|
guildInfo,
|
||||||
@@ -831,13 +855,16 @@ export function createDiscordMessageHandler(params: {
|
|||||||
|
|
||||||
let threadStarterBody: string | undefined;
|
let threadStarterBody: string | undefined;
|
||||||
let threadLabel: string | undefined;
|
let threadLabel: string | undefined;
|
||||||
let threadSessionKey: string | undefined;
|
|
||||||
let parentSessionKey: string | undefined;
|
let parentSessionKey: string | undefined;
|
||||||
if (threadChannel) {
|
if (threadChannel) {
|
||||||
const starter = await resolveDiscordThreadStarter(threadChannel);
|
const starter = await resolveDiscordThreadStarter({
|
||||||
|
channel: threadChannel,
|
||||||
|
client,
|
||||||
|
parentId: threadParentId,
|
||||||
|
});
|
||||||
if (starter?.text) {
|
if (starter?.text) {
|
||||||
const starterEnvelope = formatAgentEnvelope({
|
const starterEnvelope = formatAgentEnvelope({
|
||||||
surface: "Discord",
|
provider: "Discord",
|
||||||
from: starter.author,
|
from: starter.author,
|
||||||
timestamp: starter.timestamp,
|
timestamp: starter.timestamp,
|
||||||
body: starter.text,
|
body: starter.text,
|
||||||
@@ -848,20 +875,12 @@ export function createDiscordMessageHandler(params: {
|
|||||||
threadLabel = threadName
|
threadLabel = threadName
|
||||||
? `Discord thread #${normalizeDiscordSlug(parentName)} › ${threadName}`
|
? `Discord thread #${normalizeDiscordSlug(parentName)} › ${threadName}`
|
||||||
: `Discord thread #${normalizeDiscordSlug(parentName)}`;
|
: `Discord thread #${normalizeDiscordSlug(parentName)}`;
|
||||||
threadSessionKey = `discord:thread:${message.channelId}`;
|
|
||||||
const sessionCfg = cfg.session;
|
|
||||||
const sessionScope = sessionCfg?.scope ?? "per-sender";
|
|
||||||
const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
|
|
||||||
if (threadParentId) {
|
if (threadParentId) {
|
||||||
parentSessionKey = resolveSessionKey(
|
parentSessionKey = buildAgentSessionKey({
|
||||||
sessionScope,
|
agentId: route.agentId,
|
||||||
{
|
provider: route.provider,
|
||||||
From: `group:${threadParentId}`,
|
peer: { kind: "channel", id: threadParentId },
|
||||||
ChatType: "group",
|
});
|
||||||
Surface: "discord",
|
|
||||||
},
|
|
||||||
mainKey,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const mediaPayload = buildDiscordMediaPayload(mediaList);
|
const mediaPayload = buildDiscordMediaPayload(mediaList);
|
||||||
@@ -872,7 +891,7 @@ export function createDiscordMessageHandler(params: {
|
|||||||
? `discord:${author.id}`
|
? `discord:${author.id}`
|
||||||
: `group:${message.channelId}`,
|
: `group:${message.channelId}`,
|
||||||
To: discordTo,
|
To: discordTo,
|
||||||
SessionKey: threadSessionKey ?? route.sessionKey,
|
SessionKey: baseSessionKey,
|
||||||
AccountId: route.accountId,
|
AccountId: route.accountId,
|
||||||
ChatType: isDirectMessage ? "direct" : "group",
|
ChatType: isDirectMessage ? "direct" : "group",
|
||||||
SenderName:
|
SenderName:
|
||||||
|
|||||||
@@ -285,8 +285,6 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("treats parent_user_id as a thread reply even when thread_ts matches ts", async () => {
|
it("treats parent_user_id as a thread reply even when thread_ts matches ts", async () => {
|
||||||
const { resolveSessionKey } = await import("../config/sessions.js");
|
|
||||||
vi.mocked(resolveSessionKey).mockReturnValue("main");
|
|
||||||
replyMock.mockResolvedValue({ text: "thread reply" });
|
replyMock.mockResolvedValue({ text: "thread reply" });
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -322,13 +320,11 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
SessionKey?: string;
|
SessionKey?: string;
|
||||||
ParentSessionKey?: string;
|
ParentSessionKey?: string;
|
||||||
};
|
};
|
||||||
expect(ctx.SessionKey).toBe("slack:thread:C1:123");
|
expect(ctx.SessionKey).toBe("agent:main:main:thread:123");
|
||||||
expect(ctx.ParentSessionKey).toBe("main");
|
expect(ctx.ParentSessionKey).toBe("agent:main:main");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forks thread sessions and injects starter context", async () => {
|
it("forks thread sessions and injects starter context", async () => {
|
||||||
const { resolveSessionKey } = await import("../config/sessions.js");
|
|
||||||
vi.mocked(resolveSessionKey).mockReturnValue("slack:channel:C1");
|
|
||||||
replyMock.mockResolvedValue({ text: "ok" });
|
replyMock.mockResolvedValue({ text: "ok" });
|
||||||
|
|
||||||
const client = getSlackClient();
|
const client = getSlackClient();
|
||||||
@@ -386,8 +382,8 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
ThreadStarterBody?: string;
|
ThreadStarterBody?: string;
|
||||||
ThreadLabel?: string;
|
ThreadLabel?: string;
|
||||||
};
|
};
|
||||||
expect(ctx.SessionKey).toBe("slack:thread:C1:111.222");
|
expect(ctx.SessionKey).toBe("agent:main:slack:channel:C1:thread:111.222");
|
||||||
expect(ctx.ParentSessionKey).toBe("slack:channel:C1");
|
expect(ctx.ParentSessionKey).toBe("agent:main:slack:channel:C1");
|
||||||
expect(ctx.ThreadStarterBody).toContain("starter message");
|
expect(ctx.ThreadStarterBody).toContain("starter message");
|
||||||
expect(ctx.ThreadLabel).toContain("Slack thread #general");
|
expect(ctx.ThreadLabel).toContain("Slack thread #general");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -928,10 +928,12 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
const threadTs = message.thread_ts;
|
const threadTs = message.thread_ts;
|
||||||
const hasThreadTs = typeof threadTs === "string" && threadTs.length > 0;
|
const hasThreadTs = typeof threadTs === "string" && threadTs.length > 0;
|
||||||
const isThreadReply =
|
const isThreadReply =
|
||||||
hasThreadTs && (threadTs !== message.ts || Boolean(message.parent_user_id));
|
hasThreadTs &&
|
||||||
const threadSessionKey = isThreadReply && threadTs
|
(threadTs !== message.ts || Boolean(message.parent_user_id));
|
||||||
? `slack:thread:${message.channel}:${threadTs}`
|
const threadSessionKey =
|
||||||
: undefined;
|
isThreadReply && threadTs
|
||||||
|
? `${baseSessionKey}:thread:${threadTs}`
|
||||||
|
: undefined;
|
||||||
const parentSessionKey = isThreadReply ? baseSessionKey : undefined;
|
const parentSessionKey = isThreadReply ? baseSessionKey : undefined;
|
||||||
const sessionKey = threadSessionKey ?? baseSessionKey;
|
const sessionKey = threadSessionKey ?? baseSessionKey;
|
||||||
enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
|
enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
|
||||||
|
|||||||
Reference in New Issue
Block a user