diff --git a/extensions/matrix/src/matrix/monitor/handler.test.ts b/extensions/matrix/src/matrix/monitor/handler.test.ts index c826dc6e576..9d1655b3444 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test.ts @@ -79,6 +79,37 @@ describe("matrix monitor handler pairing account scope", () => { expect(readAllowFromStore).toHaveBeenCalledTimes(1); }); + it("refreshes the account-scoped allowFrom cache after its ttl expires", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-01T10:00:00.000Z")); + try { + const readAllowFromStore = vi.fn(async () => [] as string[]); + const { handler } = createMatrixHandlerTestHarness({ + readAllowFromStore, + dmPolicy: "pairing", + buildPairingReply: () => "pairing", + }); + + const makeEvent = (id: string): MatrixRawEvent => + createMatrixTextMessageEvent({ + eventId: id, + body: "hello", + mentions: { room: true }, + }); + + await handler("!room:example.org", makeEvent("$event1")); + await handler("!room:example.org", makeEvent("$event2")); + expect(readAllowFromStore).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(30_001); + await handler("!room:example.org", makeEvent("$event3")); + + expect(readAllowFromStore).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + it("sends pairing reminders for pending requests with cooldown", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-01T10:00:00.000Z")); diff --git a/extensions/matrix/src/matrix/monitor/startup-verification.test.ts b/extensions/matrix/src/matrix/monitor/startup-verification.test.ts index e3dd06e5829..88a53106287 100644 --- a/extensions/matrix/src/matrix/monitor/startup-verification.test.ts +++ b/extensions/matrix/src/matrix/monitor/startup-verification.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { ensureMatrixStartupVerification } from "./startup-verification.js"; function createTempStateDir(): string { @@ -79,10 +79,6 @@ function createHarness(params?: { }; } -afterEach(() => { - vi.useRealTimers(); -}); - describe("ensureMatrixStartupVerification", () => { it("skips automatic requests when the device is already verified", async () => { const tempHome = createTempStateDir(); @@ -145,16 +141,15 @@ describe("ensureMatrixStartupVerification", () => { }); it("respects the startup verification cooldown", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-08T12:00:00.000Z")); const tempHome = createTempStateDir(); const harness = createHarness(); + const initialNowMs = Date.parse("2026-03-08T12:00:00.000Z"); await ensureMatrixStartupVerification({ client: harness.client as never, auth: createAuth(), accountConfig: {}, stateFilePath: createStateFilePath(tempHome), - nowMs: Date.now(), + nowMs: initialNowMs, }); expect(harness.client.crypto.requestVerification).toHaveBeenCalledTimes(1); @@ -163,7 +158,7 @@ describe("ensureMatrixStartupVerification", () => { auth: createAuth(), accountConfig: {}, stateFilePath: createStateFilePath(tempHome), - nowMs: Date.now() + 60_000, + nowMs: initialNowMs + 60_000, }); expect(second.kind).toBe("cooldown"); diff --git a/extensions/matrix/src/matrix/thread-bindings.test.ts b/extensions/matrix/src/matrix/thread-bindings.test.ts index aeabc3edd5d..ecf77e11733 100644 --- a/extensions/matrix/src/matrix/thread-bindings.test.ts +++ b/extensions/matrix/src/matrix/thread-bindings.test.ts @@ -40,6 +40,24 @@ describe("matrix thread bindings", () => { accessToken: "token", } as const; + function resolveBindingsFilePath() { + return path.join( + resolveMatrixStoragePaths({ + ...auth, + env: process.env, + }).rootDir, + "thread-bindings.json", + ); + } + + async function readPersistedLastActivityAt(bindingsPath: string) { + const raw = await fs.readFile(bindingsPath, "utf-8"); + const parsed = JSON.parse(raw) as { + bindings?: Array<{ lastActivityAt?: number }>; + }; + return parsed.bindings?.[0]?.lastActivityAt; + } + beforeEach(async () => { stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "matrix-thread-bindings-")); __testing.resetSessionBindingAdaptersForTests(); @@ -274,6 +292,50 @@ describe("matrix thread bindings", () => { } }); + it("persists the latest touched activity only after the debounce window", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-06T10:00:00.000Z")); + try { + await createMatrixThreadBindingManager({ + accountId: "ops", + auth, + client: {} as never, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, + enableSweeper: false, + }); + const binding = await getSessionBindingService().bind({ + targetSessionKey: "agent:ops:subagent:child", + targetKind: "subagent", + conversation: { + channel: "matrix", + accountId: "ops", + conversationId: "$thread", + parentConversationId: "!room:example", + }, + placement: "current", + }); + + const bindingsPath = resolveBindingsFilePath(); + const originalLastActivityAt = await readPersistedLastActivityAt(bindingsPath); + const firstTouchedAt = Date.parse("2026-03-06T10:05:00.000Z"); + const secondTouchedAt = Date.parse("2026-03-06T10:10:00.000Z"); + + getSessionBindingService().touch(binding.bindingId, firstTouchedAt); + getSessionBindingService().touch(binding.bindingId, secondTouchedAt); + + await vi.advanceTimersByTimeAsync(29_000); + expect(await readPersistedLastActivityAt(bindingsPath)).toBe(originalLastActivityAt); + + await vi.advanceTimersByTimeAsync(1_000); + await vi.waitFor(async () => { + expect(await readPersistedLastActivityAt(bindingsPath)).toBe(secondTouchedAt); + }); + } finally { + vi.useRealTimers(); + } + }); + it("flushes pending touch persistence on stop", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-06T10:00:00.000Z")); @@ -303,19 +365,9 @@ describe("matrix thread bindings", () => { manager.stop(); vi.useRealTimers(); - const bindingsPath = path.join( - resolveMatrixStoragePaths({ - ...auth, - env: process.env, - }).rootDir, - "thread-bindings.json", - ); + const bindingsPath = resolveBindingsFilePath(); await vi.waitFor(async () => { - const raw = await fs.readFile(bindingsPath, "utf-8"); - const parsed = JSON.parse(raw) as { - bindings?: Array<{ lastActivityAt?: number }>; - }; - expect(parsed.bindings?.[0]?.lastActivityAt).toBe(touchedAt); + expect(await readPersistedLastActivityAt(bindingsPath)).toBe(touchedAt); }); } finally { vi.useRealTimers();