feat(agent): add human-like delay between block replies
Adds `agent.humanDelay` config option to create natural rhythm between
streamed message bubbles. When enabled, introduces a random delay
(default 800-2500ms) between block replies, making multi-message
responses feel more like natural human texting.
Config example:
```json
{
"agent": {
"blockStreamingDefault": "on",
"humanDelay": {
"enabled": true,
"minMs": 800,
"maxMs": 2500
}
}
}
```
- First message sends immediately
- Subsequent messages wait a random delay before sending
- Works with iMessage, Signal, and Discord providers
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -103,4 +103,55 @@ describe("createReplyDispatcher", () => {
|
||||
await dispatcher.waitForIdle();
|
||||
expect(onIdle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("delays block replies after the first when humanDelay is natural", async () => {
|
||||
vi.useFakeTimers();
|
||||
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0);
|
||||
const deliver = vi.fn().mockResolvedValue(undefined);
|
||||
const dispatcher = createReplyDispatcher({
|
||||
deliver,
|
||||
humanDelay: { mode: "natural" },
|
||||
});
|
||||
|
||||
dispatcher.sendBlockReply({ text: "first" });
|
||||
await Promise.resolve();
|
||||
expect(deliver).toHaveBeenCalledTimes(1);
|
||||
|
||||
dispatcher.sendBlockReply({ text: "second" });
|
||||
await Promise.resolve();
|
||||
expect(deliver).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(799);
|
||||
expect(deliver).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await dispatcher.waitForIdle();
|
||||
expect(deliver).toHaveBeenCalledTimes(2);
|
||||
|
||||
randomSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("uses custom bounds for humanDelay and clamps when max <= min", async () => {
|
||||
vi.useFakeTimers();
|
||||
const deliver = vi.fn().mockResolvedValue(undefined);
|
||||
const dispatcher = createReplyDispatcher({
|
||||
deliver,
|
||||
humanDelay: { mode: "custom", minMs: 1200, maxMs: 400 },
|
||||
});
|
||||
|
||||
dispatcher.sendBlockReply({ text: "first" });
|
||||
await Promise.resolve();
|
||||
expect(deliver).toHaveBeenCalledTimes(1);
|
||||
|
||||
dispatcher.sendBlockReply({ text: "second" });
|
||||
await vi.advanceTimersByTimeAsync(1199);
|
||||
expect(deliver).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await dispatcher.waitForIdle();
|
||||
expect(deliver).toHaveBeenCalledTimes(2);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { HumanDelayConfig } from "../../config/types.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
import { normalizeReplyPayload } from "./normalize-reply.js";
|
||||
import type { TypingController } from "./typing.js";
|
||||
@@ -14,12 +15,36 @@ type ReplyDispatchDeliverer = (
|
||||
info: { kind: ReplyDispatchKind },
|
||||
) => Promise<void>;
|
||||
|
||||
const DEFAULT_HUMAN_DELAY_MIN_MS = 800;
|
||||
const DEFAULT_HUMAN_DELAY_MAX_MS = 2500;
|
||||
|
||||
/** Generate a random delay within the configured range. */
|
||||
function getHumanDelay(config: HumanDelayConfig | undefined): number {
|
||||
const mode = config?.mode ?? "off";
|
||||
if (mode === "off") return 0;
|
||||
const min =
|
||||
mode === "custom"
|
||||
? (config?.minMs ?? DEFAULT_HUMAN_DELAY_MIN_MS)
|
||||
: DEFAULT_HUMAN_DELAY_MIN_MS;
|
||||
const max =
|
||||
mode === "custom"
|
||||
? (config?.maxMs ?? DEFAULT_HUMAN_DELAY_MAX_MS)
|
||||
: DEFAULT_HUMAN_DELAY_MAX_MS;
|
||||
if (max <= min) return min;
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
/** Sleep for a given number of milliseconds. */
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export type ReplyDispatcherOptions = {
|
||||
deliver: ReplyDispatchDeliverer;
|
||||
responsePrefix?: string;
|
||||
onHeartbeatStrip?: () => void;
|
||||
onIdle?: () => void;
|
||||
onError?: ReplyDispatchErrorHandler;
|
||||
/** Human-like delay between block replies for natural rhythm. */
|
||||
humanDelay?: HumanDelayConfig;
|
||||
};
|
||||
|
||||
export type ReplyDispatcherWithTypingOptions = Omit<
|
||||
@@ -60,6 +85,8 @@ export function createReplyDispatcher(
|
||||
let sendChain: Promise<void> = Promise.resolve();
|
||||
// Track in-flight deliveries so we can emit a reliable "idle" signal.
|
||||
let pending = 0;
|
||||
// Track whether we've sent a block reply (for human delay - skip delay on first block).
|
||||
let sentFirstBlock = false;
|
||||
// Serialize outbound replies to preserve tool/block/final order.
|
||||
const queuedCounts: Record<ReplyDispatchKind, number> = {
|
||||
tool: 0,
|
||||
@@ -72,8 +99,20 @@ export function createReplyDispatcher(
|
||||
if (!normalized) return false;
|
||||
queuedCounts[kind] += 1;
|
||||
pending += 1;
|
||||
|
||||
// Determine if we should add human-like delay (only for block replies after the first).
|
||||
const shouldDelay = kind === "block" && sentFirstBlock;
|
||||
if (kind === "block") sentFirstBlock = true;
|
||||
|
||||
sendChain = sendChain
|
||||
.then(() => options.deliver(normalized, { kind }))
|
||||
.then(async () => {
|
||||
// Add human-like delay between block replies for natural rhythm.
|
||||
if (shouldDelay) {
|
||||
const delayMs = getHumanDelay(options.humanDelay);
|
||||
if (delayMs > 0) await sleep(delayMs);
|
||||
}
|
||||
await options.deliver(normalized, { kind });
|
||||
})
|
||||
.catch((err) => {
|
||||
options.onError?.(err, { kind });
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user