Sessions: parse thread suffixes by channel

This commit is contained in:
Gustavo Madeira Santana
2026-03-30 23:14:46 -04:00
parent 11590eb6ce
commit 50fb19a814
8 changed files with 146 additions and 35 deletions

View File

@@ -95,6 +95,28 @@ describe("resolveAnnounceTargetFromKey", () => {
},
},
},
{
pluginId: "feishu",
source: "test",
plugin: {
id: "feishu",
meta: {
id: "feishu",
label: "Feishu",
selectionLabel: "Feishu",
docsPath: "/channels/feishu",
blurb: "Feishu test stub.",
},
capabilities: { chatTypes: ["direct", "group", "thread"] },
messaging: {
normalizeTarget: (raw: string) => raw.replace(/^group:/, ""),
},
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
},
},
},
]),
);
});
@@ -141,4 +163,16 @@ describe("resolveAnnounceTargetFromKey", () => {
threadId: "$AbC123:example.org",
});
});
it("preserves feishu conversation ids that embed :topic: in the base id", () => {
expect(
resolveAnnounceTargetFromKey(
"agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
),
).toEqual({
channel: "feishu",
to: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
threadId: undefined,
});
});
});

View File

@@ -4,7 +4,7 @@ import {
} from "../../channels/plugins/index.js";
import { normalizeChannelId as normalizeChatChannelId } from "../../channels/registry.js";
import type { OpenClawConfig } from "../../config/config.js";
import { parseSessionThreadInfo } from "../../config/sessions/delivery-info.js";
import { parseThreadSessionSuffix } from "../../sessions/session-key-utils.js";
const ANNOUNCE_SKIP_TOKEN = "ANNOUNCE_SKIP";
const REPLY_SKIP_TOKEN = "REPLY_SKIP";
@@ -30,7 +30,9 @@ export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget
}
const restJoined = rest.join(":");
const { baseSessionKey, threadId } = parseSessionThreadInfo(restJoined);
const { baseSessionKey, threadId } = parseThreadSessionSuffix(restJoined, {
channelHint: channelRaw,
});
const id = (baseSessionKey ?? restJoined).trim();
if (!id) {

View File

@@ -52,6 +52,15 @@ describe("extractDeliveryInfo", () => {
baseSessionKey: "agent:main:matrix:channel:!room:example.org",
threadId: "$AbC123:example.org",
});
expect(
parseSessionThreadInfo(
"agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
),
).toEqual({
baseSessionKey:
"agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
threadId: undefined,
});
expect(parseSessionThreadInfo("agent:main:telegram:dm:user-1")).toEqual({
baseSessionKey: "agent:main:telegram:dm:user-1",
threadId: undefined,

View File

@@ -1,3 +1,4 @@
import { parseThreadSessionSuffix } from "../../sessions/session-key-utils.js";
import { loadConfig } from "../io.js";
import { resolveStorePath } from "./paths.js";
import { loadSessionStore } from "./store.js";
@@ -10,19 +11,7 @@ export function parseSessionThreadInfo(sessionKey: string | undefined): {
baseSessionKey: string | undefined;
threadId: string | undefined;
} {
if (!sessionKey) {
return { baseSessionKey: undefined, threadId: undefined };
}
const topicIndex = sessionKey.lastIndexOf(":topic:");
const threadIndex = sessionKey.lastIndexOf(":thread:");
const markerIndex = Math.max(topicIndex, threadIndex);
const marker = topicIndex > threadIndex ? ":topic:" : ":thread:";
const baseSessionKey = markerIndex === -1 ? sessionKey : sessionKey.slice(0, markerIndex);
const threadIdRaw =
markerIndex === -1 ? undefined : sessionKey.slice(markerIndex + marker.length);
const threadId = threadIdRaw?.trim() || undefined;
return { baseSessionKey, threadId };
return parseThreadSessionSuffix(sessionKey);
}
export function extractDeliveryInfo(sessionKey: string | undefined): {

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { isThreadSessionKey, resolveSessionResetType } from "./reset.js";
describe("session reset thread detection", () => {
it("does not treat feishu conversation ids with embedded :topic: as thread suffixes", () => {
const sessionKey =
"agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user";
expect(isThreadSessionKey(sessionKey)).toBe(false);
expect(resolveSessionResetType({ sessionKey })).toBe("group");
});
it("still treats telegram :topic: suffixes as thread sessions", () => {
const sessionKey = "agent:main:telegram:group:-100123:topic:77";
expect(isThreadSessionKey(sessionKey)).toBe(true);
expect(resolveSessionResetType({ sessionKey })).toBe("thread");
});
});

View File

@@ -1,3 +1,4 @@
import { parseThreadSessionSuffix } from "../../sessions/session-key-utils.js";
import { normalizeMessageChannel } from "../../utils/message-channel.js";
import type { SessionConfig, SessionResetConfig } from "../types.base.js";
import { DEFAULT_IDLE_MINUTES } from "./types.js";
@@ -20,15 +21,10 @@ export type SessionFreshness = {
export const DEFAULT_RESET_MODE: SessionResetMode = "daily";
export const DEFAULT_RESET_AT_HOUR = 4;
const THREAD_SESSION_MARKERS = [":thread:", ":topic:"];
const GROUP_SESSION_MARKERS = [":group:", ":channel:"];
export function isThreadSessionKey(sessionKey?: string | null): boolean {
const normalized = (sessionKey ?? "").toLowerCase();
if (!normalized) {
return false;
}
return THREAD_SESSION_MARKERS.some((marker) => normalized.includes(marker));
return Boolean(parseThreadSessionSuffix(sessionKey).threadId);
}
export function resolveSessionResetType(params: {

View File

@@ -3,6 +3,8 @@ import {
deriveSessionChatType,
getSubagentDepth,
isCronSessionKey,
parseThreadSessionSuffix,
resolveThreadParentSessionKey,
} from "../sessions/session-key-utils.js";
import {
classifySessionKeyShape,
@@ -84,6 +86,35 @@ describe("deriveSessionChatType", () => {
});
});
describe("thread session suffix parsing", () => {
it("preserves feishu conversation ids that embed :topic: in the base id", () => {
expect(
parseThreadSessionSuffix(
"agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
),
).toEqual({
baseSessionKey:
"agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
threadId: undefined,
});
expect(
resolveThreadParentSessionKey(
"agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
),
).toBeNull();
});
it("still parses telegram topic session suffixes", () => {
expect(parseThreadSessionSuffix("agent:main:telegram:group:-100123:topic:77")).toEqual({
baseSessionKey: "agent:main:telegram:group:-100123",
threadId: "77",
});
expect(resolveThreadParentSessionKey("agent:main:telegram:group:-100123:topic:77")).toBe(
"agent:main:telegram:group:-100123",
);
});
});
describe("session key canonicalization", () => {
function expectSessionKeyCanonicalizationCase(params: { run: () => void }) {
params.run();

View File

@@ -4,6 +4,10 @@ export type ParsedAgentSessionKey = {
};
export type SessionKeyChatType = "direct" | "group" | "channel" | "unknown";
export type ParsedThreadSessionSuffix = {
baseSessionKey: string | undefined;
threadId: string | undefined;
};
/**
* Parse agent-scoped session keys in a canonical, case-insensitive way.
@@ -107,26 +111,55 @@ export function isAcpSessionKey(sessionKey: string | undefined | null): boolean
return Boolean((parsed?.rest ?? "").toLowerCase().startsWith("acp:"));
}
const THREAD_SESSION_MARKERS = [":thread:", ":topic:"];
function normalizeThreadSuffixChannelHint(value: string | undefined | null): string | undefined {
const trimmed = (value ?? "").trim().toLowerCase();
return trimmed || undefined;
}
function inferThreadSuffixChannelHint(sessionKey: string): string | undefined {
const parts = sessionKey.split(":").filter(Boolean);
if (parts.length === 0) {
return undefined;
}
if ((parts[0] ?? "").trim().toLowerCase() === "agent") {
return normalizeThreadSuffixChannelHint(parts[2]);
}
return normalizeThreadSuffixChannelHint(parts[0]);
}
export function parseThreadSessionSuffix(
sessionKey: string | undefined | null,
options?: { channelHint?: string | null },
): ParsedThreadSessionSuffix {
const raw = (sessionKey ?? "").trim();
if (!raw) {
return { baseSessionKey: undefined, threadId: undefined };
}
const channelHint =
normalizeThreadSuffixChannelHint(options?.channelHint) ?? inferThreadSuffixChannelHint(raw);
const topicIndex = channelHint === "telegram" ? raw.lastIndexOf(":topic:") : -1;
const threadIndex = raw.lastIndexOf(":thread:");
const markerIndex = Math.max(topicIndex, threadIndex);
const marker = topicIndex > threadIndex ? ":topic:" : ":thread:";
const baseSessionKey = markerIndex === -1 ? raw : raw.slice(0, markerIndex);
const threadIdRaw = markerIndex === -1 ? undefined : raw.slice(markerIndex + marker.length);
const threadId = threadIdRaw?.trim() || undefined;
return { baseSessionKey, threadId };
}
export function resolveThreadParentSessionKey(
sessionKey: string | undefined | null,
): string | null {
const raw = (sessionKey ?? "").trim();
if (!raw) {
const { baseSessionKey, threadId } = parseThreadSessionSuffix(sessionKey);
if (!threadId) {
return null;
}
const normalized = raw.toLowerCase();
let idx = -1;
for (const marker of THREAD_SESSION_MARKERS) {
const candidate = normalized.lastIndexOf(marker);
if (candidate > idx) {
idx = candidate;
}
}
if (idx <= 0) {
const parent = baseSessionKey?.trim();
if (!parent) {
return null;
}
const parent = raw.slice(0, idx).trim();
return parent ? parent : null;
return parent;
}