From bac160893317ab43b81d43ea227663f9386802c2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 7 Jan 2026 21:58:54 +0000 Subject: [PATCH] feat: add typing mode controls --- docs/concepts/typing-indicators.md | 58 ++++++++++++++++ docs/docs.json | 1 + docs/gateway/configuration.md | 7 ++ src/auto-reply/reply.ts | 13 +++- .../agent-runner.heartbeat-typing.test.ts | 60 ++++++++++++++++ src/auto-reply/reply/agent-runner.ts | 56 ++++++++++----- .../reply/followup-runner.compaction.test.ts | 1 + src/auto-reply/reply/followup-runner.ts | 11 ++- src/auto-reply/reply/typing-mode.test.ts | 68 +++++++++++++++++++ src/auto-reply/reply/typing-mode.ts | 25 +++++++ src/auto-reply/reply/typing.test.ts | 15 ++++ src/auto-reply/reply/typing.ts | 1 + src/config/types.ts | 3 + src/config/zod-schema.ts | 8 +++ 14 files changed, 307 insertions(+), 20 deletions(-) create mode 100644 docs/concepts/typing-indicators.md create mode 100644 src/auto-reply/reply/typing-mode.test.ts create mode 100644 src/auto-reply/reply/typing-mode.ts diff --git a/docs/concepts/typing-indicators.md b/docs/concepts/typing-indicators.md new file mode 100644 index 000000000..a389ba240 --- /dev/null +++ b/docs/concepts/typing-indicators.md @@ -0,0 +1,58 @@ +--- +summary: "When Clawdbot shows typing indicators and how to tune them" +read_when: + - Changing typing indicator behavior or defaults +--- +# Typing indicators + +Typing indicators are sent to the chat provider while a run is active. Use +`agent.typingMode` to control **when** typing starts and `typingIntervalSeconds` +to control **how often** it refreshes. + +## Defaults +When `agent.typingMode` is **unset**, Clawdbot keeps the legacy behavior: +- **Direct chats**: typing starts immediately once the model loop begins. +- **Group chats with a mention**: typing starts immediately. +- **Group chats without a mention**: typing starts only when message text begins streaming. +- **Heartbeat runs**: typing is disabled. + +## Modes +Set `agent.typingMode` to one of: +- `never` — no typing indicator, ever. +- `instant` — start typing **as soon as the model loop begins**, even if the run + later returns only the silent reply token. +- `thinking` — start typing on the **first reasoning delta** (requires + `reasoningLevel: "stream"` for the run). +- `message` — start typing on the **first non-silent text delta** (ignores + the `NO_REPLY` silent token). + +Order of “how early it fires”: +`never` → `message` → `thinking` → `instant` + +## Configuration +```json5 +{ + agent: { + typingMode: "thinking", + typingIntervalSeconds: 6 + } +} +``` + +You can override the refresh cadence per session: +```json5 +{ + session: { + typingIntervalSeconds: 4 + } +} +``` + +## Notes +- `message` mode won’t show typing for silent-only replies (e.g. the `NO_REPLY` + token used to suppress output). +- `thinking` only fires if the run streams reasoning; if the model doesn’t emit + reasoning deltas, typing won’t start. +- Heartbeats never show typing, regardless of mode. +- `typingIntervalSeconds` controls the **refresh cadence**, not the start time. + The default is 6 seconds. diff --git a/docs/docs.json b/docs/docs.json index b6ce5c4cd..ca8a54b7d 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -554,6 +554,7 @@ "concepts/provider-routing", "concepts/groups", "concepts/group-messages", + "concepts/typing-indicators", "concepts/queue", "concepts/models", "concepts/model-failover", diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 57c3cc955..b0a36b1e9 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -993,6 +993,13 @@ Block streaming: ``` See [/concepts/streaming](/concepts/streaming) for behavior + chunking details. +Typing indicators: +- `agent.typingMode`: `"never" | "instant" | "thinking" | "message"`. Defaults to + `instant` for direct chats / mentions and `message` for unmentioned group chats. +- `agent.typingIntervalSeconds`: how often the typing signal is refreshed (default: 6s). +- `session.typingIntervalSeconds`: per-session override for the refresh interval. +See [/concepts/typing-indicators](/concepts/typing-indicators) for behavior details. + `agent.model.primary` should be set as `provider/model` (e.g. `anthropic/claude-opus-4-5`). Aliases come from `agent.models.*.alias` (e.g. `Opus`). If you omit the provider, CLAWDBOT currently assumes `anthropic` as a temporary diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 1ac4fabb7..2b95c798d 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -65,6 +65,10 @@ import { prependSystemEvents, } from "./reply/session-updates.js"; import { createTypingController } from "./reply/typing.js"; +import { + resolveTypingMode, + shouldStartTypingImmediately, +} from "./reply/typing-mode.js"; import type { MsgContext, TemplateContext } from "./templating.js"; import { type ElevatedLevel, @@ -594,7 +598,13 @@ export async function getReplyFromConfig( const isGroupChat = sessionCtx.ChatType === "group"; const wasMentioned = ctx.WasMentioned === true; const isHeartbeat = opts?.isHeartbeat === true; - const shouldEagerType = (!isGroupChat || wasMentioned) && !isHeartbeat; + const typingMode = resolveTypingMode({ + configured: agentCfg?.typingMode, + isGroupChat, + wasMentioned, + isHeartbeat, + }); + const shouldEagerType = shouldStartTypingImmediately(typingMode); const shouldInjectGroupIntro = Boolean( isGroupChat && (isFirstTurnInSession || sessionEntry?.groupActivationNeedsSystemIntro), @@ -816,6 +826,7 @@ export async function getReplyFromConfig( resolvedBlockStreamingBreak, sessionCtx, shouldInjectGroupIntro, + typingMode, }); } diff --git a/src/auto-reply/reply/agent-runner.heartbeat-typing.test.ts b/src/auto-reply/reply/agent-runner.heartbeat-typing.test.ts index 5ae3b6ec1..16473ce8f 100644 --- a/src/auto-reply/reply/agent-runner.heartbeat-typing.test.ts +++ b/src/auto-reply/reply/agent-runner.heartbeat-typing.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest"; import type { SessionEntry } from "../../config/sessions.js"; import * as sessions from "../../config/sessions.js"; +import type { TypingMode } from "../../config/types.js"; import type { TemplateContext } from "../templating.js"; import type { GetReplyOptions } from "../types.js"; import type { FollowupRun, QueueSettings } from "./queue.js"; @@ -68,6 +69,7 @@ function createMinimalRun(params?: { sessionEntry?: SessionEntry; sessionKey?: string; storePath?: string; + typingMode?: TypingMode; }) { const typing = createTyping(); const opts = params?.opts; @@ -130,6 +132,7 @@ function createMinimalRun(params?: { blockStreamingEnabled: false, resolvedBlockStreamingBreak: "message_end", shouldInjectGroupIntro: false, + typingMode: params?.typingMode ?? "instant", }), }; } @@ -173,6 +176,63 @@ describe("runReplyAgent typing (heartbeat)", () => { expect(typing.startTypingLoop).not.toHaveBeenCalled(); }); + it("starts typing only on deltas in message mode", async () => { + runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ + payloads: [{ text: "final" }], + meta: {}, + })); + + const { run, typing } = createMinimalRun({ + typingMode: "message", + }); + await run(); + + expect(typing.startTypingOnText).not.toHaveBeenCalled(); + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + }); + + it("starts typing from reasoning stream in thinking mode", async () => { + runEmbeddedPiAgentMock.mockImplementationOnce( + async (params: { + onPartialReply?: (payload: { text?: string }) => Promise | void; + onReasoningStream?: (payload: { + text?: string; + }) => Promise | void; + }) => { + await params.onReasoningStream?.({ text: "Reasoning:\nstep" }); + await params.onPartialReply?.({ text: "hi" }); + return { payloads: [{ text: "final" }], meta: {} }; + }, + ); + + const { run, typing } = createMinimalRun({ + typingMode: "thinking", + }); + await run(); + + expect(typing.startTypingLoop).toHaveBeenCalled(); + expect(typing.startTypingOnText).not.toHaveBeenCalled(); + }); + + it("suppresses typing in never mode", async () => { + runEmbeddedPiAgentMock.mockImplementationOnce( + async (params: { + onPartialReply?: (payload: { text?: string }) => void; + }) => { + params.onPartialReply?.({ text: "hi" }); + return { payloads: [{ text: "final" }], meta: {} }; + }, + ); + + const { run, typing } = createMinimalRun({ + typingMode: "never", + }); + await run(); + + expect(typing.startTypingOnText).not.toHaveBeenCalled(); + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + }); + it("announces auto-compaction in verbose mode and tracks count", async () => { const storePath = path.join( await fs.mkdtemp(path.join(tmpdir(), "clawdbot-compaction-")), diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index cf64530da..478be71a0 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -14,6 +14,7 @@ import { type SessionEntry, saveSessionStore, } from "../../config/sessions.js"; +import type { TypingMode } from "../../config/types.js"; import { logVerbose } from "../../globals.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; import { defaultRuntime } from "../../runtime.js"; @@ -76,6 +77,7 @@ export async function runReplyAgent(params: { resolvedBlockStreamingBreak: "text_end" | "message_end"; sessionCtx: TemplateContext; shouldInjectGroupIntro: boolean; + typingMode: TypingMode; }): Promise { const { commandBody, @@ -101,9 +103,30 @@ export async function runReplyAgent(params: { resolvedBlockStreamingBreak, sessionCtx, shouldInjectGroupIntro, + typingMode, } = params; const isHeartbeat = opts?.isHeartbeat === true; + const shouldStartTypingOnText = + typingMode === "message" || typingMode === "instant"; + const shouldStartTypingOnReasoning = typingMode === "thinking"; + + 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 = () => { if (!sessionKey || !storePath) { @@ -173,6 +196,7 @@ export async function runReplyAgent(params: { const runFollowupTurn = createFollowupRunner({ opts, typing, + typingMode, sessionEntry, sessionStore, sessionKey, @@ -252,23 +276,23 @@ export async function runReplyAgent(params: { } text = stripped.text; } - if (!isHeartbeat) { - await typing.startTypingOnText(text); - } + await signalTypingFromText(text); await opts.onPartialReply?.({ text, mediaUrls: payload.mediaUrls, }); } : undefined, - onReasoningStream: opts?.onReasoningStream - ? async (payload) => { - await opts.onReasoningStream?.({ - text: payload.text, - mediaUrls: payload.mediaUrls, - }); - } - : undefined, + onReasoningStream: + shouldStartTypingOnReasoning || opts?.onReasoningStream + ? async (payload) => { + await signalTypingFromReasoning(); + await opts?.onReasoningStream?.({ + text: payload.text, + mediaUrls: payload.mediaUrls, + }); + } + : undefined, onAgentEvent: (evt) => { if (evt.stream !== "compaction") return; const phase = @@ -320,9 +344,7 @@ export async function runReplyAgent(params: { } pendingStreamedPayloadKeys.add(payloadKey); const task = (async () => { - if (!isHeartbeat) { - await typing.startTypingOnText(cleaned); - } + await signalTypingFromText(cleaned); await opts.onBlockReply?.(blockPayload); })() .then(() => { @@ -367,9 +389,7 @@ export async function runReplyAgent(params: { } text = stripped.text; } - if (!isHeartbeat) { - await typing.startTypingOnText(text); - } + await signalTypingFromText(text); await opts.onToolResult?.({ text, mediaUrls: payload.mediaUrls, @@ -524,7 +544,7 @@ export async function runReplyAgent(params: { if (payload.mediaUrls && payload.mediaUrls.length > 0) return true; return false; }); - if (shouldSignalTyping && !isHeartbeat) { + if (shouldSignalTyping && typingMode === "instant" && !isHeartbeat) { await typing.startTypingLoop(); } diff --git a/src/auto-reply/reply/followup-runner.compaction.test.ts b/src/auto-reply/reply/followup-runner.compaction.test.ts index 6c319a310..17e3ccd7a 100644 --- a/src/auto-reply/reply/followup-runner.compaction.test.ts +++ b/src/auto-reply/reply/followup-runner.compaction.test.ts @@ -76,6 +76,7 @@ describe("createFollowupRunner compaction", () => { const runner = createFollowupRunner({ opts: { onBlockReply }, typing: createTyping(), + typingMode: "instant", sessionEntry, sessionStore, sessionKey: "main", diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 026fc0e80..e628a806c 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -5,6 +5,7 @@ import { runWithModelFallback } from "../../agents/model-fallback.js"; import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js"; import { hasNonzeroUsage } from "../../agents/usage.js"; import { type SessionEntry, saveSessionStore } from "../../config/sessions.js"; +import type { TypingMode } from "../../config/types.js"; import { logVerbose } from "../../globals.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; import { defaultRuntime } from "../../runtime.js"; @@ -20,6 +21,7 @@ import type { TypingController } from "./typing.js"; export function createFollowupRunner(params: { opts?: GetReplyOptions; typing: TypingController; + typingMode: TypingMode; sessionEntry?: SessionEntry; sessionStore?: Record; sessionKey?: string; @@ -30,6 +32,7 @@ export function createFollowupRunner(params: { const { opts, typing, + typingMode, sessionEntry, sessionStore, sessionKey, @@ -71,7 +74,13 @@ export function createFollowupRunner(params: { ) { continue; } - await typing.startTypingOnText(payload.text); + if (typingMode !== "never") { + 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. if (shouldRouteToOriginating) { diff --git a/src/auto-reply/reply/typing-mode.test.ts b/src/auto-reply/reply/typing-mode.test.ts new file mode 100644 index 000000000..278f84cc9 --- /dev/null +++ b/src/auto-reply/reply/typing-mode.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import { resolveTypingMode } from "./typing-mode.js"; + +describe("resolveTypingMode", () => { + it("defaults to instant for direct chats", () => { + expect( + resolveTypingMode({ + configured: undefined, + isGroupChat: false, + wasMentioned: false, + isHeartbeat: false, + }), + ).toBe("instant"); + }); + + it("defaults to message for group chats without mentions", () => { + expect( + resolveTypingMode({ + configured: undefined, + isGroupChat: true, + wasMentioned: false, + isHeartbeat: false, + }), + ).toBe("message"); + }); + + it("defaults to instant for mentioned group chats", () => { + expect( + resolveTypingMode({ + configured: undefined, + isGroupChat: true, + wasMentioned: true, + isHeartbeat: false, + }), + ).toBe("instant"); + }); + + it("honors configured mode across contexts", () => { + expect( + resolveTypingMode({ + configured: "thinking", + isGroupChat: false, + wasMentioned: false, + isHeartbeat: false, + }), + ).toBe("thinking"); + expect( + resolveTypingMode({ + configured: "message", + isGroupChat: true, + wasMentioned: true, + isHeartbeat: false, + }), + ).toBe("message"); + }); + + it("forces never for heartbeat runs", () => { + expect( + resolveTypingMode({ + configured: "instant", + isGroupChat: false, + wasMentioned: false, + isHeartbeat: true, + }), + ).toBe("never"); + }); +}); diff --git a/src/auto-reply/reply/typing-mode.ts b/src/auto-reply/reply/typing-mode.ts new file mode 100644 index 000000000..d9e150b51 --- /dev/null +++ b/src/auto-reply/reply/typing-mode.ts @@ -0,0 +1,25 @@ +import type { TypingMode } from "../../config/types.js"; + +export type TypingModeContext = { + configured?: TypingMode; + isGroupChat: boolean; + wasMentioned: boolean; + isHeartbeat: boolean; +}; + +export const DEFAULT_GROUP_TYPING_MODE: TypingMode = "message"; + +export function resolveTypingMode({ + configured, + isGroupChat, + wasMentioned, + isHeartbeat, +}: TypingModeContext): TypingMode { + if (isHeartbeat) return "never"; + if (configured) return configured; + if (!isGroupChat || wasMentioned) return "instant"; + return DEFAULT_GROUP_TYPING_MODE; +} + +export const shouldStartTypingImmediately = (mode: TypingMode) => + mode === "instant"; diff --git a/src/auto-reply/reply/typing.test.ts b/src/auto-reply/reply/typing.test.ts index 18c3fd322..da7033162 100644 --- a/src/auto-reply/reply/typing.test.ts +++ b/src/auto-reply/reply/typing.test.ts @@ -52,6 +52,21 @@ describe("typing controller", () => { expect(onReplyStart).toHaveBeenCalledTimes(3); }); + it("does not start typing after run completion", async () => { + vi.useFakeTimers(); + const onReplyStart = vi.fn(async () => {}); + const typing = createTypingController({ + onReplyStart, + typingIntervalSeconds: 1, + typingTtlMs: 30_000, + }); + + typing.markRunComplete(); + await typing.startTypingOnText("late text"); + vi.advanceTimersByTime(2_000); + expect(onReplyStart).not.toHaveBeenCalled(); + }); + it("does not restart typing after it has stopped", async () => { vi.useFakeTimers(); const onReplyStart = vi.fn(async () => {}); diff --git a/src/auto-reply/reply/typing.ts b/src/auto-reply/reply/typing.ts index 7850ec132..09cc4e51b 100644 --- a/src/auto-reply/reply/typing.ts +++ b/src/auto-reply/reply/typing.ts @@ -101,6 +101,7 @@ export function createTypingController(params: { const startTypingLoop = async () => { if (sealed) return; + if (runComplete) return; if (!onReplyStart) return; if (typingIntervalMs <= 0) return; if (typingTimer) return; diff --git a/src/config/types.ts b/src/config/types.ts index fcb499022..5883cf9c7 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1,4 +1,5 @@ export type ReplyMode = "text" | "command"; +export type TypingMode = "never" | "instant" | "thinking" | "message"; export type SessionScope = "per-sender" | "global"; export type ReplyToMode = "off" | "first" | "all"; export type GroupPolicy = "open" | "disabled" | "allowlist"; @@ -964,6 +965,8 @@ export type ClawdbotConfig = { /** Max inbound media size in MB for agent-visible attachments (text note or future image attach). */ mediaMaxMb?: number; typingIntervalSeconds?: number; + /** Typing indicator start mode (never|instant|thinking|message). */ + typingMode?: TypingMode; /** Periodic background heartbeat runs. */ heartbeat?: { /** Heartbeat interval (duration string, default unit: minutes; default: 30m). */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index e6ca4099a..b38d9c23e 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -595,6 +595,14 @@ export const ClawdbotSchema = z.object({ timeoutSeconds: z.number().int().positive().optional(), mediaMaxMb: z.number().positive().optional(), typingIntervalSeconds: z.number().int().positive().optional(), + typingMode: z + .union([ + z.literal("never"), + z.literal("instant"), + z.literal("thinking"), + z.literal("message"), + ]) + .optional(), heartbeat: HeartbeatSchema, maxConcurrent: z.number().int().positive().optional(), subagents: z