feat: add typing mode controls
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user