Files
openclaw/src/plugin-sdk/thread-aware-outbound-session-route.test.ts
lukeboyett b5bd6e8828 fix(sessions): preserve Matrix room-id case in session keys (#75670) (#87366)
* fix(sessions): preserve Matrix room-id case in session keys (#75670)

Matrix room IDs (and thread event IDs) are opaque, case-sensitive per the
Matrix spec, but session-key canonicalization lowercased them. That forked
one room into duplicate sessions and produced 403 M_FORBIDDEN on recovery /
delivery paths that reconstruct the target from the (lowercased) session key,
even though deliveryContext.to stayed correct.

Introduce a generic, opt-in case-preservation registry (CASE_PRESERVING_PEERS)
consulted at all three lowercasing sites:
- construction: normalizeSessionPeerId
- store canonicalization: normalizeSessionKeyPreservingOpaquePeerIds
- gateway send: explicit request.sessionKey

Signal group preservation is encoded to match prior behavior exactly (segment
span, unscoped, thread suffix still lowercased). Matrix channel/group enrolls
the opaque tail (room id with embedded :server + any 🧵<event> suffix).
Exact mixed-case keys now win over folded legacy aliases in
resolveSessionStoreEntry and delivery-info lookup; existing lowercased rows
collapse on the next write. Matrix DM/MXID and non-enrolled channels keep the
default lowercase behavior.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sessions): guard Matrix folded alias delivery proof

* test(agents): cover cold OpenAI gpt-5.5 fallback

* fix(sessions): preserve non-opaque alias freshness

* fix(sessions): prevent Matrix cross-room thread recovery

* build(protocol): refresh tools effective Swift models

* test(codex): include effective cwd in startup fixture

* test(codex): align startup failure cleanup expectation

* fix(sessions): keep Signal folded aliases fresh

* fix(sessions): preserve unscoped Matrix room keys

* fix(sessions): recover legacy Matrix thread aliases

* fix(sessions): preserve Matrix keys in state migrations

* fix(sessions): keep Matrix structural alias freshness

* fix(sessions): preserve unscoped Matrix migration keys

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-28 00:26:49 +01:00

157 lines
4.6 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
buildThreadAwareOutboundSessionRoute,
recoverCurrentThreadSessionId,
type ChannelOutboundSessionRoute,
} from "./core.js";
function baseRoute(
overrides: Partial<ChannelOutboundSessionRoute> = {},
): ChannelOutboundSessionRoute {
return {
sessionKey: "agent:main:workspace:channel:c123",
baseSessionKey: "agent:main:workspace:channel:c123",
peer: { kind: "channel", id: "c123" },
chatType: "channel",
from: "workspace:channel:c123",
to: "channel:c123",
...overrides,
};
}
describe("buildThreadAwareOutboundSessionRoute", () => {
it("uses replyToId before threadId and recovered current-session thread by default", () => {
const route = buildThreadAwareOutboundSessionRoute({
route: baseRoute(),
replyToId: "reply-1",
threadId: "thread-1",
currentSessionKey: "agent:main:workspace:channel:c123:thread:current-1",
});
expect(route).toEqual(
baseRoute({
sessionKey: "agent:main:workspace:channel:c123:thread:reply-1",
threadId: "reply-1",
}),
);
});
it("supports provider-specific threadId-first precedence", () => {
const route = buildThreadAwareOutboundSessionRoute({
route: baseRoute(),
replyToId: "reply-1",
threadId: "thread-1",
precedence: ["threadId", "replyToId", "currentSession"],
});
expect(route).toEqual(
baseRoute({
sessionKey: "agent:main:workspace:channel:c123:thread:thread-1",
threadId: "thread-1",
}),
);
});
it("keeps numeric delivery thread ids on the route while stringifying the session suffix", () => {
const route = buildThreadAwareOutboundSessionRoute({
route: baseRoute(),
threadId: 99,
});
expect(route).toEqual(
baseRoute({
sessionKey: "agent:main:workspace:channel:c123:thread:99",
threadId: 99,
}),
);
});
it("recovers a current-session thread only when the base session matches", () => {
expect(
recoverCurrentThreadSessionId({
route: baseRoute(),
currentSessionKey: "agent:main:workspace:channel:c123:thread:current-1",
}),
).toBe("current-1");
expect(
recoverCurrentThreadSessionId({
route: baseRoute(),
currentSessionKey: "agent:main:workspace:channel:other:thread:current-1",
}),
).toBeUndefined();
});
it("does not recover current-session threads across case-distinct Matrix rooms", () => {
const route = baseRoute({
sessionKey: "agent:main:matrix:channel:!Mixed:example.org",
baseSessionKey: "agent:main:matrix:channel:!Mixed:example.org",
peer: { kind: "channel", id: "!Mixed:example.org" },
from: "matrix:room:!Mixed:example.org",
to: "room:!Mixed:example.org",
});
expect(
recoverCurrentThreadSessionId({
route,
currentSessionKey: "agent:main:matrix:channel:!mixed:example.org:thread:$Root",
}),
).toBeUndefined();
});
it("keeps recovering current-session threads for non-opaque folded channel keys", () => {
expect(
recoverCurrentThreadSessionId({
route: baseRoute({
sessionKey: "agent:main:slack:channel:c1",
baseSessionKey: "agent:main:slack:channel:c1",
}),
currentSessionKey: "agent:main:slack:channel:C1:thread:1712345678.123456",
}),
).toBe("1712345678.123456");
});
it("lets providers veto current-session recovery", () => {
const route = buildThreadAwareOutboundSessionRoute({
route: baseRoute(),
currentSessionKey: "agent:main:workspace:channel:c123:thread:current-1",
canRecoverCurrentThread: () => false,
});
expect(route).toEqual(
baseRoute({
sessionKey: "agent:main:workspace:channel:c123",
}),
);
});
it("preserves provider-specific thread case when requested", () => {
const route = buildThreadAwareOutboundSessionRoute({
route: baseRoute(),
threadId: "$EventID:Example.Org",
normalizeThreadId: (threadId) => threadId,
});
expect(route).toEqual(
baseRoute({
sessionKey: "agent:main:workspace:channel:c123:thread:$EventID:Example.Org",
threadId: "$EventID:Example.Org",
}),
);
});
it("can carry a delivery thread without adding a session suffix", () => {
const route = buildThreadAwareOutboundSessionRoute({
route: baseRoute(),
threadId: "thread-1",
useSuffix: false,
});
expect(route).toEqual(
baseRoute({
sessionKey: "agent:main:workspace:channel:c123",
threadId: "thread-1",
}),
);
});
});