@@ -509,6 +509,90 @@ describe("mattermost inbound user posts", () => {
|
509 | 509 | const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx; |
510 | 510 | expect(ctx?.BodyForAgent).toBe("hello /status"); |
511 | 511 | expect(ctx?.CommandAuthorized).toBe(false); |
| 512 | +// Inline non-control text must not be tagged as an explicit text-slash command turn — |
| 513 | +// only authorized control commands take the source-reply suppression bypass. |
| 514 | +expect(ctx?.CommandSource).toBeUndefined(); |
| 515 | +}); |
| 516 | + |
| 517 | +// Regression for issue #86664: typed `/reset` (and `/new`) on a Mattermost DM under |
| 518 | +// message_tool_only source delivery (e.g. Codex harness default) silently dropped the |
| 519 | +// acknowledgement because the inbound context was not tagged as an explicit text-slash |
| 520 | +// command turn. The explicit-command exception in source-reply-delivery-mode.ts only |
| 521 | +// fires when CommandSource is "text" (or "native") AND CommandAuthorized is true. Mirrors |
| 522 | +// the iMessage fix from #82642. |
| 523 | +it("tags authorized typed text-slash control commands with CommandSource: text", async () => { |
| 524 | +const socket = new FakeWebSocket(); |
| 525 | +const abortController = new AbortController(); |
| 526 | +mockState.abortController = abortController; |
| 527 | +const directConfig: OpenClawConfig = { |
| 528 | +channels: { |
| 529 | +mattermost: { |
| 530 | +enabled: true, |
| 531 | +baseUrl: "https://mattermost.example.com", |
| 532 | +botToken: "bot-token", |
| 533 | +chatmode: "onmessage", |
| 534 | +dmPolicy: "allowlist", |
| 535 | +groupPolicy: "open", |
| 536 | +allowFrom: ["user-1"], |
| 537 | +}, |
| 538 | +}, |
| 539 | +}; |
| 540 | +const isControlCommandMessage = vi.fn((text?: string) => |
| 541 | +["/reset", "/new"].includes(text?.trim() ?? ""), |
| 542 | +); |
| 543 | +const shouldComputeCommandAuthorized = vi.fn(() => true); |
| 544 | +mockState.runtimeCore = createRuntimeCore(directConfig, undefined, { |
| 545 | + isControlCommandMessage, |
| 546 | + shouldComputeCommandAuthorized, |
| 547 | +shouldHandleTextCommands: () => true, |
| 548 | +}); |
| 549 | +mockState.resolveChannelInfo.mockResolvedValue({ |
| 550 | +id: "dm-1", |
| 551 | +name: "", |
| 552 | +display_name: "", |
| 553 | +team_id: "team-1", |
| 554 | +type: "D", |
| 555 | +}); |
| 556 | + |
| 557 | +const monitor = monitorMattermostProvider({ |
| 558 | +config: directConfig, |
| 559 | +runtime: testRuntime(), |
| 560 | +abortSignal: abortController.signal, |
| 561 | +webSocketFactory: () => socket, |
| 562 | +}); |
| 563 | + |
| 564 | +await vi.waitFor(() => { |
| 565 | +expect(socket.openListenerCount).toBeGreaterThan(0); |
| 566 | +}); |
| 567 | +socket.emitOpen(); |
| 568 | + |
| 569 | +await socket.emitMessage({ |
| 570 | +event: "posted", |
| 571 | +data: { |
| 572 | +channel_id: "dm-1", |
| 573 | +sender_name: "alice", |
| 574 | +post: JSON.stringify({ |
| 575 | +id: "post-reset", |
| 576 | +channel_id: "dm-1", |
| 577 | +user_id: "user-1", |
| 578 | +message: " /reset", |
| 579 | +create_at: 1_714_000_000_000, |
| 580 | +}), |
| 581 | +}, |
| 582 | +broadcast: { |
| 583 | +channel_id: "dm-1", |
| 584 | +user_id: "user-1", |
| 585 | +}, |
| 586 | +}); |
| 587 | +socket.emitClose(1000); |
| 588 | +await monitor; |
| 589 | + |
| 590 | +expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1); |
| 591 | +const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx; |
| 592 | +expect(ctx?.BodyForAgent).toBe("/reset"); |
| 593 | +expect(ctx?.CommandBody).toBe("/reset"); |
| 594 | +expect(ctx?.CommandAuthorized).toBe(true); |
| 595 | +expect(ctx?.CommandSource).toBe("text"); |
512 | 596 | }); |
513 | 597 | |
514 | 598 | it("uses websocket channel type when REST channel lookup fails", async () => { |
|