mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
refactor: unify typing dispatch lifecycle and policy boundaries
This commit is contained in:
@@ -162,6 +162,24 @@ function createMockRuntime(): PluginRuntime {
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveHumanDelayConfig"],
|
||||
dispatchReplyFromConfig:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"],
|
||||
withReplyDispatcher: vi.fn(
|
||||
async ({
|
||||
dispatcher,
|
||||
run,
|
||||
onSettled,
|
||||
}: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) => {
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
dispatcher.markComplete();
|
||||
try {
|
||||
await dispatcher.waitForIdle();
|
||||
} finally {
|
||||
await onSettled?.();
|
||||
}
|
||||
}
|
||||
},
|
||||
) as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"],
|
||||
finalizeInboundContext: vi.fn(
|
||||
(ctx: Record<string, unknown>) => ctx,
|
||||
) as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
|
||||
|
||||
@@ -90,6 +90,24 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
const mockDispatchReplyFromConfig = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ queuedFinal: false, counts: { final: 1 } });
|
||||
const mockWithReplyDispatcher = vi.fn(
|
||||
async ({
|
||||
dispatcher,
|
||||
run,
|
||||
onSettled,
|
||||
}: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) => {
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
dispatcher.markComplete();
|
||||
try {
|
||||
await dispatcher.waitForIdle();
|
||||
} finally {
|
||||
await onSettled?.();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false);
|
||||
const mockShouldComputeCommandAuthorized = vi.fn(() => true);
|
||||
const mockReadAllowFromStore = vi.fn().mockResolvedValue([]);
|
||||
@@ -127,6 +145,7 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
|
||||
finalizeInboundContext: mockFinalizeInboundContext,
|
||||
dispatchReplyFromConfig: mockDispatchReplyFromConfig,
|
||||
withReplyDispatcher: mockWithReplyDispatcher,
|
||||
},
|
||||
commands: {
|
||||
shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized,
|
||||
|
||||
@@ -943,33 +943,31 @@ export async function handleFeishuMessage(params: {
|
||||
});
|
||||
|
||||
log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
|
||||
try {
|
||||
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
replyOptions,
|
||||
});
|
||||
|
||||
if (isGroup && historyKey && chatHistories) {
|
||||
clearHistoryEntriesIfEnabled({
|
||||
historyMap: chatHistories,
|
||||
historyKey,
|
||||
limit: historyLimit,
|
||||
});
|
||||
}
|
||||
|
||||
log(
|
||||
`feishu[${account.accountId}]: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`,
|
||||
);
|
||||
} finally {
|
||||
dispatcher.markComplete();
|
||||
try {
|
||||
await dispatcher.waitForIdle();
|
||||
} finally {
|
||||
const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
|
||||
dispatcher,
|
||||
onSettled: () => {
|
||||
markDispatchIdle();
|
||||
}
|
||||
},
|
||||
run: () =>
|
||||
core.channel.reply.dispatchReplyFromConfig({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
replyOptions,
|
||||
}),
|
||||
});
|
||||
|
||||
if (isGroup && historyKey && chatHistories) {
|
||||
clearHistoryEntriesIfEnabled({
|
||||
historyMap: chatHistories,
|
||||
historyKey,
|
||||
limit: historyLimit,
|
||||
});
|
||||
}
|
||||
|
||||
log(
|
||||
`feishu[${account.accountId}]: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`,
|
||||
);
|
||||
} catch (err) {
|
||||
error(`feishu[${account.accountId}]: failed to dispatch message: ${String(err)}`);
|
||||
}
|
||||
|
||||
@@ -655,39 +655,37 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
...replyOptions,
|
||||
skillFilter: roomConfig?.skills,
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
if (!queuedFinal) {
|
||||
return;
|
||||
}
|
||||
didSendReply = true;
|
||||
const finalCount = counts.final;
|
||||
logVerboseMessage(
|
||||
`matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
|
||||
);
|
||||
if (didSendReply) {
|
||||
const previewText = bodyText.replace(/\s+/g, " ").slice(0, 160);
|
||||
core.system.enqueueSystemEvent(`Matrix message from ${senderName}: ${previewText}`, {
|
||||
sessionKey: route.sessionKey,
|
||||
contextKey: `matrix:message:${roomId}:${messageId || "unknown"}`,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
dispatcher.markComplete();
|
||||
try {
|
||||
await dispatcher.waitForIdle();
|
||||
} finally {
|
||||
const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
|
||||
dispatcher,
|
||||
onSettled: () => {
|
||||
markDispatchIdle();
|
||||
}
|
||||
},
|
||||
run: () =>
|
||||
core.channel.reply.dispatchReplyFromConfig({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
...replyOptions,
|
||||
skillFilter: roomConfig?.skills,
|
||||
onModelSelected,
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!queuedFinal) {
|
||||
return;
|
||||
}
|
||||
didSendReply = true;
|
||||
const finalCount = counts.final;
|
||||
logVerboseMessage(
|
||||
`matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
|
||||
);
|
||||
if (didSendReply) {
|
||||
const previewText = bodyText.replace(/\s+/g, " ").slice(0, 160);
|
||||
core.system.enqueueSystemEvent(`Matrix message from ${senderName}: ${previewText}`, {
|
||||
sessionKey: route.sessionKey,
|
||||
contextKey: `matrix:message:${roomId}:${messageId || "unknown"}`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
runtime.error?.(`matrix handler failed: ${String(err)}`);
|
||||
|
||||
@@ -772,32 +772,30 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await core.channel.reply.dispatchReplyFromConfig({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
...replyOptions,
|
||||
disableBlockStreaming:
|
||||
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
if (historyKey) {
|
||||
clearHistoryEntriesIfEnabled({
|
||||
historyMap: channelHistories,
|
||||
historyKey,
|
||||
limit: historyLimit,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
dispatcher.markComplete();
|
||||
try {
|
||||
await dispatcher.waitForIdle();
|
||||
} finally {
|
||||
await core.channel.reply.withReplyDispatcher({
|
||||
dispatcher,
|
||||
onSettled: () => {
|
||||
markDispatchIdle();
|
||||
}
|
||||
},
|
||||
run: () =>
|
||||
core.channel.reply.dispatchReplyFromConfig({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
...replyOptions,
|
||||
disableBlockStreaming:
|
||||
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
|
||||
onModelSelected,
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (historyKey) {
|
||||
clearHistoryEntriesIfEnabled({
|
||||
historyMap: channelHistories,
|
||||
historyKey,
|
||||
limit: historyLimit,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -533,30 +533,23 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
|
||||
log.info("dispatching to agent", { sessionKey: route.sessionKey });
|
||||
try {
|
||||
try {
|
||||
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
replyOptions,
|
||||
});
|
||||
const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
|
||||
dispatcher,
|
||||
onSettled: () => {
|
||||
markDispatchIdle();
|
||||
},
|
||||
run: () =>
|
||||
core.channel.reply.dispatchReplyFromConfig({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
replyOptions,
|
||||
}),
|
||||
});
|
||||
|
||||
log.info("dispatch complete", { queuedFinal, counts });
|
||||
log.info("dispatch complete", { queuedFinal, counts });
|
||||
|
||||
if (!queuedFinal) {
|
||||
if (isRoomish && historyKey) {
|
||||
clearHistoryEntriesIfEnabled({
|
||||
historyMap: conversationHistories,
|
||||
historyKey,
|
||||
limit: historyLimit,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const finalCount = counts.final;
|
||||
logVerboseMessage(
|
||||
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
|
||||
);
|
||||
if (!queuedFinal) {
|
||||
if (isRoomish && historyKey) {
|
||||
clearHistoryEntriesIfEnabled({
|
||||
historyMap: conversationHistories,
|
||||
@@ -564,13 +557,18 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
limit: historyLimit,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
dispatcher.markComplete();
|
||||
try {
|
||||
await dispatcher.waitForIdle();
|
||||
} finally {
|
||||
markDispatchIdle();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const finalCount = counts.final;
|
||||
logVerboseMessage(
|
||||
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
|
||||
);
|
||||
if (isRoomish && historyKey) {
|
||||
clearHistoryEntriesIfEnabled({
|
||||
historyMap: conversationHistories,
|
||||
historyKey,
|
||||
limit: historyLimit,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
log.error("dispatch failed", { error: String(err) });
|
||||
|
||||
@@ -40,7 +40,7 @@ const allowedRawFetchCallsites = new Set([
|
||||
"extensions/matrix/src/directory-live.ts:41",
|
||||
"extensions/matrix/src/matrix/client/config.ts:171",
|
||||
"extensions/mattermost/src/mattermost/client.ts:211",
|
||||
"extensions/mattermost/src/mattermost/monitor.ts:234",
|
||||
"extensions/mattermost/src/mattermost/monitor.ts:230",
|
||||
"extensions/mattermost/src/mattermost/probe.ts:27",
|
||||
"extensions/minimax-portal-auth/oauth.ts:71",
|
||||
"extensions/minimax-portal-auth/oauth.ts:112",
|
||||
@@ -89,6 +89,9 @@ async function collectTypeScriptFiles(targetPath) {
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(targetPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "node_modules") {
|
||||
continue;
|
||||
}
|
||||
files.push(...(await collectTypeScriptFiles(entryPath)));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
61
src/auto-reply/reply/typing-policy.test.ts
Normal file
61
src/auto-reply/reply/typing-policy.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
35
src/auto-reply/reply/typing-policy.ts
Normal file
35
src/auto-reply/reply/typing-policy.ts
Normal 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 };
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
65
src/channels/typing-start-guard.test.ts
Normal file
65
src/channels/typing-start-guard.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createTypingStartGuard } from "./typing-start-guard.js";
|
||||
|
||||
describe("createTypingStartGuard", () => {
|
||||
it("skips starts when sealed", async () => {
|
||||
const start = vi.fn();
|
||||
const guard = createTypingStartGuard({
|
||||
isSealed: () => true,
|
||||
});
|
||||
|
||||
const result = await guard.run(start);
|
||||
expect(result).toBe("skipped");
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("trips breaker after max consecutive failures", async () => {
|
||||
const onStartError = vi.fn();
|
||||
const onTrip = vi.fn();
|
||||
const guard = createTypingStartGuard({
|
||||
isSealed: () => false,
|
||||
onStartError,
|
||||
onTrip,
|
||||
maxConsecutiveFailures: 2,
|
||||
});
|
||||
const start = vi.fn().mockRejectedValue(new Error("fail"));
|
||||
|
||||
const first = await guard.run(start);
|
||||
const second = await guard.run(start);
|
||||
const third = await guard.run(start);
|
||||
|
||||
expect(first).toBe("failed");
|
||||
expect(second).toBe("tripped");
|
||||
expect(third).toBe("skipped");
|
||||
expect(onStartError).toHaveBeenCalledTimes(2);
|
||||
expect(onTrip).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resets breaker state", async () => {
|
||||
const guard = createTypingStartGuard({
|
||||
isSealed: () => false,
|
||||
maxConsecutiveFailures: 1,
|
||||
});
|
||||
const failStart = vi.fn().mockRejectedValue(new Error("fail"));
|
||||
const okStart = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const trip = await guard.run(failStart);
|
||||
expect(trip).toBe("tripped");
|
||||
expect(guard.isTripped()).toBe(true);
|
||||
|
||||
guard.reset();
|
||||
const started = await guard.run(okStart);
|
||||
expect(started).toBe("started");
|
||||
expect(guard.isTripped()).toBe(false);
|
||||
});
|
||||
|
||||
it("rethrows start errors when configured", async () => {
|
||||
const guard = createTypingStartGuard({
|
||||
isSealed: () => false,
|
||||
rethrowOnError: true,
|
||||
});
|
||||
const start = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
|
||||
await expect(guard.run(start)).rejects.toThrow("boom");
|
||||
});
|
||||
});
|
||||
63
src/channels/typing-start-guard.ts
Normal file
63
src/channels/typing-start-guard.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export type TypingStartGuard = {
|
||||
run: (start: () => Promise<void> | void) => Promise<"started" | "skipped" | "failed" | "tripped">;
|
||||
reset: () => void;
|
||||
isTripped: () => boolean;
|
||||
};
|
||||
|
||||
export function createTypingStartGuard(params: {
|
||||
isSealed: () => boolean;
|
||||
shouldBlock?: () => boolean;
|
||||
onStartError?: (err: unknown) => void;
|
||||
maxConsecutiveFailures?: number;
|
||||
onTrip?: () => void;
|
||||
rethrowOnError?: boolean;
|
||||
}): TypingStartGuard {
|
||||
const maxConsecutiveFailures =
|
||||
typeof params.maxConsecutiveFailures === "number" && params.maxConsecutiveFailures > 0
|
||||
? Math.floor(params.maxConsecutiveFailures)
|
||||
: undefined;
|
||||
let consecutiveFailures = 0;
|
||||
let tripped = false;
|
||||
|
||||
const isBlocked = () => {
|
||||
if (params.isSealed()) {
|
||||
return true;
|
||||
}
|
||||
if (tripped) {
|
||||
return true;
|
||||
}
|
||||
return params.shouldBlock?.() === true;
|
||||
};
|
||||
|
||||
const run: TypingStartGuard["run"] = async (start) => {
|
||||
if (isBlocked()) {
|
||||
return "skipped";
|
||||
}
|
||||
try {
|
||||
await start();
|
||||
consecutiveFailures = 0;
|
||||
return "started";
|
||||
} catch (err) {
|
||||
consecutiveFailures += 1;
|
||||
params.onStartError?.(err);
|
||||
if (params.rethrowOnError) {
|
||||
throw err;
|
||||
}
|
||||
if (maxConsecutiveFailures && consecutiveFailures >= maxConsecutiveFailures) {
|
||||
tripped = true;
|
||||
params.onTrip?.();
|
||||
return "tripped";
|
||||
}
|
||||
return "failed";
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
run,
|
||||
reset: () => {
|
||||
consecutiveFailures = 0;
|
||||
tripped = false;
|
||||
},
|
||||
isTripped: () => tripped,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createTypingKeepaliveLoop } from "./typing-lifecycle.js";
|
||||
import { createTypingStartGuard } from "./typing-start-guard.js";
|
||||
|
||||
export type TypingCallbacks = {
|
||||
onReplyStart: () => Promise<void>;
|
||||
@@ -26,28 +27,19 @@ export function createTypingCallbacks(params: CreateTypingCallbacksParams): Typi
|
||||
const maxDurationMs = params.maxDurationMs ?? 60_000; // Default 60s TTL
|
||||
let stopSent = false;
|
||||
let closed = false;
|
||||
let consecutiveFailures = 0;
|
||||
let breakerTripped = false;
|
||||
let ttlTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const startGuard = createTypingStartGuard({
|
||||
isSealed: () => closed,
|
||||
onStartError: params.onStartError,
|
||||
maxConsecutiveFailures,
|
||||
onTrip: () => {
|
||||
keepaliveLoop.stop();
|
||||
},
|
||||
});
|
||||
|
||||
const fireStart = async (): Promise<void> => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
if (breakerTripped) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await params.start();
|
||||
consecutiveFailures = 0;
|
||||
} catch (err) {
|
||||
consecutiveFailures += 1;
|
||||
params.onStartError(err);
|
||||
if (consecutiveFailures >= maxConsecutiveFailures) {
|
||||
breakerTripped = true;
|
||||
keepaliveLoop.stop();
|
||||
}
|
||||
}
|
||||
await startGuard.run(() => params.start());
|
||||
};
|
||||
|
||||
const keepaliveLoop = createTypingKeepaliveLoop({
|
||||
@@ -81,12 +73,11 @@ export function createTypingCallbacks(params: CreateTypingCallbacksParams): Typi
|
||||
return;
|
||||
}
|
||||
stopSent = false;
|
||||
breakerTripped = false;
|
||||
consecutiveFailures = 0;
|
||||
startGuard.reset();
|
||||
keepaliveLoop.stop();
|
||||
clearTtlTimer();
|
||||
await fireStart();
|
||||
if (breakerTripped) {
|
||||
if (startGuard.isTripped()) {
|
||||
return;
|
||||
}
|
||||
keepaliveLoop.start();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fetchWithBearerAuthScopeFallback } from "./fetch-auth.js";
|
||||
|
||||
const asFetch = (fn: unknown): typeof fetch => fn as typeof fetch;
|
||||
|
||||
describe("fetchWithBearerAuthScopeFallback", () => {
|
||||
it("rejects non-https urls when https is required", async () => {
|
||||
await expect(
|
||||
@@ -19,7 +21,7 @@ describe("fetchWithBearerAuthScopeFallback", () => {
|
||||
const response = await fetchWithBearerAuthScopeFallback({
|
||||
url: "https://example.com/file",
|
||||
scopes: ["https://graph.microsoft.com"],
|
||||
fetchFn,
|
||||
fetchFn: asFetch(fetchFn),
|
||||
tokenProvider,
|
||||
});
|
||||
|
||||
@@ -38,7 +40,7 @@ describe("fetchWithBearerAuthScopeFallback", () => {
|
||||
const response = await fetchWithBearerAuthScopeFallback({
|
||||
url: "https://graph.microsoft.com/v1.0/me",
|
||||
scopes: ["https://graph.microsoft.com", "https://api.botframework.com"],
|
||||
fetchFn,
|
||||
fetchFn: asFetch(fetchFn),
|
||||
tokenProvider,
|
||||
});
|
||||
|
||||
@@ -57,7 +59,7 @@ describe("fetchWithBearerAuthScopeFallback", () => {
|
||||
const response = await fetchWithBearerAuthScopeFallback({
|
||||
url: "https://example.com/file",
|
||||
scopes: ["https://graph.microsoft.com"],
|
||||
fetchFn,
|
||||
fetchFn: asFetch(fetchFn),
|
||||
tokenProvider,
|
||||
shouldAttachAuth: () => false,
|
||||
});
|
||||
@@ -82,7 +84,7 @@ describe("fetchWithBearerAuthScopeFallback", () => {
|
||||
const response = await fetchWithBearerAuthScopeFallback({
|
||||
url: "https://graph.microsoft.com/v1.0/me",
|
||||
scopes: ["https://first.example", "https://second.example"],
|
||||
fetchFn,
|
||||
fetchFn: asFetch(fetchFn),
|
||||
tokenProvider,
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
shouldComputeCommandAuthorized,
|
||||
} from "../../auto-reply/command-detection.js";
|
||||
import { shouldHandleTextCommands } from "../../auto-reply/commands-registry.js";
|
||||
import { withReplyDispatcher } from "../../auto-reply/dispatch.js";
|
||||
import {
|
||||
formatAgentEnvelope,
|
||||
formatInboundEnvelope,
|
||||
@@ -304,6 +305,7 @@ function createRuntimeChannel(): PluginRuntime["channel"] {
|
||||
resolveEffectiveMessagesConfig,
|
||||
resolveHumanDelayConfig,
|
||||
dispatchReplyFromConfig,
|
||||
withReplyDispatcher,
|
||||
finalizeInboundContext,
|
||||
formatAgentEnvelope,
|
||||
/** @deprecated Prefer `BodyForAgent` + structured user-context blocks (do not build plaintext envelopes for prompts). */
|
||||
|
||||
@@ -55,6 +55,7 @@ type ShouldHandleTextCommands =
|
||||
typeof import("../../auto-reply/commands-registry.js").shouldHandleTextCommands;
|
||||
type DispatchReplyFromConfig =
|
||||
typeof import("../../auto-reply/reply/dispatch-from-config.js").dispatchReplyFromConfig;
|
||||
type WithReplyDispatcher = typeof import("../../auto-reply/dispatch.js").withReplyDispatcher;
|
||||
type FinalizeInboundContext =
|
||||
typeof import("../../auto-reply/reply/inbound-context.js").finalizeInboundContext;
|
||||
type FormatAgentEnvelope = typeof import("../../auto-reply/envelope.js").formatAgentEnvelope;
|
||||
@@ -222,6 +223,7 @@ export type PluginRuntime = {
|
||||
resolveEffectiveMessagesConfig: ResolveEffectiveMessagesConfig;
|
||||
resolveHumanDelayConfig: ResolveHumanDelayConfig;
|
||||
dispatchReplyFromConfig: DispatchReplyFromConfig;
|
||||
withReplyDispatcher: WithReplyDispatcher;
|
||||
finalizeInboundContext: FinalizeInboundContext;
|
||||
formatAgentEnvelope: FormatAgentEnvelope;
|
||||
/** @deprecated Prefer `BodyForAgent` + structured user-context blocks (do not build plaintext envelopes for prompts). */
|
||||
|
||||
@@ -111,8 +111,10 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
|
||||
hadPreviewMessage: boolean;
|
||||
}): boolean => {
|
||||
const currentPreviewText = args.currentPreviewText;
|
||||
if (currentPreviewText === undefined) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
currentPreviewText !== undefined &&
|
||||
currentPreviewText.startsWith(args.text) &&
|
||||
args.text.length < currentPreviewText.length &&
|
||||
(args.skipRegressive === "always" || args.hadPreviewMessage)
|
||||
|
||||
Reference in New Issue
Block a user