feat: add typing mode controls
This commit is contained in:
58
docs/concepts/typing-indicators.md
Normal file
58
docs/concepts/typing-indicators.md
Normal file
@@ -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.
|
||||
@@ -554,6 +554,7 @@
|
||||
"concepts/provider-routing",
|
||||
"concepts/groups",
|
||||
"concepts/group-messages",
|
||||
"concepts/typing-indicators",
|
||||
"concepts/queue",
|
||||
"concepts/models",
|
||||
"concepts/model-failover",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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> | void;
|
||||
onReasoningStream?: (payload: {
|
||||
text?: string;
|
||||
}) => Promise<void> | 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-")),
|
||||
|
||||
@@ -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<ReplyPayload | ReplyPayload[] | undefined> {
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ describe("createFollowupRunner compaction", () => {
|
||||
const runner = createFollowupRunner({
|
||||
opts: { onBlockReply },
|
||||
typing: createTyping(),
|
||||
typingMode: "instant",
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "main",
|
||||
|
||||
@@ -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<string, SessionEntry>;
|
||||
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) {
|
||||
|
||||
68
src/auto-reply/reply/typing-mode.test.ts
Normal file
68
src/auto-reply/reply/typing-mode.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
25
src/auto-reply/reply/typing-mode.ts
Normal file
25
src/auto-reply/reply/typing-mode.ts
Normal file
@@ -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";
|
||||
@@ -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 () => {});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user