refactor: centralize typing mode signals

This commit is contained in:
Peter Steinberger
2026-01-07 22:18:11 +00:00
parent bac1608933
commit 434c25331e
13 changed files with 192 additions and 73 deletions

View File

@@ -71,3 +71,4 @@ Only the owner number (from `whatsapp.allowFrom`, or the bots own E.164 when
- Heartbeats are intentionally skipped for groups to avoid noisy broadcasts. - Heartbeats are intentionally skipped for groups to avoid noisy broadcasts.
- Echo suppression uses the combined batch string; if you send identical text twice without mentions, only the first will get a response. - Echo suppression uses the combined batch string; if you send identical text twice without mentions, only the first will get a response.
- Session store entries will appear as `agent:<agentId>:whatsapp:group:<jid>` in the session store (`~/.clawdbot/agents/<agentId>/sessions/sessions.json` by default); a missing entry just means the group hasnt triggered a run yet. - Session store entries will appear as `agent:<agentId>:whatsapp:group:<jid>` in the session store (`~/.clawdbot/agents/<agentId>/sessions/sessions.json` by default); a missing entry just means the group hasnt triggered a run yet.
- Typing indicators in groups follow `agent.typingMode` (default: `message` when unmentioned).

View File

@@ -39,10 +39,11 @@ Order of “how early it fires”:
} }
``` ```
You can override the refresh cadence per session: You can override mode or cadence per session:
```json5 ```json5
{ {
session: { session: {
typingMode: "message",
typingIntervalSeconds: 4 typingIntervalSeconds: 4
} }
} }
@@ -51,8 +52,8 @@ You can override the refresh cadence per session:
## Notes ## Notes
- `message` mode wont show typing for silent-only replies (e.g. the `NO_REPLY` - `message` mode wont show typing for silent-only replies (e.g. the `NO_REPLY`
token used to suppress output). token used to suppress output).
- `thinking` only fires if the run streams reasoning; if the model doesnt emit - `thinking` only fires if the run streams reasoning (`reasoningLevel: "stream"`).
reasoning deltas, typing wont start. If the model doesnt emit reasoning deltas, typing wont start.
- Heartbeats never show typing, regardless of mode. - Heartbeats never show typing, regardless of mode.
- `typingIntervalSeconds` controls the **refresh cadence**, not the start time. - `typingIntervalSeconds` controls the **refresh cadence**, not the start time.
The default is 6 seconds. The default is 6 seconds.

View File

@@ -996,6 +996,7 @@ See [/concepts/streaming](/concepts/streaming) for behavior + chunking details.
Typing indicators: Typing indicators:
- `agent.typingMode`: `"never" | "instant" | "thinking" | "message"`. Defaults to - `agent.typingMode`: `"never" | "instant" | "thinking" | "message"`. Defaults to
`instant` for direct chats / mentions and `message` for unmentioned group chats. `instant` for direct chats / mentions and `message` for unmentioned group chats.
- `session.typingMode`: per-session override for the mode.
- `agent.typingIntervalSeconds`: how often the typing signal is refreshed (default: 6s). - `agent.typingIntervalSeconds`: how often the typing signal is refreshed (default: 6s).
- `session.typingIntervalSeconds`: per-session override for the refresh interval. - `session.typingIntervalSeconds`: per-session override for the refresh interval.
See [/concepts/typing-indicators](/concepts/typing-indicators) for behavior details. See [/concepts/typing-indicators](/concepts/typing-indicators) for behavior details.

View File

