mirror of
https://github.com/cline/cline.git
synced 2026-09-12 17:19:32 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
133d2a449b |
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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<
|
||||
|
||||
Reference in New Issue
Block a user