diff --git a/CHANGELOG.md b/CHANGELOG.md index 7edcc627a93..65d1fbb2477 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai - Web search/MiniMax: allow `MINIMAX_OAUTH_TOKEN` to satisfy MiniMax Search credentials, so OAuth-authorized MiniMax Token Plan setups do not need a separate web-search key. Fixes #65768. Thanks @kikibrian and @zhouhe-xydt. - Subagents: honor `sessions_spawn` with `expectsCompletionMessage: false` by skipping parent completion handoff delivery while still running child cleanup. Fixes #75848. Thanks @alfredjbclaw. - Gateway/logging: keep deferred channel startup logs on the subsystem logger, so Slack, Discord, Telegram, and voice-call startup messages keep timestamped prefixes. Thanks @vincentkoc. +- Replies/typing: keep typing alive for queued follow-up messages that are genuinely waiting behind an active run, instead of making chat surfaces look idle while work is queued. Fixes #65685. Thanks @papag00se. - ACP/Discord: suppress completion announce delivery for inline thread-bound ACP session runs, so Discord thread-bound ACP replies are not delivered twice. Fixes #60780. Thanks @solavrc. - Discord/threads: ignore webhook-authored copies in already-bound Discord session threads even when the webhook id differs, preventing PluralKit proxy copies from creating duplicate turn pressure. Fixes #52005. Thanks @acgh213. - Discord/threads: return the created thread as partial success when the follow-up initial message fails, so agents do not retry thread creation and create empty duplicate threads. Fixes #48450. Thanks @dahifi. diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts index c0eec929550..9aa9d6a56c8 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts @@ -219,8 +219,28 @@ describe("runReplyAgent heartbeat followup guard", () => { expect(state.runEmbeddedPiAgentMock).not.toHaveBeenCalled(); }); + it("keeps typing alive when a followup is queued behind a live active run", async () => { + const { run, typing } = createMinimalRun({ + opts: { isHeartbeat: false }, + isActive: true, + isRunActive: () => true, + shouldFollowup: true, + resolvedQueueMode: "collect", + }); + + const result = await run(); + + expect(result).toBeUndefined(); + expect(vi.mocked(enqueueFollowupRun)).toHaveBeenCalledTimes(1); + expect(vi.mocked(scheduleFollowupDrain)).not.toHaveBeenCalled(); + expect(state.runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(typing.startTypingLoop).toHaveBeenCalledTimes(1); + expect(typing.refreshTypingTtl).toHaveBeenCalledTimes(1); + expect(typing.cleanup).not.toHaveBeenCalled(); + }); + it("starts draining immediately when the active snapshot is already stale", async () => { - const { run } = createMinimalRun({ + const { run, typing } = createMinimalRun({ opts: { isHeartbeat: false }, isActive: true, isRunActive: () => false, @@ -234,6 +254,7 @@ describe("runReplyAgent heartbeat followup guard", () => { expect(vi.mocked(enqueueFollowupRun)).toHaveBeenCalledTimes(1); expect(vi.mocked(scheduleFollowupDrain)).toHaveBeenCalledTimes(1); expect(state.runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(typing.cleanup).toHaveBeenCalledTimes(1); }); it("drains followup queue when an unexpected exception escapes the run path", async () => { diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 15cb1499b1a..c90bb14029d 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -1045,11 +1045,16 @@ export async function runReplyAgent(params: { ); // Re-check liveness after enqueue so a stale active snapshot cannot leave // the followup queue idle if the original run already finished. - if (!isRunActive?.()) { + const queuedBehindActiveRun = isRunActive?.() === true; + if (!queuedBehindActiveRun) { finalizeWithFollowup(undefined, queueKey, queuedRunFollowupTurn); } await touchActiveSessionEntry(); - typing.cleanup(); + if (queuedBehindActiveRun) { + await typingSignals.signalToolStart(); + } else { + typing.cleanup(); + } return undefined; }