@@ -66,8 +66,8 @@ import {
} from "./reply/session-updates.js"; } from "./reply/session-updates.js";
import { createTypingController } from "./reply/typing.js"; import { createTypingController } from "./reply/typing.js";
import { import {
createTypingSignaler,
resolveTypingMode, resolveTypingMode,
shouldStartTypingImmediately,
} from "./reply/typing-mode.js"; } from "./reply/typing-mode.js";
import type { MsgContext, TemplateContext } from "./templating.js"; import type { MsgContext, TemplateContext } from "./templating.js";
import { import {
@@ -599,12 +599,16 @@ export async function getReplyFromConfig(
const wasMentioned = ctx.WasMentioned === true; const wasMentioned = ctx.WasMentioned === true;
const isHeartbeat = opts?.isHeartbeat === true; const isHeartbeat = opts?.isHeartbeat === true;
const typingMode = resolveTypingMode({ const typingMode = resolveTypingMode({
configured: agentCfg?.typingMode, configured: sessionCfg?.typingMode ?? agentCfg?.typingMode,
isGroupChat, isGroupChat,
wasMentioned, wasMentioned,
isHeartbeat, isHeartbeat,
}); });
const shouldEagerType = shouldStartTypingImmediately(typingMode); const typingSignals = createTypingSignaler({
typing,
mode: typingMode,
isHeartbeat,
});
const shouldInjectGroupIntro = Boolean( const shouldInjectGroupIntro = Boolean(
isGroupChat && isGroupChat &&
(isFirstTurnInSession || sessionEntry?.groupActivationNeedsSystemIntro), (isFirstTurnInSession || sessionEntry?.groupActivationNeedsSystemIntro),
@@ -798,8 +802,8 @@ export async function getReplyFromConfig(
}, },
}; };
if (shouldEagerType) { if (typingSignals.shouldStartImmediately) {
await typing.startTypingLoop(); await typingSignals.signalRunStart();
} }
return runReplyAgent({ return runReplyAgent({

View File

@@ -9,7 +9,7 @@ import type { TypingMode } from "../../config/types.js";
import type { TemplateContext } from "../templating.js"; import type { TemplateContext } from "../templating.js";
import type { GetReplyOptions } from "../types.js"; import type { GetReplyOptions } from "../types.js";
import type { FollowupRun, QueueSettings } from "./queue.js"; import type { FollowupRun, QueueSettings } from "./queue.js";
import type { TypingController } from "./typing.js"; import { createMockTypingController } from "./test-helpers.js";
const runEmbeddedPiAgentMock = vi.fn(); const runEmbeddedPiAgentMock = vi.fn();
@@ -46,18 +46,6 @@ vi.mock("./queue.js", async () => {
import { runReplyAgent } from "./agent-runner.js"; import { runReplyAgent } from "./agent-runner.js";
function createTyping(): TypingController {
return {
onReplyStart: vi.fn(async () => {}),
startTypingLoop: vi.fn(async () => {}),
startTypingOnText: vi.fn(async () => {}),
refreshTypingTtl: vi.fn(),
markRunComplete: vi.fn(),
markDispatchIdle: vi.fn(),
cleanup: vi.fn(),
};
}
type EmbeddedPiAgentParams = { type EmbeddedPiAgentParams = {
onPartialReply?: (payload: { text?: string }) => Promise<void> | void; onPartialReply?: (payload: { text?: string }) => Promise<void> | void;
}; };
@@ -71,7 +59,7 @@ function createMinimalRun(params?: {
storePath?: string; storePath?: string;
typingMode?: TypingMode; typingMode?: TypingMode;
}) { }) {
const typing = createTyping(); const typing = createMockTypingController();
const opts = params?.opts; const opts = params?.opts;
const sessionCtx = { const sessionCtx = {
Provider: "whatsapp", Provider: "whatsapp",

View File

@@ -33,6 +33,7 @@ import {
import { extractReplyToTag } from "./reply-tags.js"; import { extractReplyToTag } from "./reply-tags.js";
import { incrementCompactionCount } from "./session-updates.js"; import { incrementCompactionCount } from "./session-updates.js";
import type { TypingController } from "./typing.js"; import type { TypingController } from "./typing.js";
import { createTypingSignaler } from "./typing-mode.js";
const BUN_FETCH_SOCKET_ERROR_RE = /socket connection was closed unexpectedly/i; const BUN_FETCH_SOCKET_ERROR_RE = /socket connection was closed unexpectedly/i;
@@ -107,26 +108,11 @@ export async function runReplyAgent(params: {
} = params; } = params;
const isHeartbeat = opts?.isHeartbeat === true; const isHeartbeat = opts?.isHeartbeat === true;
const shouldStartTypingOnText = const typingSignals = createTypingSignaler({
typingMode === "message" || typingMode === "instant"; typing,
const shouldStartTypingOnReasoning = typingMode === "thinking"; mode: typingMode,
isHeartbeat,
const signalTypingFromText = async (text?: string) => { });
if (isHeartbeat || typingMode === "never") return;
if (shouldStartTypingOnText) {
await typing.startTypingOnText(text);
return;
}
if (shouldStartTypingOnReasoning) {
typing.refreshTypingTtl();
}
};
const signalTypingFromReasoning = async () => {
if (isHeartbeat || !shouldStartTypingOnReasoning) return;
await typing.startTypingLoop();
typing.refreshTypingTtl();
};
const shouldEmitToolResult = () => { const shouldEmitToolResult = () => {
if (!sessionKey || !storePath) { if (!sessionKey || !storePath) {
@@ -276,7 +262,7 @@ export async function runReplyAgent(params: {
} }
text = stripped.text; text = stripped.text;
} }
await signalTypingFromText(text); await typingSignals.signalTextDelta(text);
await opts.onPartialReply?.({ await opts.onPartialReply?.({
text, text,
mediaUrls: payload.mediaUrls, mediaUrls: payload.mediaUrls,
@@ -284,9 +270,9 @@ export async function runReplyAgent(params: {
} }
: undefined, : undefined,
onReasoningStream: onReasoningStream:
shouldStartTypingOnReasoning || opts?.onReasoningStream typingSignals.shouldStartOnReasoning || opts?.onReasoningStream
? async (payload) => { ? async (payload) => {
await signalTypingFromReasoning(); await typingSignals.signalReasoningDelta();
await opts?.onReasoningStream?.({ await opts?.onReasoningStream?.({
text: payload.text, text: payload.text,
mediaUrls: payload.mediaUrls, mediaUrls: payload.mediaUrls,
@@ -344,7 +330,7 @@ export async function runReplyAgent(params: {
} }
pendingStreamedPayloadKeys.add(payloadKey); pendingStreamedPayloadKeys.add(payloadKey);
const task = (async () => { const task = (async () => {
await signalTypingFromText(cleaned); await typingSignals.signalTextDelta(cleaned);
await opts.onBlockReply?.(blockPayload); await opts.onBlockReply?.(blockPayload);
})() })()
.then(() => { .then(() => {
@@ -389,7 +375,7 @@ export async function runReplyAgent(params: {
} }
text = stripped.text; text = stripped.text;
} }
await signalTypingFromText(text); await typingSignals.signalTextDelta(text);
await opts.onToolResult?.({ await opts.onToolResult?.({
text, text,
mediaUrls: payload.mediaUrls, mediaUrls: payload.mediaUrls,
@@ -544,8 +530,8 @@ export async function runReplyAgent(params: {
if (payload.mediaUrls && payload.mediaUrls.length > 0) return true; if (payload.mediaUrls && payload.mediaUrls.length > 0) return true;
return false; return false;
}); });
if (shouldSignalTyping && typingMode === "instant" && !isHeartbeat) { if (shouldSignalTyping) {
await typing.startTypingLoop(); await typingSignals.signalRunStart();
} }
if (sessionStore && sessionKey) { if (sessionStore && sessionKey) {

View File

@@ -5,7 +5,7 @@ import { describe, expect, it, vi } from "vitest";
import type { SessionEntry } from "../../config/sessions.js"; import type { SessionEntry } from "../../config/sessions.js";
import type { FollowupRun } from "./queue.js"; import type { FollowupRun } from "./queue.js";
import type { TypingController } from "./typing.js"; import { createMockTypingController } from "./test-helpers.js";
const runEmbeddedPiAgentMock = vi.fn(); const runEmbeddedPiAgentMock = vi.fn();
@@ -31,18 +31,6 @@ vi.mock("../../agents/pi-embedded.js", () => ({
import { createFollowupRunner } from "./followup-runner.js"; import { createFollowupRunner } from "./followup-runner.js";
function createTyping(): TypingController {
return {
onReplyStart: vi.fn(async () => {}),
startTypingLoop: vi.fn(async () => {}),
startTypingOnText: vi.fn(async () => {}),
refreshTypingTtl: vi.fn(),
markRunComplete: vi.fn(),
markDispatchIdle: vi.fn(),
cleanup: vi.fn(),
};
}
describe("createFollowupRunner compaction", () => { describe("createFollowupRunner compaction", () => {
it("adds verbose auto-compaction notice and tracks count", async () => { it("adds verbose auto-compaction notice and tracks count", async () => {
const storePath = path.join( const storePath = path.join(
@@ -75,7 +63,7 @@ describe("createFollowupRunner compaction", () => {
const runner = createFollowupRunner({ const runner = createFollowupRunner({
opts: { onBlockReply }, opts: { onBlockReply },
typing: createTyping(), typing: createMockTypingController(),
typingMode: "instant", typingMode: "instant",
sessionEntry, sessionEntry,
sessionStore, sessionStore,

View File

@@ -17,6 +17,7 @@ import { extractReplyToTag } from "./reply-tags.js";
import { isRoutableChannel, routeReply } from "./route-reply.js"; import { isRoutableChannel, routeReply } from "./route-reply.js";
import { incrementCompactionCount } from "./session-updates.js"; import { incrementCompactionCount } from "./session-updates.js";
import type { TypingController } from "./typing.js"; import type { TypingController } from "./typing.js";
import { createTypingSignaler } from "./typing-mode.js";
export function createFollowupRunner(params: { export function createFollowupRunner(params: {
opts?: GetReplyOptions; opts?: GetReplyOptions;
@@ -40,6 +41,11 @@ export function createFollowupRunner(params: {
defaultModel, defaultModel,
agentCfgContextTokens, agentCfgContextTokens,
} = params; } = params;
const typingSignals = createTypingSignaler({
typing,
mode: typingMode,
isHeartbeat: opts?.isHeartbeat === true,
});
/** /**
* Sends followup payloads, routing to the originating channel if set. * Sends followup payloads, routing to the originating channel if set.
@@ -74,13 +80,7 @@ export function createFollowupRunner(params: {
) { ) {
continue; continue;
} }
if (typingMode !== "never") { await typingSignals.signalTextDelta(payload.text);
if (typingMode === "message" || typingMode === "instant") {
await typing.startTypingOnText(payload.text);
} else if (typingMode === "thinking") {
typing.refreshTypingTtl();
}
}
// Route to originating channel if set, otherwise fall back to dispatcher. // Route to originating channel if set, otherwise fall back to dispatcher.
if (shouldRouteToOriginating) { if (shouldRouteToOriginating) {
@@ -108,6 +108,7 @@ export function createFollowupRunner(params: {
}; };
return async (queued: FollowupRun) => { return async (queued: FollowupRun) => {
await typingSignals.signalRunStart();
try { try {
const runId = crypto.randomUUID(); const runId = crypto.randomUUID();
if (queued.run.sessionKey) { if (queued.run.sessionKey) {

View File

@@ -0,0 +1,15 @@
import { vi } from "vitest";
import type { TypingController } from "./typing.js";
export function createMockTypingController(): TypingController {
return {
onReplyStart: vi.fn(async () => {}),
startTypingLoop: vi.fn(async () => {}),
startTypingOnText: vi.fn(async () => {}),
refreshTypingTtl: vi.fn(),
markRunComplete: vi.fn(),
markDispatchIdle: vi.fn(),
cleanup: vi.fn(),
};
}

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { resolveTypingMode } from "./typing-mode.js"; import { createMockTypingController } from "./test-helpers.js";
import { createTypingSignaler, resolveTypingMode } from "./typing-mode.js";
describe("resolveTypingMode", () => { describe("resolveTypingMode", () => {
it("defaults to instant for direct chats", () => { it("defaults to instant for direct chats", () => {
@@ -66,3 +67,75 @@ describe("resolveTypingMode", () => {
).toBe("never"); ).toBe("never");
}); });
}); });
describe("createTypingSignaler", () => {
it("signals immediately for instant mode", async () => {
const typing = createMockTypingController();
const signaler = createTypingSignaler({
typing,
mode: "instant",
isHeartbeat: false,
});
await signaler.signalRunStart();
expect(typing.startTypingLoop).toHaveBeenCalled();
});
it("signals on text for message mode", async () => {
const typing = createMockTypingController();
const signaler = createTypingSignaler({
typing,
mode: "message",
isHeartbeat: false,
});
await signaler.signalTextDelta("hello");
expect(typing.startTypingOnText).toHaveBeenCalledWith("hello");
expect(typing.startTypingLoop).not.toHaveBeenCalled();
});
it("signals on reasoning for thinking mode", async () => {
const typing = createMockTypingController();
const signaler = createTypingSignaler({
typing,
mode: "thinking",
isHeartbeat: false,
});
await signaler.signalReasoningDelta();
expect(typing.startTypingLoop).toHaveBeenCalled();
});
it("refreshes ttl on text for thinking mode", async () => {
const typing = createMockTypingController();
const signaler = createTypingSignaler({
typing,
mode: "thinking",
isHeartbeat: false,
});
await signaler.signalTextDelta("hi");
expect(typing.refreshTypingTtl).toHaveBeenCalled();
expect(typing.startTypingOnText).not.toHaveBeenCalled();
});
it("suppresses typing when disabled", async () => {
const typing = createMockTypingController();
const signaler = createTypingSignaler({
typing,
mode: "instant",
isHeartbeat: true,
});
await signaler.signalRunStart();
await signaler.signalTextDelta("hi");
await signaler.signalReasoningDelta();
expect(typing.startTypingLoop).not.toHaveBeenCalled();
expect(typing.startTypingOnText).not.toHaveBeenCalled();
});
});

View File

@@ -1,4 +1,5 @@
import type { TypingMode } from "../../config/types.js"; import type { TypingMode } from "../../config/types.js";
import type { TypingController } from "./typing.js";
export type TypingModeContext = { export type TypingModeContext = {
configured?: TypingMode; configured?: TypingMode;
@@ -21,5 +22,56 @@ export function resolveTypingMode({
return DEFAULT_GROUP_TYPING_MODE; return DEFAULT_GROUP_TYPING_MODE;
} }
export const shouldStartTypingImmediately = (mode: TypingMode) => export type TypingSignaler = {
mode === "instant"; mode: TypingMode;
shouldStartImmediately: boolean;
shouldStartOnText: boolean;
shouldStartOnReasoning: boolean;
signalRunStart: () => Promise<void>;
signalTextDelta: (text?: string) => Promise<void>;
signalReasoningDelta: () => Promise<void>;
};
export function createTypingSignaler(params: {
typing: TypingController;
mode: TypingMode;
isHeartbeat: boolean;
}): TypingSignaler {
const { typing, mode, isHeartbeat } = params;
const shouldStartImmediately = mode === "instant";
const shouldStartOnText = mode === "message" || mode === "instant";
const shouldStartOnReasoning = mode === "thinking";
const disabled = isHeartbeat || mode === "never";
const signalRunStart = async () => {
if (disabled || !shouldStartImmediately) return;
await typing.startTypingLoop();
};
const signalTextDelta = async (text?: string) => {
if (disabled) return;
if (shouldStartOnText) {
await typing.startTypingOnText(text);
return;
}
if (shouldStartOnReasoning) {
typing.refreshTypingTtl();
}
};
const signalReasoningDelta = async () => {
if (disabled || !shouldStartOnReasoning) return;
await typing.startTypingLoop();
typing.refreshTypingTtl();
};
return {
mode,
shouldStartImmediately,
shouldStartOnText,
shouldStartOnReasoning,
signalRunStart,
signalTextDelta,
signalReasoningDelta,
};
}

View File

@@ -38,6 +38,7 @@ export type SessionConfig = {
heartbeatIdleMinutes?: number; heartbeatIdleMinutes?: number;
store?: string; store?: string;
typingIntervalSeconds?: number; typingIntervalSeconds?: number;
typingMode?: TypingMode;
mainKey?: string; mainKey?: string;
sendPolicy?: SessionSendPolicyConfig; sendPolicy?: SessionSendPolicyConfig;
agentToAgent?: { agentToAgent?: {

View File

@@ -129,6 +129,14 @@ const SessionSchema = z
heartbeatIdleMinutes: z.number().int().positive().optional(), heartbeatIdleMinutes: z.number().int().positive().optional(),
store: z.string().optional(), store: z.string().optional(),
typingIntervalSeconds: z.number().int().positive().optional(), typingIntervalSeconds: z.number().int().positive().optional(),
typingMode: z
.union([
z.literal("never"),
z.literal("instant"),
z.literal("thinking"),
z.literal("message"),
])
.optional(),
mainKey: z.string().optional(), mainKey: z.string().optional(),
sendPolicy: z sendPolicy: z
.object({ .object({