diff --git a/CHANGELOG.md b/CHANGELOG.md index d9a786ae85e..d9111564b35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -406,6 +406,7 @@ Docs: https://docs.openclaw.ai - Codex app-server: keep native hook relays alive for long-running turns so shell and file approvals stay reachable until the configured run window finishes. (#77533) Thanks @rubencu. - Gateway/macOS: clear ignored SIGUSR1 restart state, skip redundant package-update restarts when the refreshed LaunchAgent already serves the expected version, and give launchd a 10s throttle plus 20s shutdown window so update restarts do not leave old gateways alive or fight supervisor recovery. Fixes #79577; refs #78699 and #60885. Thanks @BunsDev. - Status/Codex: route Codex-harness `openai/*` usage through the OpenAI Codex quota provider and scope CLI status usage to the default agent auth store so `/status` and `openclaw status --usage` show Codex quota windows again. Fixes #79312. Thanks @keshavbotagent. +- Matrix: keep joined strict DM rooms discoverable when stale `m.direct` mappings already point at an older strict room, and let `dm.sessionScope: "per-room"` promote safe unmapped strict rooms through the existing unnamed/unaliased room gate. Fixes #79514. Thanks @stainlu. - Gateway/agent: pass the session-key agent id into inline image attachment validation so the first image in a fresh per-agent session uses the agent's vision-capable model override instead of the text-only system default. Fixes #79407. Thanks @pandadev66. - Gateway/maintenance: prune dedupe overflow against a stable excess count and keep active agent retries from starting duplicate runs after cache eviction. (#73841) Thanks @thesomewhatyou. - Control UI/subagents: suppress internal `subagent_announce` handoff prompts from requester transcripts and hide legacy inter-session wrapper rows so completed subagent results no longer surface runtime context in WebChat history. (#79618) Thanks @joshavant. diff --git a/extensions/matrix/src/matrix/direct-management.test.ts b/extensions/matrix/src/matrix/direct-management.test.ts index 3dd40db72ce..2d3753789d9 100644 --- a/extensions/matrix/src/matrix/direct-management.test.ts +++ b/extensions/matrix/src/matrix/direct-management.test.ts @@ -47,6 +47,24 @@ describe("inspectMatrixDirectRooms", () => { ]); }); + it("still surfaces joined strict rooms when an older mapped room is strict", async () => { + const client = createClient({ + getAccountData: vi.fn(async () => ({ + "@alice:example.org": ["!older:example.org"], + })), + getJoinedRooms: vi.fn(async () => ["!older:example.org", "!fresh:example.org"]), + getJoinedRoomMembers: vi.fn(async () => ["@bot:example.org", "@alice:example.org"]), + }); + + const result = await inspectMatrixDirectRooms({ + client, + remoteUserId: "@alice:example.org", + }); + + expect(result.activeRoomId).toBe("!older:example.org"); + expect(result.discoveredStrictRoomIds).toEqual(["!fresh:example.org"]); + }); + it("falls back to discovered strict joined rooms when m.direct is stale", async () => { const client = createClient({ getAccountData: vi.fn(async () => ({ diff --git a/extensions/matrix/src/matrix/direct-management.ts b/extensions/matrix/src/matrix/direct-management.ts index 209f63181b5..7eb926d5c73 100644 --- a/extensions/matrix/src/matrix/direct-management.ts +++ b/extensions/matrix/src/matrix/direct-management.ts @@ -277,7 +277,7 @@ export async function inspectMatrixDirectRooms(params: { const mappedStrict = mappedRooms.find((room) => room.strict); let joinedRooms: string[] = []; - if (!mappedStrict && typeof params.client.getJoinedRooms === "function") { + if (typeof params.client.getJoinedRooms === "function") { try { const resolved = await params.client.getJoinedRooms(); joinedRooms = Array.isArray(resolved) ? resolved : []; diff --git a/extensions/matrix/src/matrix/monitor/direct.test.ts b/extensions/matrix/src/matrix/monitor/direct.test.ts index 0c5edfcf769..ad865ce55e1 100644 --- a/extensions/matrix/src/matrix/monitor/direct.test.ts +++ b/extensions/matrix/src/matrix/monitor/direct.test.ts @@ -128,6 +128,43 @@ describe("createDirectRoomTracker", () => { expect(client.getJoinedRoomMembers).toHaveBeenCalledWith("!room:example.org"); }); + it("promotes strict unmapped rooms when the per-room fallback gate allows it", async () => { + const client = createMockClient({ isDm: false, dmCacheAvailable: true }); + const tracker = createDirectRoomTracker(client, { + canPromoteUnmappedStrictRoom: () => true, + }); + + await expect( + tracker.isDirectMessage({ + roomId: "!room:example.org", + senderId: "@alice:example.org", + }), + ).resolves.toBe(true); + + expect(client.setAccountData).toHaveBeenCalledWith( + EventType.Direct, + expect.objectContaining({ + "@alice:example.org": ["!room:example.org"], + }), + ); + }); + + it("does not promote strict unmapped rooms when the per-room fallback gate vetoes it", async () => { + const client = createMockClient({ isDm: false, dmCacheAvailable: true }); + const tracker = createDirectRoomTracker(client, { + canPromoteUnmappedStrictRoom: () => false, + }); + + await expect( + tracker.isDirectMessage({ + roomId: "!room:example.org", + senderId: "@alice:example.org", + }), + ).resolves.toBe(false); + + expect(client.setAccountData).not.toHaveBeenCalled(); + }); + it("falls back to strict 2-member membership before m.direct account data is available", async () => { const client = createMockClient({ isDm: false, dmCacheAvailable: false }); const tracker = createDirectRoomTracker(client); diff --git a/extensions/matrix/src/matrix/monitor/direct.ts b/extensions/matrix/src/matrix/monitor/direct.ts index d8727820e8c..bd520c4a6a6 100644 --- a/extensions/matrix/src/matrix/monitor/direct.ts +++ b/extensions/matrix/src/matrix/monitor/direct.ts @@ -15,6 +15,7 @@ type DirectMessageCheck = { type DirectRoomTrackerOptions = { log?: (message: string) => void; canPromoteRecentInvite?: (roomId: string) => boolean | Promise; + canPromoteUnmappedStrictRoom?: (roomId: string) => boolean | Promise; shouldKeepLocallyPromotedDirectRoom?: | ((roomId: string) => boolean | undefined | Promise) | undefined; @@ -141,6 +142,15 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr } }; + const canPromoteUnmappedStrictRoom = async (roomId: string): Promise => { + try { + return (await opts.canPromoteUnmappedStrictRoom?.(roomId)) ?? false; + } catch (err) { + log(`matrix: unmapped strict room promotion veto failed room=${roomId} (${String(err)})`); + return false; + } + }; + const shouldKeepLocallyPromotedDirectRoom = async ( roomId: string, ): Promise => { @@ -259,6 +269,22 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr return true; } } + + if (await canPromoteUnmappedStrictRoom(roomId)) { + const promotion = await promoteMatrixDirectRoomCandidate({ + client, + remoteUserId: senderId ?? "", + roomId, + selfUserId, + }); + if (promotion.classifyAsDirect) { + rememberLocallyPromotedDirectRoom(roomId, senderId ?? ""); + log( + `matrix: dm detected via per-room strict fallback room=${roomId} reason=${promotion.reason} repaired=${String(promotion.repaired)}`, + ); + return true; + } + } } log( diff --git a/extensions/matrix/src/matrix/monitor/index.test.ts b/extensions/matrix/src/matrix/monitor/index.test.ts index 21f1efbb46c..aaa3026e9bb 100644 --- a/extensions/matrix/src/matrix/monitor/index.test.ts +++ b/extensions/matrix/src/matrix/monitor/index.test.ts @@ -5,6 +5,7 @@ import type { MatrixRoomInfo } from "./room-info.js"; type DirectRoomTrackerOptions = { canPromoteRecentInvite?: (roomId: string) => boolean | Promise; + canPromoteUnmappedStrictRoom?: (roomId: string) => boolean | Promise; shouldKeepLocallyPromotedDirectRoom?: | ((roomId: string) => boolean | undefined | Promise) | undefined; @@ -981,6 +982,40 @@ describe("monitorMatrixProvider", () => { await expect(trackerOpts.canPromoteRecentInvite("!room:example.org")).resolves.toBe(false); }); + it("does not wire unmapped strict room promotion for per-user DM scope", async () => { + await startMonitorAndAbortAfterStartup(); + + const trackerOpts = hoisted.createDirectRoomTracker.mock.calls[0]?.[1]; + + expect(trackerOpts?.canPromoteUnmappedStrictRoom).toBeUndefined(); + }); + + it("wires per-room unmapped strict room promotion through the room metadata gate", async () => { + hoisted.accountConfig.dm = { sessionScope: "per-room" }; + + await startMonitorAndAbortAfterStartup(); + + const trackerOpts = hoisted.createDirectRoomTracker.mock.calls[0]?.[1]; + if (!trackerOpts?.canPromoteUnmappedStrictRoom) { + throw new Error("per-room strict fallback callback was not wired"); + } + + hoisted.getRoomInfo.mockResolvedValueOnce({ + altAliases: [], + nameResolved: true, + aliasesResolved: true, + }); + await expect(trackerOpts.canPromoteUnmappedStrictRoom("!dm:example.org")).resolves.toBe(true); + + hoisted.getRoomInfo.mockResolvedValueOnce({ + name: "Ops Room", + altAliases: [], + nameResolved: true, + aliasesResolved: true, + }); + await expect(trackerOpts.canPromoteUnmappedStrictRoom("!ops:example.org")).resolves.toBe(false); + }); + it("treats unresolved room metadata as indeterminate for local promotion revalidation", async () => { await startMonitorAndAbortAfterStartup(); diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index 167b98b938e..55be5eeca26 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -353,6 +353,16 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi roomInfo: await getRoomInfo(roomId, { includeAliases: true }), rooms: roomsConfig, }), + ...(dmSessionScope === "per-room" + ? { + canPromoteUnmappedStrictRoom: async (roomId) => + shouldPromoteRecentInviteRoom({ + roomId, + roomInfo: await getRoomInfo(roomId, { includeAliases: true }), + rooms: roomsConfig, + }), + } + : {}), shouldKeepLocallyPromotedDirectRoom: async (roomId) => { try { const roomInfo = await getRoomInfo(roomId, { includeAliases: true });