refactor: unify typing dispatch lifecycle and policy boundaries

This commit is contained in:
Peter Steinberger
2026-02-26 17:36:09 +01:00
parent 6fd9ec97de
commit 273973d374
19 changed files with 420 additions and 164 deletions

View File

@@ -22,6 +22,7 @@ import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js";
import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js";
import { shouldSuppressReasoningPayload } from "./reply-payloads.js";
import { isRoutableChannel, routeReply } from "./route-reply.js";
import { resolveRunTypingPolicy } from "./typing-policy.js";
const AUDIO_PLACEHOLDER_RE = /^<media:audio>(\s*\([^)]*\))?$/i;
const AUDIO_HEADER_RE = /^\[Audio\b/i;
@@ -395,19 +396,19 @@ export async function dispatchReplyFromConfig(params: {
}
return { ...payload, text: undefined };
};
const typing = resolveRunTypingPolicy({
requestedPolicy: params.replyOptions?.typingPolicy,
suppressTyping: params.replyOptions?.suppressTyping === true || shouldSuppressTyping,
originatingChannel,
systemEvent: shouldRouteToOriginating,
});
const replyResult = await (params.replyResolver ?? getReplyFromConfig)(
ctx,
{
...params.replyOptions,
typingPolicy:
params.replyOptions?.typingPolicy ??
(originatingChannel === INTERNAL_MESSAGE_CHANNEL
? "internal_webchat"
: shouldRouteToOriginating
? "system_event"
: undefined),
suppressTyping: params.replyOptions?.suppressTyping === true || shouldSuppressTyping,
typingPolicy: typing.typingPolicy,
suppressTyping: typing.suppressTyping,
onToolResult: (payload: ReplyPayload) => {
const run = async () => {
const ttsPayload = await maybeApplyTtsToPayload({

View File

@@ -18,7 +18,6 @@ import {
import { logVerbose } from "../../globals.js";
import { clearCommandLane, getQueueSize } from "../../process/command-queue.js";
import { normalizeMainKey } from "../../routing/session-key.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
import { isReasoningTagProvider } from "../../utils/provider-utils.js";
import { hasControlCommand } from "../command-detection.js";
import { buildInboundMediaNote } from "../media-note.js";
@@ -47,6 +46,7 @@ import { routeReply } from "./route-reply.js";
import { BARE_SESSION_RESET_PROMPT } from "./session-reset-prompt.js";
import { ensureSkillSnapshot, prependSystemEvents } from "./session-updates.js";
import { resolveTypingMode } from "./typing-mode.js";
import { resolveRunTypingPolicy } from "./typing-policy.js";
import type { TypingController } from "./typing.js";
import { appendUntrustedContext } from "./untrusted-context.js";
@@ -234,14 +234,12 @@ export async function runPreparedReply(
const isGroupChat = sessionCtx.ChatType === "group";
const wasMentioned = ctx.WasMentioned === true;
const isHeartbeat = opts?.isHeartbeat === true;
const typingPolicy =
opts?.typingPolicy ??
(isHeartbeat
? "heartbeat"
: ctx.OriginatingChannel === INTERNAL_MESSAGE_CHANNEL
? "internal_webchat"
: "auto");
const suppressTyping = opts?.suppressTyping === true;
const { typingPolicy, suppressTyping } = resolveRunTypingPolicy({
requestedPolicy: opts?.typingPolicy,
suppressTyping: opts?.suppressTyping === true,
isHeartbeat,
originatingChannel: ctx.OriginatingChannel,
});
const typingMode = resolveTypingMode({
configured: sessionCfg?.typingMode ?? agentCfg?.typingMode,
isGroupChat,

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { resolveRunTypingPolicy } from "./typing-policy.js";
describe("resolveRunTypingPolicy", () => {
it("forces heartbeat policy for heartbeat runs", () => {
const resolved = resolveRunTypingPolicy({
requestedPolicy: "user_message",
isHeartbeat: true,
});
expect(resolved).toEqual({
typingPolicy: "heartbeat",
suppressTyping: true,
});
});
it("forces internal webchat policy", () => {
const resolved = resolveRunTypingPolicy({
requestedPolicy: "user_message",
originatingChannel: "webchat",
});
expect(resolved).toEqual({
typingPolicy: "internal_webchat",
suppressTyping: true,
});
});
it("forces system event policy for routed turns", () => {
const resolved = resolveRunTypingPolicy({
requestedPolicy: "user_message",
systemEvent: true,
originatingChannel: "telegram",
});
expect(resolved).toEqual({
typingPolicy: "system_event",
suppressTyping: true,
});
});
it("preserves requested policy for regular user turns", () => {
const resolved = resolveRunTypingPolicy({
requestedPolicy: "user_message",
originatingChannel: "telegram",
});
expect(resolved).toEqual({
typingPolicy: "user_message",
suppressTyping: false,
});
});
it("respects explicit suppressTyping", () => {
const resolved = resolveRunTypingPolicy({
requestedPolicy: "auto",
originatingChannel: "telegram",
suppressTyping: true,
});
expect(resolved).toEqual({
typingPolicy: "auto",
suppressTyping: true,
});
});
});

View File

@@ -0,0 +1,35 @@
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
import type { TypingPolicy } from "../types.js";
export type ResolveRunTypingPolicyParams = {
requestedPolicy?: TypingPolicy;
suppressTyping?: boolean;
isHeartbeat?: boolean;
originatingChannel?: string;
systemEvent?: boolean;
};
export type ResolvedRunTypingPolicy = {
typingPolicy: TypingPolicy;
suppressTyping: boolean;
};
export function resolveRunTypingPolicy(
params: ResolveRunTypingPolicyParams,
): ResolvedRunTypingPolicy {
const typingPolicy = params.isHeartbeat
? "heartbeat"
: params.originatingChannel === INTERNAL_MESSAGE_CHANNEL
? "internal_webchat"
: params.systemEvent
? "system_event"
: (params.requestedPolicy ?? "auto");
const suppressTyping =
params.suppressTyping === true ||
typingPolicy === "heartbeat" ||
typingPolicy === "system_event" ||
typingPolicy === "internal_webchat";
return { typingPolicy, suppressTyping };
}

View File

@@ -1,4 +1,5 @@
import { createTypingKeepaliveLoop } from "../../channels/typing-lifecycle.js";
import { createTypingStartGuard } from "../../channels/typing-start-guard.js";
import { isSilentReplyPrefixText, isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
export type TypingController = {
@@ -99,15 +100,16 @@ export function createTypingController(params: {
const isActive = () => active && !sealed;
const startGuard = createTypingStartGuard({
isSealed: () => sealed,
shouldBlock: () => runComplete,
rethrowOnError: true,
});
const triggerTyping = async () => {
if (sealed) {
return;
}
// Late callbacks after a run completed should never restart typing.
if (runComplete) {
return;
}
await onReplyStart?.();
await startGuard.run(async () => {
await onReplyStart?.();
});
};
const typingLoop = createTypingKeepaliveLoop({