feat: add typing mode controls

This commit is contained in:
Peter Steinberger
2026-01-07 21:58:54 +00:00
parent 430662f6ef
commit bac1608933
14 changed files with 307 additions and 20 deletions

View File

@@ -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,
});
}

View File

@@ -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-")),

View File

@@ -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();
}

View File

@@ -76,6 +76,7 @@ describe("createFollowupRunner compaction", () => {
const runner = createFollowupRunner({
opts: { onBlockReply },
typing: createTyping(),
typingMode: "instant",
sessionEntry,
sessionStore,
sessionKey: "main",

View File

@@ -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) {

View 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");
});
});

View 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";

View File

@@ -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 () => {});

View File

@@ -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;