diff --git a/CHANGELOG.md b/CHANGELOG.md index d70562d49..a2aa745fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai - Telegram/Inline buttons: allow callback-query button handling in groups (including `/models` follow-up buttons) when group policy authorizes the sender, by removing the redundant callback allowlist gate that blocked open-policy groups. (#27343) Thanks @GodsBoy. - Telegram/Streaming preview: when finalizing without an existing preview message, prime pending preview text with final answer before stop-flush so users do not briefly see stale 1-2 word fragments (for example `no` before `no problem`). (#27449) Thanks @emanuelst for the original fix direction in #19673. - Typing/Main reply pipeline: always mark dispatch idle in `agent-runner` finalization so typing cleanup runs even when dispatcher `onIdle` does not fire, preventing stuck typing indicators after run completion. (#27250) Thanks @Sid-Qin. +- Typing/Run completion race: prevent post-run keepalive ticks from re-triggering typing callbacks by guarding `triggerTyping()` with `runComplete`, with regression coverage for no-restart behavior during run-complete/dispatch-idle boundaries. (#27413) Thanks @widingmarcus-cyber. - Daemon/macOS launchd: forward proxy env vars into supervised service environments, keep LaunchAgent `KeepAlive=true` semantics, and harden restart sequencing to `print -> bootout -> wait old pid exit -> bootstrap -> kickstart`. (#27276) thanks @frankekn. - Web tools/Proxy: route `web_search` provider HTTP calls (Brave, Perplexity, xAI, Gemini, Kimi), redirect resolution, and `web_fetch` through a shared proxy-aware SSRF guard path so gateway installs behind `HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY` no longer fail with transport `fetch failed` errors. (#27430) thanks @kevinWangSheng. - Android/Node invoke: remove native gateway WebSocket `Origin` header to avoid false origin rejections, unify invoke command registry/policy/error parsing paths, and keep command availability checks centralized to reduce dispatcher/advertisement drift. (#27257) Thanks @obviyus. diff --git a/src/auto-reply/reply/reply-utils.test.ts b/src/auto-reply/reply/reply-utils.test.ts index ef5a3a733..f704abf95 100644 --- a/src/auto-reply/reply/reply-utils.test.ts +++ b/src/auto-reply/reply/reply-utils.test.ts @@ -142,7 +142,7 @@ describe("typing controller", () => { typing.markDispatchIdle(); } await vi.advanceTimersByTimeAsync(2_000); - expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(5); + expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(testCase.first === "run" ? 3 : 5); if (testCase.second === "run") { typing.markRunComplete(); @@ -150,7 +150,7 @@ describe("typing controller", () => { typing.markDispatchIdle(); } await vi.advanceTimersByTimeAsync(2_000); - expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(5); + expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(testCase.first === "run" ? 3 : 5); } }); diff --git a/src/auto-reply/reply/typing-persistence.test.ts b/src/auto-reply/reply/typing-persistence.test.ts new file mode 100644 index 000000000..c57e3cbf4 --- /dev/null +++ b/src/auto-reply/reply/typing-persistence.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from "vitest"; +import { createTypingController } from "./typing.js"; + +describe("typing persistence bug fix", () => { + let onReplyStartSpy: Mock; + let onCleanupSpy: Mock; + let controller: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + onReplyStartSpy = vi.fn(); + onCleanupSpy = vi.fn(); + + controller = createTypingController({ + onReplyStart: onReplyStartSpy, + onCleanup: onCleanupSpy, + typingIntervalSeconds: 6, + log: vi.fn(), + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should NOT restart typing after markRunComplete is called", async () => { + // Start typing normally + await controller.startTypingLoop(); + expect(onReplyStartSpy).toHaveBeenCalledTimes(1); + + // Mark run as complete (but not yet dispatch idle) + controller.markRunComplete(); + + // Advance time to trigger the typing interval (6 seconds) + vi.advanceTimersByTime(6000); + + // BUG: The typing loop should NOT call onReplyStart again + // because the run is already complete + expect(onReplyStartSpy).toHaveBeenCalledTimes(1); + expect(onReplyStartSpy).not.toHaveBeenCalledTimes(2); + }); + + it("should stop typing when both runComplete and dispatchIdle are true", async () => { + // Start typing + await controller.startTypingLoop(); + expect(onReplyStartSpy).toHaveBeenCalledTimes(1); + + // Mark run complete + controller.markRunComplete(); + expect(onCleanupSpy).not.toHaveBeenCalled(); + + // Mark dispatch idle - should trigger cleanup + controller.markDispatchIdle(); + expect(onCleanupSpy).toHaveBeenCalledTimes(1); + + // After cleanup, typing interval should not restart typing + vi.advanceTimersByTime(6000); + expect(onReplyStartSpy).toHaveBeenCalledTimes(1); // Still only the initial call + }); + + it("should prevent typing restart even if cleanup is delayed", async () => { + // Start typing + await controller.startTypingLoop(); + expect(onReplyStartSpy).toHaveBeenCalledTimes(1); + + // Mark run complete (but dispatch not idle yet - simulating cleanup delay) + controller.markRunComplete(); + + // Multiple typing intervals should NOT restart typing + vi.advanceTimersByTime(6000); // First interval + expect(onReplyStartSpy).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(6000); // Second interval + expect(onReplyStartSpy).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(6000); // Third interval + expect(onReplyStartSpy).toHaveBeenCalledTimes(1); + + // Eventually dispatch becomes idle and triggers cleanup + controller.markDispatchIdle(); + expect(onCleanupSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/auto-reply/reply/typing.ts b/src/auto-reply/reply/typing.ts index 6f8ce6be5..82e3d7622 100644 --- a/src/auto-reply/reply/typing.ts +++ b/src/auto-reply/reply/typing.ts @@ -99,6 +99,10 @@ export function createTypingController(params: { if (sealed) { return; } + // Late callbacks after a run completed should never restart typing. + if (runComplete) { + return; + } await onReplyStart?.(); };