mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-03 21:31:26 +00:00
feat(matrix): thread-isolated sessions and per-chat-type threadReplies (#57995)
Merged via squash.
Prepared head SHA: 9ed96dd063
Co-authored-by: teconomix <6959299+teconomix@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
@@ -51,6 +51,28 @@ describe("resolveAnnounceTargetFromKey", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pluginId: "matrix",
|
||||
source: "test",
|
||||
plugin: {
|
||||
id: "matrix",
|
||||
meta: {
|
||||
id: "matrix",
|
||||
label: "Matrix",
|
||||
selectionLabel: "Matrix",
|
||||
docsPath: "/channels/matrix",
|
||||
blurb: "Matrix test stub.",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct", "channel", "thread"] },
|
||||
messaging: {
|
||||
resolveSessionTarget: ({ id }: { id: string }) => `channel:${id}`,
|
||||
},
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pluginId: "telegram",
|
||||
source: "test",
|
||||
@@ -107,4 +129,16 @@ describe("resolveAnnounceTargetFromKey", () => {
|
||||
threadId: "1699999999.0001",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves colon-delimited matrix ids for channel and thread targets", () => {
|
||||
expect(
|
||||
resolveAnnounceTargetFromKey(
|
||||
"agent:main:matrix:channel:!room:example.org:thread:$AbC123:example.org",
|
||||
),
|
||||
).toEqual({
|
||||
channel: "matrix",
|
||||
to: "channel:!room:example.org",
|
||||
threadId: "$AbC123:example.org",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +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";
|
||||
|
||||
const ANNOUNCE_SKIP_TOKEN = "ANNOUNCE_SKIP";
|
||||
const REPLY_SKIP_TOKEN = "REPLY_SKIP";
|
||||
@@ -28,20 +29,9 @@ export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract topic/thread ID from rest (supports both :topic: and :thread:)
|
||||
// Telegram uses :topic:, other platforms use :thread:
|
||||
let threadId: string | undefined;
|
||||
const restJoined = rest.join(":");
|
||||
const topicMatch = restJoined.match(/:topic:([^:]+)$/);
|
||||
const threadMatch = restJoined.match(/:thread:([^:]+)$/);
|
||||
const match = topicMatch || threadMatch;
|
||||
|
||||
if (match) {
|
||||
threadId = match[1]; // Keep as string to match AgentCommandOpts.threadId
|
||||
}
|
||||
|
||||
// Remove :topic:N or :thread:N suffix from ID for target
|
||||
const id = match ? restJoined.replace(/:(topic|thread):[^:]+$/, "") : restJoined.trim();
|
||||
const { baseSessionKey, threadId } = parseSessionThreadInfo(restJoined);
|
||||
const id = (baseSessionKey ?? restJoined).trim();
|
||||
|
||||
if (!id) {
|
||||
return null;
|
||||
|
||||
@@ -176,6 +176,21 @@ function createMatrixThreadCommandParams(commandBody: string, overrides?: Record
|
||||
});
|
||||
}
|
||||
|
||||
function createMatrixTriggerThreadCommandParams(
|
||||
commandBody: string,
|
||||
overrides?: Record<string, unknown>,
|
||||
) {
|
||||
return buildCommandTestParams(commandBody, baseCfg, {
|
||||
Provider: "matrix",
|
||||
Surface: "matrix",
|
||||
OriginatingChannel: "matrix",
|
||||
OriginatingTo: "room:!room:example.org",
|
||||
AccountId: "default",
|
||||
MessageThreadId: "$root",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function createMatrixRoomCommandParams(commandBody: string, overrides?: Record<string, unknown>) {
|
||||
return buildCommandTestParams(commandBody, baseCfg, {
|
||||
Provider: "matrix",
|
||||
@@ -248,6 +263,21 @@ function createMatrixBinding(overrides?: Partial<SessionBindingRecord>): Session
|
||||
};
|
||||
}
|
||||
|
||||
function createMatrixTriggerBinding(
|
||||
overrides?: Partial<SessionBindingRecord>,
|
||||
): SessionBindingRecord {
|
||||
return createMatrixBinding({
|
||||
bindingId: "default:$root",
|
||||
conversation: {
|
||||
channel: "matrix",
|
||||
accountId: "default",
|
||||
conversationId: "$root",
|
||||
parentConversationId: "!room:example.org",
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function expectIdleTimeoutSetReply(
|
||||
mock: ReturnType<typeof vi.fn>,
|
||||
text: string,
|
||||
@@ -409,6 +439,40 @@ describe("/session idle and /session max-age", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("sets idle timeout for the triggering Matrix always-thread turn", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
|
||||
|
||||
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createMatrixTriggerBinding());
|
||||
hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock.mockReturnValue([
|
||||
{
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
boundAt: Date.now(),
|
||||
lastActivityAt: Date.now(),
|
||||
idleTimeoutMs: 2 * 60 * 60 * 1000,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await handleSessionCommand(
|
||||
createMatrixTriggerThreadCommandParams("/session idle 2h"),
|
||||
true,
|
||||
);
|
||||
const text = result?.reply?.text ?? "";
|
||||
|
||||
expect(hoisted.sessionBindingResolveByConversationMock).toHaveBeenCalledWith({
|
||||
channel: "matrix",
|
||||
accountId: "default",
|
||||
conversationId: "$root",
|
||||
parentConversationId: "!room:example.org",
|
||||
});
|
||||
expectIdleTimeoutSetReply(
|
||||
hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock,
|
||||
text,
|
||||
2 * 60 * 60 * 1000,
|
||||
"2h",
|
||||
);
|
||||
});
|
||||
|
||||
it("sets max age for focused Matrix threads", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
|
||||
|
||||
@@ -116,6 +116,22 @@ function createMatrixThreadCommandParams(commandBody: string, cfg: OpenClawConfi
|
||||
return params;
|
||||
}
|
||||
|
||||
function createMatrixTriggerThreadCommandParams(
|
||||
commandBody: string,
|
||||
cfg: OpenClawConfig = baseCfg,
|
||||
) {
|
||||
const params = buildCommandTestParams(commandBody, cfg, {
|
||||
Provider: "matrix",
|
||||
Surface: "matrix",
|
||||
OriginatingChannel: "matrix",
|
||||
OriginatingTo: "room:!room:example.org",
|
||||
AccountId: "default",
|
||||
MessageThreadId: "$root",
|
||||
});
|
||||
params.command.senderId = "user-1";
|
||||
return params;
|
||||
}
|
||||
|
||||
function createMatrixRoomCommandParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
|
||||
const params = buildCommandTestParams(commandBody, cfg, {
|
||||
Provider: "matrix",
|
||||
@@ -282,6 +298,22 @@ describe("/focus, /unfocus, /agents", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("/focus treats the triggering Matrix always-thread turn as the current thread", async () => {
|
||||
const result = await focusCodexAcp(createMatrixTriggerThreadCommandParams("/focus codex-acp"));
|
||||
|
||||
expect(result?.reply?.text).toContain("bound this thread");
|
||||
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
placement: "current",
|
||||
conversation: expect.objectContaining({
|
||||
channel: "matrix",
|
||||
conversationId: "$root",
|
||||
parentConversationId: "!room:example.org",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("/focus rejects Matrix top-level thread creation when spawnSubagentSessions is disabled", async () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
|
||||
@@ -44,6 +44,14 @@ describe("extractDeliveryInfo", () => {
|
||||
baseSessionKey: "agent:main:slack:channel:C1",
|
||||
threadId: "123.456",
|
||||
});
|
||||
expect(
|
||||
parseSessionThreadInfo(
|
||||
"agent:main:matrix:channel:!room:example.org:thread:$AbC123:example.org",
|
||||
),
|
||||
).toEqual({
|
||||
baseSessionKey: "agent:main:matrix:channel:!room:example.org",
|
||||
threadId: "$AbC123:example.org",
|
||||
});
|
||||
expect(parseSessionThreadInfo("agent:main:telegram:dm:user-1")).toEqual({
|
||||
baseSessionKey: "agent:main:telegram:dm:user-1",
|
||||
threadId: undefined,
|
||||
|
||||
Reference in New Issue
Block a user