diff --git a/CHANGELOG.md b/CHANGELOG.md index 0aa25f9858d..dea5a6c89ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai - QA/Slack: fail the live mention-gating scenario on any unexpected SUT reply, even when the reply does not echo the expected marker. Thanks @vincentkoc. - QA/Matrix: steer the live tool-progress preview check away from `HEARTBEAT.md` and report final preview candidates when the live marker reply misses the exact token. Thanks @vincentkoc. - QA/Matrix: let the live tool-progress preview check verify progress replacement events without depending on the preview saying `Working`. Thanks @vincentkoc. +- QA/Matrix: wait for live approval reactions to echo before starting the threaded approval decision timeout. Thanks @vincentkoc. - Tlon: expose `groupInviteAllowlist` in the channel config schema and clarify that group invite auto-accept fails closed without an invite allowlist. Thanks @vincentkoc. - Control UI/WebChat: collapse duplicate in-flight internal text sends onto the active Gateway run so rapid repeat submits do not start fresh `agent:main:main` dispatches. Fixes #75737. Thanks @dsdsddd1 and @BunsDev. - Mattermost: accept the documented `channels.mattermost.streaming` config and honor `streaming: "off"` by disabling draft preview posts. Thanks @vincentkoc. diff --git a/extensions/qa-matrix/src/runners/contract/scenario-runtime-approval.ts b/extensions/qa-matrix/src/runners/contract/scenario-runtime-approval.ts index 55e0978c914..20fdba30d1a 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-runtime-approval.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime-approval.ts @@ -179,6 +179,23 @@ async function reactToApproval(params: { messageId: params.targetEventId, roomId: params.roomId, }); + await client + .waitForRoomEvent({ + observedEvents: params.context.observedEvents, + predicate: (event) => + event.roomId === params.roomId && + event.sender === params.context.driverUserId && + event.type === "m.reaction" && + event.reaction?.eventId === params.targetEventId && + event.reaction.key === emoji, + roomId: params.roomId, + timeoutMs: params.context.timeoutMs, + }) + .catch((err: unknown) => { + throw new Error( + `Matrix approval reaction ${eventId} was not observed before waiting for the gateway decision: ${String(err)}`, + ); + }); return { eventId, reaction: { diff --git a/extensions/qa-matrix/src/runners/contract/scenarios.test.ts b/extensions/qa-matrix/src/runners/contract/scenarios.test.ts index ddd5908e4c5..e03fe69991d 100644 --- a/extensions/qa-matrix/src/runners/contract/scenarios.test.ts +++ b/extensions/qa-matrix/src/runners/contract/scenarios.test.ts @@ -369,6 +369,103 @@ describe("matrix live qa scenarios", () => { expect(shardIds.toSorted()).toEqual(allIds.toSorted()); }); + it("waits for the driver Matrix approval reaction echo before awaiting the decision", async () => { + const context = matrixQaScenarioContext(); + let approvalId = ""; + const gatewayCall = vi.fn().mockImplementation(async (method: string, ...args: unknown[]) => { + if (method === "exec.approval.request") { + const params = args.find( + (arg): arg is { id?: unknown } => typeof arg === "object" && arg !== null && "id" in arg, + ); + const payload = + typeof params === "object" && params !== null ? (params as { id?: unknown }) : undefined; + approvalId = String(payload?.id ?? "approval-missing"); + return { id: approvalId, status: "accepted" }; + } + if (method === "exec.approval.waitDecision") { + return { decision: "allow-once", id: approvalId }; + } + throw new Error(`unexpected gateway method ${method}`); + }); + context.gatewayCall = gatewayCall; + const rootEventId = "$approval-thread-root"; + const approvalEventId = "$approval-thread-event"; + const sendReaction = vi.fn().mockResolvedValue("$driver-approval-reaction"); + const waitForRoomEvent = vi + .fn() + .mockImplementationOnce(async () => ({ + event: matrixQaMessageEvent({ + approval: { + allowedDecisions: ["allow-once", "deny"], + hasCommandText: true, + id: approvalId, + kind: "exec", + state: "pending", + type: "approval.request", + version: 1, + }, + body: "approval requested", + eventId: approvalEventId, + kind: "message", + relatesTo: { + eventId: rootEventId, + inReplyToId: rootEventId, + isFallingBack: true, + relType: "m.thread", + }, + }), + since: "driver-sync-approval", + })) + .mockImplementationOnce(async () => ({ + event: { + eventId: "$bot-approval-option", + kind: "reaction", + reaction: { + eventId: approvalEventId, + key: "✅", + }, + roomId: "!main:matrix-qa.test", + sender: "@sut:matrix-qa.test", + type: "m.reaction", + } satisfies MatrixQaObservedEvent, + since: "driver-sync-option", + })) + .mockImplementationOnce(async () => ({ + event: { + eventId: "$driver-approval-reaction", + kind: "reaction", + reaction: { + eventId: approvalEventId, + key: "✅", + }, + roomId: "!main:matrix-qa.test", + sender: "@driver:matrix-qa.test", + type: "m.reaction", + } satisfies MatrixQaObservedEvent, + since: "driver-sync-driver-reaction", + })); + createMatrixQaClient.mockReturnValue({ + primeRoom: vi.fn().mockResolvedValue("driver-sync-start"), + sendReaction, + sendTextMessage: vi.fn().mockResolvedValue(rootEventId), + waitForRoomEvent, + }); + + const scenario = MATRIX_QA_SCENARIOS.find( + (entry) => entry.id === "matrix-approval-thread-target", + ); + expect(scenario).toBeDefined(); + + await expect(runMatrixQaScenario(scenario!, context)).resolves.toMatchObject({ + artifacts: { + reactionEventId: "$driver-approval-reaction", + reactionTargetEventId: approvalEventId, + }, + }); + expect(waitForRoomEvent).toHaveBeenCalledTimes(3); + expect(gatewayCall.mock.calls.at(-1)?.[0]).toBe("exec.approval.waitDecision"); + }); + it("lets explicit Matrix scenario ids override the selected profile", () => { expect( scenarioTesting