fix(replies): keep queued followup typing alive

This commit is contained in:
Peter Steinberger
2026-05-02 05:45:08 +01:00
parent 7c2802b212
commit a3c9c098e5
3 changed files with 30 additions and 3 deletions

View File

@@ -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.

View File

@@ -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 () => {

View File

@@ -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;
}