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/provider-routing",
|
||||||
"concepts/groups",
|
"concepts/groups",
|
||||||
"concepts/group-messages",
|
"concepts/group-messages",
|
||||||
|
"concepts/typing-indicators",
|
||||||
"concepts/queue",
|
"concepts/queue",
|
||||||
"concepts/models",
|
"concepts/models",
|
||||||
"concepts/model-failover",
|
"concepts/model-failover",
|
||||||
|
|||||||
@@ -993,6 +993,13 @@ Block streaming:
|
|||||||
```
|
```
|
||||||
See [/concepts/streaming](/concepts/streaming) for behavior + chunking details.
|
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`).
|
`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`).
|
Aliases come from `agent.models.*.alias` (e.g. `Opus`).
|
||||||
If you omit the provider, CLAWDBOT currently assumes `anthropic` as a temporary
|
If you omit the provider, CLAWDBOT currently assumes `anthropic` as a temporary
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ import {
|
|||||||
prependSystemEvents,
|
prependSystemEvents,
|
||||||
} from "./reply/session-updates.js";
|
} from "./reply/session-updates.js";
|
||||||
import { createTypingController } from "./reply/typing.js";
|
import { createTypingController } from "./reply/typing.js";
|
||||||
|
import {
|
||||||
|
resolveTypingMode,
|
||||||
|
shouldStartTypingImmediately,
|
||||||
|
} from "./reply/typing-mode.js";
|
||||||
import type { MsgContext, TemplateContext } from "./templating.js";
|
import type { MsgContext, TemplateContext } from "./templating.js";
|
||||||
import {
|
import {
|
||||||
type ElevatedLevel,
|
type ElevatedLevel,
|
||||||
@@ -594,7 +598,13 @@ export async function getReplyFromConfig(
|
|||||||
const isGroupChat = sessionCtx.ChatType === "group";
|
const isGroupChat = sessionCtx.ChatType === "group";
|
||||||
const wasMentioned = ctx.WasMentioned === true;
|
const wasMentioned = ctx.WasMentioned === true;
|
||||||
const isHeartbeat = opts?.isHeartbeat === 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(
|
const shouldInjectGroupIntro = Boolean(
|
||||||
isGroupChat &&
|
isGroupChat &&
|
||||||
(isFirstTurnInSession || sessionEntry?.groupActivationNeedsSystemIntro),
|
(isFirstTurnInSession || sessionEntry?.groupActivationNeedsSystemIntro),
|
||||||
@@ -816,6 +826,7 @@ export async function getReplyFromConfig(
|
|||||||
resolvedBlockStreamingBreak,
|
resolvedBlockStreamingBreak,
|
||||||
sessionCtx,
|
sessionCtx,
|
||||||
shouldInjectGroupIntro,
|
shouldInjectGroupIntro,
|
||||||
|
typingMode,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import type { SessionEntry } from "../../config/sessions.js";
|
import type { SessionEntry } from "../../config/sessions.js";
|
||||||
import * as sessions 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 { 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";
|
||||||
@@ -68,6 +69,7 @@ function createMinimalRun(params?: {
|
|||||||
sessionEntry?: SessionEntry;
|
sessionEntry?: SessionEntry;
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
storePath?: string;
|
storePath?: string;
|
||||||
|
typingMode?: TypingMode;
|
||||||
}) {
|
}) {
|
||||||
const typing = createTyping();
|
const typing = createTyping();
|
||||||
const opts = params?.opts;
|
const opts = params?.opts;
|
||||||
@@ -130,6 +132,7 @@ function createMinimalRun(params?: {
|
|||||||
blockStreamingEnabled: false,
|
blockStreamingEnabled: false,
|
||||||
resolvedBlockStreamingBreak: "message_end",
|
resolvedBlockStreamingBreak: "message_end",
|
||||||
shouldInjectGroupIntro: false,
|
shouldInjectGroupIntro: false,
|
||||||
|
typingMode: params?.typingMode ?? "instant",
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -173,6 +176,63 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
|||||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
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 () => {
|
it("announces auto-compaction in verbose mode and tracks count", async () => {
|
||||||
const storePath = path.join(
|
const storePath = path.join(
|
||||||
await fs.mkdtemp(path.join(tmpdir(), "clawdbot-compaction-")),
|
await fs.mkdtemp(path.join(tmpdir(), "clawdbot-compaction-")),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
saveSessionStore,
|
saveSessionStore,
|
||||||
} from "../../config/sessions.js";
|
} from "../../config/sessions.js";
|
||||||
|
import type { TypingMode } from "../../config/types.js";
|
||||||
import { logVerbose } from "../../globals.js";
|
import { logVerbose } from "../../globals.js";
|
||||||
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
@@ -76,6 +77,7 @@ export async function runReplyAgent(params: {
|
|||||||
resolvedBlockStreamingBreak: "text_end" | "message_end";
|
resolvedBlockStreamingBreak: "text_end" | "message_end";
|
||||||
sessionCtx: TemplateContext;
|
sessionCtx: TemplateContext;
|
||||||
shouldInjectGroupIntro: boolean;
|
shouldInjectGroupIntro: boolean;
|
||||||
|
typingMode: TypingMode;
|
||||||
}): Promise<ReplyPayload | ReplyPayload[] | undefined> {
|
}): Promise<ReplyPayload | ReplyPayload[] | undefined> {
|
||||||
const {
|
const {
|
||||||
commandBody,
|
commandBody,
|
||||||
@@ -101,9 +103,30 @@ export async function runReplyAgent(params: {
|
|||||||
resolvedBlockStreamingBreak,
|
resolvedBlockStreamingBreak,
|
||||||
sessionCtx,
|
sessionCtx,
|
||||||
shouldInjectGroupIntro,
|
shouldInjectGroupIntro,
|
||||||
|
typingMode,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
const isHeartbeat = opts?.isHeartbeat === true;
|
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 = () => {
|
const shouldEmitToolResult = () => {
|
||||||
if (!sessionKey || !storePath) {
|
if (!sessionKey || !storePath) {
|
||||||
@@ -173,6 +196,7 @@ export async function runReplyAgent(params: {
|
|||||||
const runFollowupTurn = createFollowupRunner({
|
const runFollowupTurn = createFollowupRunner({
|
||||||
opts,
|
opts,
|
||||||
typing,
|
typing,
|
||||||
|
typingMode,
|
||||||
sessionEntry,
|
sessionEntry,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
@@ -252,23 +276,23 @@ export async function runReplyAgent(params: {
|
|||||||
}
|
}
|
||||||
text = stripped.text;
|
text = stripped.text;
|
||||||
}
|
}
|
||||||
if (!isHeartbeat) {
|
await signalTypingFromText(text);
|
||||||
await typing.startTypingOnText(text);
|
|
||||||
}
|
|
||||||
await opts.onPartialReply?.({
|
await opts.onPartialReply?.({
|
||||||
text,
|
text,
|
||||||
mediaUrls: payload.mediaUrls,
|
mediaUrls: payload.mediaUrls,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
onReasoningStream: opts?.onReasoningStream
|
onReasoningStream:
|
||||||
? async (payload) => {
|
shouldStartTypingOnReasoning || opts?.onReasoningStream
|
||||||
await opts.onReasoningStream?.({
|
? async (payload) => {
|
||||||
text: payload.text,
|
await signalTypingFromReasoning();
|
||||||
mediaUrls: payload.mediaUrls,
|
await opts?.onReasoningStream?.({
|
||||||
});
|
text: payload.text,
|
||||||
}
|
mediaUrls: payload.mediaUrls,
|
||||||
: undefined,
|
});
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
onAgentEvent: (evt) => {
|
onAgentEvent: (evt) => {
|
||||||
if (evt.stream !== "compaction") return;
|
if (evt.stream !== "compaction") return;
|
||||||
const phase =
|
const phase =
|
||||||
@@ -320,9 +344,7 @@ export async function runReplyAgent(params: {
|
|||||||
}
|
}
|
||||||
pendingStreamedPayloadKeys.add(payloadKey);
|
pendingStreamedPayloadKeys.add(payloadKey);
|
||||||
const task = (async () => {
|
const task = (async () => {
|
||||||
if (!isHeartbeat) {
|
await signalTypingFromText(cleaned);
|
||||||
await typing.startTypingOnText(cleaned);
|
|
||||||
}
|
|
||||||
await opts.onBlockReply?.(blockPayload);
|
await opts.onBlockReply?.(blockPayload);
|
||||||
})()
|
})()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -367,9 +389,7 @@ export async function runReplyAgent(params: {
|
|||||||
}
|
}
|
||||||
text = stripped.text;
|
text = stripped.text;
|
||||||
}
|
}
|
||||||
if (!isHeartbeat) {
|
await signalTypingFromText(text);
|
||||||
await typing.startTypingOnText(text);
|
|
||||||
}
|
|
||||||
await opts.onToolResult?.({
|
await opts.onToolResult?.({
|
||||||
text,
|
text,
|
||||||
mediaUrls: payload.mediaUrls,
|
mediaUrls: payload.mediaUrls,
|
||||||
@@ -524,7 +544,7 @@ 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 && !isHeartbeat) {
|
if (shouldSignalTyping && typingMode === "instant" && !isHeartbeat) {
|
||||||
await typing.startTypingLoop();
|
await typing.startTypingLoop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ describe("createFollowupRunner compaction", () => {
|
|||||||
const runner = createFollowupRunner({
|
const runner = createFollowupRunner({
|
||||||
opts: { onBlockReply },
|
opts: { onBlockReply },
|
||||||
typing: createTyping(),
|
typing: createTyping(),
|
||||||
|
typingMode: "instant",
|
||||||
sessionEntry,
|
sessionEntry,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
sessionKey: "main",
|
sessionKey: "main",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { runWithModelFallback } from "../../agents/model-fallback.js";
|
|||||||
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
|
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
|
||||||
import { hasNonzeroUsage } from "../../agents/usage.js";
|
import { hasNonzeroUsage } from "../../agents/usage.js";
|
||||||
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
|
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
|
||||||
|
import type { TypingMode } from "../../config/types.js";
|
||||||
import { logVerbose } from "../../globals.js";
|
import { logVerbose } from "../../globals.js";
|
||||||
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
@@ -20,6 +21,7 @@ import type { TypingController } from "./typing.js";
|
|||||||
export function createFollowupRunner(params: {
|
export function createFollowupRunner(params: {
|
||||||
opts?: GetReplyOptions;
|
opts?: GetReplyOptions;
|
||||||
typing: TypingController;
|
typing: TypingController;
|
||||||
|
typingMode: TypingMode;
|
||||||
sessionEntry?: SessionEntry;
|
sessionEntry?: SessionEntry;
|
||||||
sessionStore?: Record<string, SessionEntry>;
|
sessionStore?: Record<string, SessionEntry>;
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
@@ -30,6 +32,7 @@ export function createFollowupRunner(params: {
|
|||||||
const {
|
const {
|
||||||
opts,
|
opts,
|
||||||
typing,
|
typing,
|
||||||
|
typingMode,
|
||||||
sessionEntry,
|
sessionEntry,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
@@ -71,7 +74,13 @@ export function createFollowupRunner(params: {
|
|||||||
) {
|
) {
|
||||||
continue;
|
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.
|
// Route to originating channel if set, otherwise fall back to dispatcher.
|
||||||
if (shouldRouteToOriginating) {
|
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);
|
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 () => {
|
it("does not restart typing after it has stopped", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const onReplyStart = vi.fn(async () => {});
|
const onReplyStart = vi.fn(async () => {});
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export function createTypingController(params: {
|
|||||||
|
|
||||||
const startTypingLoop = async () => {
|
const startTypingLoop = async () => {
|
||||||
if (sealed) return;
|
if (sealed) return;
|
||||||
|
if (runComplete) return;
|
||||||
if (!onReplyStart) return;
|
if (!onReplyStart) return;
|
||||||
if (typingIntervalMs <= 0) return;
|
if (typingIntervalMs <= 0) return;
|
||||||
if (typingTimer) return;
|
if (typingTimer) return;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export type ReplyMode = "text" | "command";
|
export type ReplyMode = "text" | "command";
|
||||||
|
export type TypingMode = "never" | "instant" | "thinking" | "message";
|
||||||
export type SessionScope = "per-sender" | "global";
|
export type SessionScope = "per-sender" | "global";
|
||||||
export type ReplyToMode = "off" | "first" | "all";
|
export type ReplyToMode = "off" | "first" | "all";
|
||||||
export type GroupPolicy = "open" | "disabled" | "allowlist";
|
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). */
|
/** Max inbound media size in MB for agent-visible attachments (text note or future image attach). */
|
||||||
mediaMaxMb?: number;
|
mediaMaxMb?: number;
|
||||||
typingIntervalSeconds?: number;
|
typingIntervalSeconds?: number;
|
||||||
|
/** Typing indicator start mode (never|instant|thinking|message). */
|
||||||
|
typingMode?: TypingMode;
|
||||||
/** Periodic background heartbeat runs. */
|
/** Periodic background heartbeat runs. */
|
||||||
heartbeat?: {
|
heartbeat?: {
|
||||||
/** Heartbeat interval (duration string, default unit: minutes; default: 30m). */
|
/** Heartbeat interval (duration string, default unit: minutes; default: 30m). */
|
||||||
|
|||||||
@@ -595,6 +595,14 @@ export const ClawdbotSchema = z.object({
|
|||||||
timeoutSeconds: z.number().int().positive().optional(),
|
timeoutSeconds: z.number().int().positive().optional(),
|
||||||
mediaMaxMb: z.number().positive().optional(),
|
mediaMaxMb: z.number().positive().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(),
|
||||||
heartbeat: HeartbeatSchema,
|
heartbeat: HeartbeatSchema,
|
||||||
maxConcurrent: z.number().int().positive().optional(),
|
maxConcurrent: z.number().int().positive().optional(),
|
||||||
subagents: z
|
subagents: z
|
||||||
|
|||||||
Reference in New Issue
Block a user