Compare commits

...
Author SHA1 Message Date
abeatrix 133d2a449b fix: prevent thread-scoped binding reuse across Slack channels
Scope thread bindings by channel thread key instead of participant key
so distinct Slack channel threads no longer share a session, while
preserving participant-scoped reuse for DMs.

Also strip leading raw Slack app mentions before command handling and
honor an explicit addressedToBot flag for command routing and bot
mention requirements. Add handling for missing connector runtime
sessions.
2026-06-04 13:38:20 -07:00
5 changed files with 594 additions and 40 deletions
@@ -157,6 +157,71 @@ describe("slack binding lookup", () => {
expect(result?.binding.sessionId).toBe("sess-1");
});
it("does not reuse a thread-scoped binding by participant key across Slack channel threads", () => {
const result = __test__.findBindingForThread(
{
"slack:C123:111.222": {
channelId: "slack:C123",
isDM: false,
participantKey,
participantLabel: "alice",
serializedThread: "{}",
sessionId: "sess-1",
state: {
sessionId: "sess-1",
teamId: "T123",
bindingScope: "thread",
participantKey,
participantLabel: "alice",
},
updatedAt: "2026-03-17T00:00:00.000Z",
},
},
{
id: "slack:C999:333.444",
channelId: "slack:C999",
isDM: false,
participantKey,
bindingScope: "thread",
},
);
expect(result).toBeUndefined();
});
it("can still reuse participant-scoped bindings for Slack DMs", () => {
const result = __test__.findBindingForThread(
{
[participantKey]: {
channelId: "slack:D123",
isDM: true,
participantKey,
participantLabel: "alice",
serializedThread: "{}",
sessionId: "sess-1",
state: {
sessionId: "sess-1",
teamId: "T123",
bindingScope: "participant",
participantKey,
participantLabel: "alice",
},
updatedAt: "2026-03-17T00:00:00.000Z",
},
},
{
id: "slack:D123",
channelId: "slack:D123",
isDM: true,
participantKey,
bindingScope: "participant",
},
);
expect(result?.key).toBe(participantKey);
expect(result?.binding.sessionId).toBe("sess-1");
});
it("builds Slack participant keys with a team scope", () => {
expect(__test__.buildSlackParticipantKey("T123", "U123")).toBe(
"slack:team:T123:user:U123",
@@ -315,6 +380,31 @@ describe("slack binding lookup", () => {
);
});
it("strips a leading raw Slack app mention before command handling", () => {
expect(
__test__.resolveSlackTurnText({
text: "@Cline CLI Test /help",
raw: { text: "<@U123> /help" },
addressedToBot: true,
}),
).toBe("/help");
expect(
__test__.resolveSlackTurnText({
text: "@Other User /help",
raw: { text: "<@U999> /help" },
botUserId: "U123",
addressedToBot: true,
}),
).toBe("@Other User /help");
expect(
__test__.resolveSlackTurnText({
text: "hello <@U123> /help",
raw: { text: "hello <@U123> /help" },
addressedToBot: true,
}),
).toBe("hello <@U123> /help");
});
it("routes Slack posts through the installation bot token for a team", async () => {
const calls: string[] = [];
const result = await __test__.withSlackTeamBotToken({
+94 -6
View File
@@ -47,6 +47,7 @@ import {
import { FileStateAdapter } from "../stores/file-state";
import { startConnectorTaskUpdateRelay } from "../task-updates";
import {
type ConnectorBindingScope,
type ConnectorBindingStore,
type ConnectorThreadBinding,
type ConnectorThreadState,
@@ -137,6 +138,10 @@ function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function normalizeSlackMessageEventChannelType<T>(event: T): T {
const record = asRecord(event);
const channel = readString(record?.channel);
@@ -149,6 +154,56 @@ function normalizeSlackMessageEventChannelType<T>(event: T): T {
} as T;
}
function readRawSlackMessageText(rawMessage: unknown): string | undefined {
const raw = asRecord(rawMessage);
return (
readString(raw?.text) ||
readString(asRecord(raw?.event)?.text) ||
readString(asRecord(raw?.message)?.text)
);
}
function stripLeadingSlackMention(input: {
text: string;
botUserId?: string;
addressedToBot?: boolean;
}): string {
const trimmed = input.text.trim();
const botId = input.botUserId?.trim();
if (botId) {
return trimmed
.replace(
new RegExp(`^<@${escapeRegExp(botId)}(?:\\|[^>]+)?>\\s*`, "i"),
"",
)
.trim();
}
if (!input.addressedToBot) {
return trimmed;
}
return trimmed.replace(/^<@[A-Z0-9]+(?:\|[^>]+)?>\s*/i, "").trim();
}
function resolveSlackTurnText(input: {
text: string;
raw: unknown;
botUserId?: string;
addressedToBot?: boolean;
}): string {
const rawText = readRawSlackMessageText(input.raw);
if (rawText) {
const strippedRaw = stripLeadingSlackMention({
text: rawText,
botUserId: input.botUserId,
addressedToBot: input.addressedToBot,
});
if (strippedRaw !== rawText.trim()) {
return strippedRaw;
}
}
return stripLeadingSlackMention(input);
}
function buildSlackParticipantKey(teamId: string, userId: string): string {
return `slack:team:${teamId}:user:${userId}`;
}
@@ -179,6 +234,12 @@ function resolveSlackParticipant(
};
}
function resolveSlackBindingScope(
thread: Pick<Thread<SlackThreadState>, "isDM">,
): ConnectorBindingScope {
return thread.isDM ? "participant" : "thread";
}
function extractSlackTeamId(raw: unknown): string | undefined {
if (!raw || typeof raw !== "object") {
return undefined;
@@ -326,9 +387,11 @@ async function persistSlackThreadContext(input: {
input.thread,
input.bindingsPath,
input.baseStartRequest,
resolveSlackBindingScope(input.thread),
);
if (
currentState.teamId === teamId &&
currentState.bindingScope === resolveSlackBindingScope(input.thread) &&
currentState.participantKey === participant?.key &&
currentState.participantLabel === participant?.label
) {
@@ -340,6 +403,7 @@ async function persistSlackThreadContext(input: {
{
...currentState,
teamId: teamId ?? currentState.teamId,
bindingScope: resolveSlackBindingScope(input.thread),
participantKey: participant?.key ?? currentState.participantKey,
participantLabel: participant?.label ?? currentState.participantLabel,
},
@@ -820,13 +884,19 @@ class SlackConnector extends ConnectorBase<
const handleTurn = async (
thread: Thread<SlackThreadState>,
text: string,
addressedToBot: boolean,
) => {
const bindingScope = resolveSlackBindingScope(thread);
const currentState = await loadThreadState(
thread,
bindingsPath,
startRequest,
bindingScope,
);
const queueKey = currentState.participantKey || thread.id;
const queueKey =
currentState.bindingScope === "thread"
? thread.id
: currentState.participantKey || thread.id;
const runTurn = async () => {
try {
await withSlackTeamBotToken({
@@ -846,6 +916,7 @@ class SlackConnector extends ConnectorBase<
logger: loggerAdapter,
transport: "slack",
botUserName: options.userName,
addressedToBot,
requestStop,
bindingsPath,
hookCommand: options.hookCommand,
@@ -946,6 +1017,12 @@ class SlackConnector extends ConnectorBase<
bot.onNewMention(async (thread, message) => {
const mentionThread = resolveSlackChannelMentionThread(thread, message);
const text = resolveSlackTurnText({
text: message.text,
raw: message.raw,
botUserId: (slack as { botUserId?: string }).botUserId,
addressedToBot: true,
});
await mentionThread.subscribe();
await persistSlackThreadContext({
thread: mentionThread,
@@ -957,7 +1034,7 @@ class SlackConnector extends ConnectorBase<
if (
await maybeHandleConnectorApprovalReply({
thread: mentionThread,
text: message.text,
text,
client,
clientId,
pendingApprovals,
@@ -966,10 +1043,17 @@ class SlackConnector extends ConnectorBase<
) {
return;
}
await handleTurn(mentionThread, message.text);
await handleTurn(mentionThread, text, true);
});
bot.onSubscribedMessage(async (thread, message) => {
const addressedToBot = message.isMention === true;
const text = resolveSlackTurnText({
text: message.text,
raw: message.raw,
botUserId: (slack as { botUserId?: string }).botUserId,
addressedToBot,
});
await persistSlackThreadContext({
thread,
bindingsPath,
@@ -980,7 +1064,7 @@ class SlackConnector extends ConnectorBase<
if (
await maybeHandleConnectorApprovalReply({
thread,
text: message.text,
text,
client,
clientId,
pendingApprovals,
@@ -989,7 +1073,7 @@ class SlackConnector extends ConnectorBase<
) {
return;
}
await handleTurn(thread, message.text);
await handleTurn(thread, text, addressedToBot);
});
bot.onSlashCommand(async (event) => {
@@ -1014,7 +1098,7 @@ class SlackConnector extends ConnectorBase<
rawMessage: event.raw,
errorLabel: "Slack",
});
await handleTurn(thread, commandText);
await handleTurn(thread, commandText, true);
});
await bot.initialize();
@@ -1202,13 +1286,17 @@ export const __test__ = {
buildSlackParticipantKey,
resolveSlackParticipant,
normalizeSlackMessageEventChannelType,
stripLeadingSlackMention,
resolveSlackTurnText,
resolveSlackChannelMentionThread,
resolveSlackBindingScope,
withSlackTeamBotToken,
isSlackInvalidThreadTsError,
findBindingForThread: (
bindings: ConnectorBindingStore<SlackThreadState>,
thread: Pick<Thread<SlackThreadState>, "id" | "channelId" | "isDM"> & {
participantKey?: string;
bindingScope?: ConnectorBindingScope;
},
) => findBindingForThread(bindings, thread),
};
@@ -1,6 +1,7 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { SessionNotFoundError } from "@cline/core";
import type { SentMessage } from "chat";
import { afterEach, describe, expect, it, vi } from "vitest";
import { handleConnectorUserTurn } from "./connector-host";
@@ -23,6 +24,7 @@ type TestState = {
cwd?: string;
workspaceRoot?: string;
systemPrompt?: string;
bindingScope?: "thread" | "participant";
participantKey?: string;
participantLabel?: string;
welcomeSentAt?: string;
@@ -301,6 +303,49 @@ describe("handleConnectorUserTurn", () => {
expect(runtime.sendRuntimeSession).not.toHaveBeenCalled();
});
it("allows transport-addressed connector slash commands after mention stripping", async () => {
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
tempDirs.push(dir);
const bindingsPath = join(dir, "threads.json");
const { thread, posts } = createThread(
{
enableTools: true,
autoApproveTools: false,
cwd: "/tmp/work",
workspaceRoot: "/tmp/work",
participantKey: "slack:team:T123:user:U123",
participantLabel: "U123",
},
false,
);
const runtime = createRuntimeClient("unused");
await handleConnectorUserTurn({
thread: thread as never,
text: "/whereami",
addressedToBot: true,
client: runtime.client as never,
pendingApprovals: new Map(),
baseStartRequest: baseStartRequest({ enableTools: true }) as never,
explicitSystemPrompt: undefined,
clientId: "client-1",
logger: {
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as never,
transport: "slack",
botUserName: "cline-slack-retest",
requestStop: vi.fn(),
bindingsPath,
systemRules: "rules",
errorLabel: "Slack",
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
});
expect(messageText(posts.at(-1))).toContain("threadId=thread-1");
expect(runtime.sendRuntimeSession).not.toHaveBeenCalled();
});
it("allows owner-addressed connector slash commands in shared threads", async () => {
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
tempDirs.push(dir);
@@ -1335,6 +1380,185 @@ describe("handleConnectorUserTurn", () => {
expect(posts).not.toContain("Previous reply.");
});
it("replaces stale persisted sessions when the runtime no longer has them", async () => {
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
tempDirs.push(dir);
const bindingsPath = join(dir, "threads.json");
const { thread, posts, getState } = createThread({
sessionId: "stale-session",
enableTools: false,
autoApproveTools: false,
cwd: "/tmp/work",
workspaceRoot: "/tmp/work",
participantKey: "slack:team:T123:user:U123",
participantLabel: "alice",
});
const startRuntimeSession = vi.fn(async () => ({
sessionId: "fresh-session",
}));
const sendRuntimeSession = vi.fn(async (sessionId: string) => {
if (sessionId === "stale-session") {
throw new SessionNotFoundError("stale-session");
}
return {
result: {
text: "fresh reply",
finishReason: "stop",
iterations: 1,
},
};
});
const stopRuntimeSession = vi.fn(async () => ({ applied: true }));
const deleteSession = vi.fn(async () => ({ deleted: true }));
const onReplyFailed = vi.fn(async () => undefined);
await handleConnectorUserTurn({
thread: thread as never,
text: "hello",
client: {
startRuntimeSession,
updateSession: vi.fn(async () => undefined),
sendRuntimeSession,
stopRuntimeSession,
deleteSession,
abortRuntimeSession: vi.fn(async () => undefined),
streamEvents: vi.fn(() => () => undefined),
} as never,
pendingApprovals: new Map(),
baseStartRequest: baseStartRequest() as never,
explicitSystemPrompt: undefined,
clientId: "client-1",
logger: {
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as never,
transport: "telegram",
botUserName: "ClineAdapterBot",
requestStop: vi.fn(),
bindingsPath,
systemRules: "rules",
errorLabel: "Slack",
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
startedLogMessage: "started",
onReplyFailed,
});
expect(sendRuntimeSession).toHaveBeenNthCalledWith(
1,
"stale-session",
expect.anything(),
{ timeoutMs: null },
);
expect(sendRuntimeSession).toHaveBeenNthCalledWith(
2,
"fresh-session",
expect.anything(),
{ timeoutMs: null },
);
expect(startRuntimeSession).toHaveBeenCalledTimes(1);
expect(stopRuntimeSession).toHaveBeenCalledWith("stale-session");
expect(deleteSession).toHaveBeenCalledWith("stale-session", true);
expect(onReplyFailed).not.toHaveBeenCalled();
expect(posts.at(-1)).toEqual({ raw: "fresh reply" });
expect(getState().sessionId).toBe("fresh-session");
});
it("uses the replacement session for empty-runtime fallback after stale-session recovery", async () => {
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
tempDirs.push(dir);
const bindingsPath = join(dir, "threads.json");
const { thread, posts, getState } = createThread({
sessionId: "stale-session",
enableTools: true,
autoApproveTools: true,
cwd: "/tmp/work",
workspaceRoot: "/tmp/work",
participantKey: "discord:user:U123",
participantLabel: "alice",
});
const startRuntimeSession = vi.fn(async () => ({
sessionId: "fresh-session",
}));
const sendRuntimeSession = vi.fn(async (sessionId: string) => {
if (sessionId === "stale-session") {
throw new Error("session not found: stale-session");
}
return {
result: {
text: "",
finishReason: "stop",
iterations: 1,
},
};
});
const stopRuntimeSession = vi.fn(async () => ({ applied: true }));
const deleteSession = vi.fn(async () => ({ deleted: true }));
const createEmptyRuntimeReplyResolver = vi.fn(
async ({ sessionId }: { sessionId: string }) =>
async () =>
sessionId === "fresh-session" ? "fresh fallback reply" : undefined,
);
await handleConnectorUserTurn({
thread: thread as never,
text: "hello",
client: {
startRuntimeSession,
updateSession: vi.fn(async () => undefined),
sendRuntimeSession,
stopRuntimeSession,
deleteSession,
abortRuntimeSession: vi.fn(async () => undefined),
streamEvents: vi.fn(() => () => undefined),
} as never,
pendingApprovals: new Map(),
baseStartRequest: baseStartRequest({
enableTools: true,
autoApproveTools: true,
}) as never,
explicitSystemPrompt: undefined,
clientId: "client-1",
logger: {
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as never,
transport: "discord",
botUserName: "ClineAdapterBot",
requestStop: vi.fn(),
bindingsPath,
systemRules: "rules",
errorLabel: "Discord",
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
startedLogMessage: "started",
createEmptyRuntimeReplyResolver: createEmptyRuntimeReplyResolver as never,
});
expect(createEmptyRuntimeReplyResolver).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ sessionId: "stale-session" }),
);
expect(createEmptyRuntimeReplyResolver).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ sessionId: "fresh-session" }),
);
expect(sendRuntimeSession).toHaveBeenNthCalledWith(
1,
"stale-session",
expect.anything(),
{ timeoutMs: null },
);
expect(sendRuntimeSession).toHaveBeenNthCalledWith(
2,
"fresh-session",
expect.anything(),
{ timeoutMs: null },
);
expect(stopRuntimeSession).toHaveBeenCalledWith("stale-session");
expect(deleteSession).toHaveBeenCalledWith("stale-session", true);
expect(posts.at(-1)).toBe("fresh fallback reply");
expect(getState().sessionId).toBe("fresh-session");
});
it("keeps Telegram empty-stream behavior from reading session history", async () => {
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
tempDirs.push(dir);
+122 -28
View File
@@ -1,10 +1,11 @@
import { readFileSync } from "node:fs";
import { basename } from "node:path";
import type {
ChatRunTurnRequest,
ChatStartSessionRequest,
HubSessionClient,
UserInstructionConfigService,
import {
type ChatRunTurnRequest,
type ChatStartSessionRequest,
type HubSessionClient,
isSessionNotFoundError,
type UserInstructionConfigService,
} from "@cline/core";
import type { SentMessage, Thread } from "chat";
import type { CliLoggerAdapter } from "../logging/adapter";
@@ -85,6 +86,20 @@ async function editConnectorText(
return await message.edit(connectorTextPayload(transport, text));
}
function isConnectorRuntimeSessionMissingError(
error: unknown,
sessionId: string,
): boolean {
if (isSessionNotFoundError(error)) {
const errorSessionId = (error as { sessionId?: unknown }).sessionId;
return typeof errorSessionId !== "string" || errorSessionId === sessionId;
}
return (
error instanceof Error &&
error.message === `session not found: ${sessionId}`
);
}
function buildAttachments(input: {
userImages: string[];
userFiles: string[];
@@ -214,6 +229,7 @@ export async function handleConnectorUserTurn<
logger: CliLoggerAdapter;
transport: string;
botUserName?: string;
addressedToBot?: boolean;
ownerParticipantKeys?: string[];
requestStop: (reason: string) => void;
bindingsPath: string;
@@ -363,11 +379,10 @@ export async function handleConnectorUserTurn<
input.botUserName,
);
const isConnectorCommand = commandName !== undefined;
if (
isConnectorCommand &&
!input.thread.isDM &&
!isConnectorCommandAddressedToThisBot(resolvedInput, input.botUserName)
) {
const commandAddressedToBot =
input.addressedToBot ||
isConnectorCommandAddressedToThisBot(resolvedInput, input.botUserName);
if (isConnectorCommand && !input.thread.isDM && !commandAddressedToBot) {
input.logger.core.log("Unaddressed connector chat command ignored", {
transport: input.transport,
threadId: input.thread.id,
@@ -494,7 +509,7 @@ export async function handleConnectorUserTurn<
await maybeHandleChatCommand(resolvedInput, {
enabled: true,
botUserName: input.botUserName,
requireBotMention: !input.thread.isDM,
requireBotMention: !input.thread.isDM && !commandAddressedToBot,
host: input.chatCommandHost,
getState: async () => {
const current = await loadThreadState(
@@ -966,13 +981,10 @@ export async function handleConnectorUserTurn<
prompt,
attachments: buildAttachments({ userImages, userFiles }),
};
const resolveFallbackText = await input.createEmptyRuntimeReplyResolver?.({
client: input.client,
sessionId,
});
let activeSessionId = sessionId;
input.activeTurns?.set(turnKey, {
sessionId,
sessionId: activeSessionId,
threadId: input.thread.id,
participantKey: currentState.participantKey,
});
@@ -983,14 +995,31 @@ export async function handleConnectorUserTurn<
await input.postFinalReply?.({ thread: input.thread, text });
}
: undefined;
try {
const notifyReplyFailed = async (
targetSessionId: string,
error: unknown,
): Promise<void> => {
await input.onReplyFailed?.({
sessionId: targetSessionId,
threadId: input.thread.id,
error: error instanceof Error ? error : new Error(String(error)),
});
};
const postRuntimeReply = async (
targetSessionId: string,
targetRequest: ChatRunTurnRequest,
): Promise<void> => {
const resolveFallbackText = await input.createEmptyRuntimeReplyResolver?.({
client: input.client,
sessionId: targetSessionId,
});
await postConnectorRuntimeReply(
input.thread,
input.transport,
createConnectorRuntimeTurnStream({
client: input.client,
sessionId,
request,
sessionId: targetSessionId,
request: targetRequest,
clientId: input.clientId,
logger: input.logger,
transport: input.transport,
@@ -1020,24 +1049,89 @@ export async function handleConnectorUserTurn<
},
onCompleted: async (result) => {
await input.onReplyCompleted?.({
sessionId,
sessionId: targetSessionId,
threadId: input.thread.id,
text: result.text,
finishReason: result.finishReason,
iterations: result.iterations,
});
},
onFailed: async (error) => {
await input.onReplyFailed?.({
sessionId,
threadId: input.thread.id,
error,
});
},
}),
postFinalReply,
resolveFallbackText,
);
};
try {
try {
await postRuntimeReply(activeSessionId, request);
} catch (error) {
const staleSessionId = currentState.sessionId?.trim();
if (
activeSessionId !== staleSessionId ||
!isConnectorRuntimeSessionMissingError(error, activeSessionId)
) {
await notifyReplyFailed(activeSessionId, error);
throw error;
}
input.logger.core.log(
"Connector runtime session missing; starting replacement session",
{
severity: "warn",
transport: input.transport,
threadId: input.thread.id,
sessionId: activeSessionId,
},
);
try {
await clearSession({
thread: input.thread,
client: input.client,
bindingsPath: input.bindingsPath,
baseStartRequest: input.baseStartRequest,
errorLabel: input.errorLabel,
});
const retryState = await loadThreadState(
input.thread,
input.bindingsPath,
input.baseStartRequest,
);
const retryStartRequest = buildThreadStartRequest(
input.baseStartRequest,
applyForcedToolDisable(retryState, input.forceDisableTools),
);
activeSessionId = await getOrCreateSessionId({
thread: input.thread,
client: input.client,
startRequest: retryStartRequest,
logger: input.logger,
clientId: input.clientId,
transport: input.transport,
bindingsPath: input.bindingsPath,
errorLabel: input.errorLabel,
hookCommand: input.hookCommand,
hookBotUserName: input.botUserName,
sessionMetadata: input.getSessionMetadata(
input.thread,
input.clientId,
retryState,
),
reusedLogMessage: input.reusedLogMessage,
startedLogMessage: input.startedLogMessage,
});
input.activeTurns?.set(turnKey, {
sessionId: activeSessionId,
threadId: input.thread.id,
participantKey: retryState.participantKey,
});
await postRuntimeReply(activeSessionId, {
...request,
config: retryStartRequest,
});
} catch (retryError) {
await notifyReplyFailed(activeSessionId, retryError);
throw retryError;
}
}
} finally {
input.pendingApprovals.delete(input.thread.id);
input.activeTurns?.delete(turnKey);
@@ -1051,7 +1145,7 @@ export async function handleConnectorUserTurn<
input.bindingsPath,
{
...currentState,
sessionId,
sessionId: activeSessionId,
},
input.errorLabel,
);
+64 -6
View File
@@ -1,6 +1,8 @@
import type { SerializedThread, Thread } from "chat";
import { readJsonFile, writeJsonFile } from "./common";
export type ConnectorBindingScope = "thread" | "participant";
export type ConnectorThreadState = {
sessionId?: string;
enableTools?: boolean;
@@ -8,6 +10,7 @@ export type ConnectorThreadState = {
cwd?: string;
workspaceRoot?: string;
systemPrompt?: string;
bindingScope?: ConnectorBindingScope;
participantKey?: string;
participantLabel?: string;
welcomeSentAt?: string;
@@ -44,6 +47,7 @@ export type ConnectorBindingThreadIdentity = Pick<
"id" | "channelId" | "isDM"
> & {
participantKey?: string;
bindingScope?: ConnectorBindingScope;
};
export type ConnectorMuteTarget = {
@@ -58,6 +62,12 @@ function normalizeParticipantKey(
return trimmed ? trimmed : undefined;
}
function normalizeBindingScope(
value: ConnectorBindingScope | undefined,
): ConnectorBindingScope | undefined {
return value === "thread" || value === "participant" ? value : undefined;
}
function readSerializedThreadIdentity(
serializedThread: string | undefined,
): Partial<ConnectorBindingThreadIdentity> | undefined {
@@ -136,6 +146,12 @@ export function resolveThreadBindingKey(
thread: ConnectorBindingThreadIdentity,
state?: ConnectorThreadState | null,
): string {
if (
normalizeBindingScope(state?.bindingScope ?? thread.bindingScope) ===
"thread"
) {
return thread.id;
}
return (
normalizeParticipantKey(state?.participantKey ?? thread.participantKey) ??
thread.id
@@ -161,6 +177,12 @@ export function findBindingForThread<TState extends ConnectorThreadState>(
thread: ConnectorBindingThreadIdentity,
): { binding: ConnectorThreadBinding<TState>; key: string } | undefined {
const participantKey = normalizeParticipantKey(thread.participantKey);
if (normalizeBindingScope(thread.bindingScope) === "thread") {
const exact = bindings[thread.id];
return exact && !isControlBinding(exact)
? { key: thread.id, binding: exact }
: undefined;
}
if (participantKey) {
const exactThread = bindings[thread.id];
const exactThreadParticipantKey = normalizeParticipantKey(
@@ -213,6 +235,7 @@ export function readBindingForThread<TState extends ConnectorThreadState>(
thread: Thread<TState>,
errorLabel: string,
participantKey?: string,
bindingScope?: ConnectorBindingScope,
): ConnectorThreadBinding<TState> | undefined {
const bindings = readBindings<TState>(path);
const threadIdentity: ConnectorBindingThreadIdentity = {
@@ -220,34 +243,45 @@ export function readBindingForThread<TState extends ConnectorThreadState>(
channelId: thread.channelId,
isDM: thread.isDM,
participantKey,
bindingScope,
};
const match = findBindingForThread(bindings, threadIdentity);
if (!match) {
return undefined;
}
const targetKey = resolveThreadBindingKey(
threadIdentity,
match.binding.state,
);
const targetKey = resolveThreadBindingKey(threadIdentity, {
...(match.binding.state ?? {}),
...(bindingScope ? { bindingScope } : {}),
});
const normalizedParticipantKey = normalizeParticipantKey(participantKey);
const normalizedBindingScope = normalizeBindingScope(bindingScope);
const storedThread = readSerializedThreadIdentity(
match.binding.serializedThread,
);
const storedParticipantKey = normalizeParticipantKey(
match.binding.participantKey ?? match.binding.state?.participantKey,
);
const storedBindingScope = normalizeBindingScope(
match.binding.state?.bindingScope,
);
const needsRefresh =
match.key !== targetKey ||
storedThread?.id !== thread.id ||
storedThread?.channelId !== thread.channelId ||
storedThread?.isDM !== thread.isDM ||
storedParticipantKey !== normalizedParticipantKey;
storedParticipantKey !== normalizedParticipantKey ||
(normalizedBindingScope !== undefined &&
storedBindingScope !== normalizedBindingScope);
if (needsRefresh) {
bindings[targetKey] = {
...match.binding,
channelId: thread.channelId,
isDM: thread.isDM,
participantKey: normalizedParticipantKey ?? match.binding.participantKey,
state: {
...(match.binding.state ?? ({} as TState)),
...(bindingScope ? { bindingScope } : {}),
},
serializedThread: serializeThread(thread, errorLabel),
updatedAt: new Date().toISOString(),
};
@@ -278,6 +312,7 @@ export function persistThreadBinding<TState extends ConnectorThreadState>(
): void {
const bindings = readBindings<TState>(path);
const participantKey = normalizeParticipantKey(state.participantKey);
const bindingScope = normalizeBindingScope(state.bindingScope);
const bindingKey = resolveThreadBindingKey(
thread as ConnectorBindingThreadIdentity,
state,
@@ -289,6 +324,13 @@ export function persistThreadBinding<TState extends ConnectorThreadState>(
const bindingParticipantKey = normalizeParticipantKey(
binding.participantKey ?? binding.state?.participantKey,
);
const storedThread = readSerializedThreadIdentity(binding.serializedThread);
if (bindingScope === "thread") {
if (key !== bindingKey && storedThread?.id === thread.id) {
delete bindings[key];
}
continue;
}
const matchesParticipant =
participantKey && bindingParticipantKey === participantKey;
const matchesLegacyKey = participantKey && key === thread.id;
@@ -476,6 +518,10 @@ export function mergeThreadState<TState extends ConnectorThreadState>(
threadState?.systemPrompt ||
bindingState?.systemPrompt ||
base.systemPrompt,
bindingScope:
threadState?.bindingScope ||
bindingState?.bindingScope ||
base.bindingScope,
participantKey:
threadState?.participantKey ||
bindingState?.participantKey ||
@@ -495,15 +541,27 @@ export async function loadThreadState<TState extends ConnectorThreadState>(
thread: Thread<TState>,
bindingsPath: string,
base: ConnectorThreadState,
bindingScope?: ConnectorBindingScope,
): Promise<TState> {
const threadState = await thread.state;
const resolvedBindingScope = threadState?.bindingScope ?? bindingScope;
const binding = readBindingForThread<TState>(
bindingsPath,
thread,
"Connector",
threadState?.participantKey,
resolvedBindingScope,
);
return mergeThreadState(
resolvedBindingScope
? ({
...(threadState ?? {}),
bindingScope: resolvedBindingScope,
} as TState)
: threadState,
binding?.state,
base,
);
return mergeThreadState(threadState, binding?.state, base);
}
export function findBindingForParticipantKey<