mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
fix: show effective channel model in Telegram status (#118548)
This commit is contained in:
committed by
GitHub
parent
684d32bed5
commit
b21056a0dc
@@ -0,0 +1,293 @@
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
|
||||
import * as preparedModelCatalog from "../../agents/prepared-model-catalog.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { replaceSessionEntry } from "../../config/sessions/session-accessor.js";
|
||||
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import { createSessionConversationTestRegistry } from "../../test-utils/session-conversation-registry.js";
|
||||
import { normalizeSessionDeliveryState } from "../../utils/delivery-context.shared.js";
|
||||
import { markCompleteReplyConfig } from "./get-reply-fast-path.test-support.js";
|
||||
import { buildTestCtx } from "./test-ctx.js";
|
||||
import type { TypingController } from "./typing.js";
|
||||
|
||||
type NativeStatusSelectionCase = {
|
||||
selection: string;
|
||||
source: "user" | "auto" | undefined;
|
||||
channelModel?: string;
|
||||
deliveryChannel?: string;
|
||||
directSenderId?: string;
|
||||
directUserId?: string;
|
||||
expectedModel?: string;
|
||||
expectedProvider?: string;
|
||||
groupId?: string;
|
||||
locked?: boolean;
|
||||
modelParentSessionKey?: string;
|
||||
preparedModel?: string;
|
||||
preparedProvider?: string;
|
||||
};
|
||||
|
||||
const buildStatusReplyMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./commands-status.js", () => ({
|
||||
buildStatusReply: (...args: unknown[]) => buildStatusReplyMock(...args),
|
||||
}));
|
||||
|
||||
const { maybeResolveNativeSlashCommandFastReply } =
|
||||
await import("./get-reply-native-slash-fast-path.js");
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
const createTypingController = (): TypingController => ({
|
||||
onReplyStart: async () => {},
|
||||
startTypingLoop: async () => {},
|
||||
startTypingOnText: async () => {},
|
||||
refreshTypingTtl: () => {},
|
||||
isActive: () => false,
|
||||
markRunComplete: () => {},
|
||||
markDispatchIdle: () => {},
|
||||
cleanup: vi.fn(),
|
||||
});
|
||||
|
||||
describe("native /status channel model routing", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
|
||||
resetPluginRuntimeStateForTest();
|
||||
setActivePluginRegistry(createSessionConversationTestRegistry());
|
||||
vi.spyOn(preparedModelCatalog, "loadPreparedModelCatalog").mockResolvedValue([
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT",
|
||||
provider: "openai",
|
||||
contextWindow: 400_000,
|
||||
reasoning: false,
|
||||
},
|
||||
{
|
||||
id: "claude-fable-5",
|
||||
name: "Fable",
|
||||
provider: "anthropic",
|
||||
contextWindow: 1_000_000,
|
||||
reasoning: true,
|
||||
},
|
||||
]);
|
||||
buildStatusReplyMock.mockReset();
|
||||
buildStatusReplyMock.mockResolvedValue({ text: "selected model status" });
|
||||
});
|
||||
|
||||
const statusSelectionCases: NativeStatusSelectionCase[] = [
|
||||
{ selection: "user override", source: "user" },
|
||||
{ selection: "automatic fallback", source: "auto" },
|
||||
{
|
||||
selection: "channel override",
|
||||
source: undefined,
|
||||
expectedProvider: "anthropic",
|
||||
expectedModel: "claude-fable-5",
|
||||
},
|
||||
{
|
||||
selection: "configured channel model alias",
|
||||
source: undefined,
|
||||
channelModel: "Fable",
|
||||
expectedProvider: "anthropic",
|
||||
expectedModel: "claude-fable-5",
|
||||
},
|
||||
{
|
||||
selection: "current command channel over stale session delivery",
|
||||
source: undefined,
|
||||
deliveryChannel: "discord",
|
||||
expectedProvider: "anthropic",
|
||||
expectedModel: "claude-fable-5",
|
||||
},
|
||||
{
|
||||
selection: "parent group override for a topic",
|
||||
source: undefined,
|
||||
groupId: "123:topic:77",
|
||||
expectedProvider: "anthropic",
|
||||
expectedModel: "claude-fable-5",
|
||||
},
|
||||
{
|
||||
selection: "thread-only model parent session override",
|
||||
source: undefined,
|
||||
groupId: "unmatched-thread",
|
||||
modelParentSessionKey: "agent:main:telegram:group:123:thread:77",
|
||||
expectedProvider: "anthropic",
|
||||
expectedModel: "claude-fable-5",
|
||||
},
|
||||
{
|
||||
selection: "native direct peer override before wildcard",
|
||||
source: undefined,
|
||||
directUserId: "native-peer-42",
|
||||
expectedProvider: "anthropic",
|
||||
expectedModel: "claude-fable-5",
|
||||
},
|
||||
{
|
||||
selection: "current direct sender override before wildcard",
|
||||
source: undefined,
|
||||
directSenderId: "live-peer-43",
|
||||
expectedProvider: "anthropic",
|
||||
expectedModel: "claude-fable-5",
|
||||
},
|
||||
{
|
||||
selection: "current direct sender over another channel's persisted peer",
|
||||
source: undefined,
|
||||
deliveryChannel: "discord",
|
||||
directUserId: "stale-discord-peer",
|
||||
directSenderId: "live-telegram-peer",
|
||||
expectedProvider: "anthropic",
|
||||
expectedModel: "claude-fable-5",
|
||||
},
|
||||
{
|
||||
selection: "locked model selection",
|
||||
source: undefined,
|
||||
locked: true,
|
||||
},
|
||||
{
|
||||
selection: "prepared non-default heartbeat or fallback model",
|
||||
source: undefined,
|
||||
preparedProvider: "xai",
|
||||
preparedModel: "grok-4.3",
|
||||
expectedProvider: "xai",
|
||||
expectedModel: "grok-4.3",
|
||||
},
|
||||
];
|
||||
|
||||
it.each(statusSelectionCases)(
|
||||
"preserves canonical native /status $selection",
|
||||
async (testCase) => {
|
||||
const targetSessionKey = "agent:main:main";
|
||||
const storePath = path.join(tempDirs.make("openclaw-native-status-"), "sessions.json");
|
||||
const {
|
||||
channelModel = "anthropic/claude-fable-5",
|
||||
deliveryChannel = "telegram",
|
||||
directSenderId,
|
||||
directUserId,
|
||||
expectedModel = "gpt-5.5",
|
||||
expectedProvider = "openai",
|
||||
groupId = "123",
|
||||
locked = false,
|
||||
modelParentSessionKey,
|
||||
preparedModel = "gpt-5.5",
|
||||
preparedProvider = "openai",
|
||||
source,
|
||||
} = testCase;
|
||||
const isDirect = directUserId !== undefined || directSenderId !== undefined;
|
||||
const overrideKey = directSenderId ?? directUserId ?? "123";
|
||||
const conflictingDirectUserId =
|
||||
directSenderId !== undefined && directUserId !== undefined ? directUserId : undefined;
|
||||
await replaceSessionEntry(
|
||||
{ agentId: "main", sessionKey: targetSessionKey, storePath },
|
||||
{
|
||||
sessionId: "status-session",
|
||||
updatedAt: Date.now(),
|
||||
contextTokens: 1_000_000,
|
||||
delivery: normalizeSessionDeliveryState({
|
||||
context: { channel: deliveryChannel },
|
||||
...(directUserId
|
||||
? { origin: { provider: deliveryChannel, nativeDirectUserId: directUserId } }
|
||||
: {}),
|
||||
}),
|
||||
...(isDirect ? {} : { groupId }),
|
||||
...(locked ? { modelSelectionLocked: true } : {}),
|
||||
...(source
|
||||
? {
|
||||
providerOverride: "anthropic",
|
||||
modelOverride: "claude-fable-5",
|
||||
modelOverrideSource: source,
|
||||
...(source === "auto"
|
||||
? {
|
||||
modelOverrideFallbackOriginProvider: "openai",
|
||||
modelOverrideFallbackOriginModel: "gpt-5.5",
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.5",
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
|
||||
const result = await maybeResolveNativeSlashCommandFastReply({
|
||||
ctx: buildTestCtx({
|
||||
Body: "/status",
|
||||
CommandBody: "/status",
|
||||
CommandSource: "native",
|
||||
CommandAuthorized: true,
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
ChatType: isDirect ? "direct" : "group",
|
||||
...(directSenderId
|
||||
? { From: `telegram:${directSenderId}`, SenderId: directSenderId }
|
||||
: {}),
|
||||
...(modelParentSessionKey ? { ModelParentSessionKey: modelParentSessionKey } : {}),
|
||||
SessionKey: "telegram:slash:123",
|
||||
CommandTargetSessionKey: targetSessionKey,
|
||||
CommandTurn: {
|
||||
kind: "native",
|
||||
source: "native",
|
||||
authorized: true,
|
||||
commandName: "status",
|
||||
body: "/status",
|
||||
},
|
||||
}),
|
||||
cfg: markCompleteReplyConfig({
|
||||
session: { store: storePath },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.5" },
|
||||
modelPolicy: { allow: ["openai/*", "anthropic/*", "xai/*"] },
|
||||
models: {
|
||||
"anthropic/claude-fable-5": {
|
||||
alias: "Fable",
|
||||
params: { thinking: "high", fastMode: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
modelByChannel: {
|
||||
telegram: {
|
||||
[overrideKey]: channelModel,
|
||||
...(conflictingDirectUserId ? { [conflictingDirectUserId]: "xai/grok-4.3" } : {}),
|
||||
"*": "openai/gpt-5.5",
|
||||
},
|
||||
discord: { "123": "openai/gpt-5.5" },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig),
|
||||
agentId: "main",
|
||||
agentDir: "/tmp/agent",
|
||||
agentCfg: undefined,
|
||||
commandAuthorized: true,
|
||||
defaultProvider: "openai",
|
||||
defaultModel: "gpt-5.5",
|
||||
aliasIndex: {
|
||||
byKey: new Map(),
|
||||
byAlias: new Map([
|
||||
["fable", { alias: "Fable", ref: { provider: "anthropic", model: "claude-fable-5" } }],
|
||||
]),
|
||||
},
|
||||
provider: preparedProvider,
|
||||
model: preparedModel,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
typing: createTypingController(),
|
||||
});
|
||||
|
||||
const statusCall = buildStatusReplyMock.mock.calls[0]?.[0];
|
||||
expect(statusCall).toMatchObject({ provider: expectedProvider, model: expectedModel });
|
||||
if (expectedProvider === "anthropic") {
|
||||
await expect(statusCall.resolveDefaultThinkingLevel()).resolves.toBe("high");
|
||||
}
|
||||
if (source) {
|
||||
expect(statusCall.sessionEntry).toMatchObject({
|
||||
providerOverride: "anthropic",
|
||||
modelOverride: "claude-fable-5",
|
||||
modelOverrideSource: source,
|
||||
});
|
||||
} else {
|
||||
expect(statusCall.sessionEntry).not.toHaveProperty("providerOverride");
|
||||
expect(statusCall.sessionEntry).not.toHaveProperty("modelOverride");
|
||||
}
|
||||
expect(result).toMatchObject({ reply: { text: "selected model status" } });
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,15 +1,22 @@
|
||||
// Handles native slash commands before full get-reply pipeline execution.
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import {
|
||||
resolveModelRefFromString,
|
||||
resolveThinkingDefaultWithRuntimeCatalog,
|
||||
type ModelAliasIndex,
|
||||
} from "../../agents/model-selection.js";
|
||||
import { loadPreparedModelCatalog } from "../../agents/prepared-model-catalog.js";
|
||||
import { resolveChannelModelOverride } from "../../channels/model-overrides.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { isModelSelectionLocked } from "../../sessions/model-overrides.js";
|
||||
import { recordSessionCreated } from "../../sessions/session-state-events.js";
|
||||
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
|
||||
import type { SkillCommandSpec } from "../../skills/types.js";
|
||||
import { isInternalMessageChannel } from "../../utils/message-channel.js";
|
||||
import {
|
||||
sessionDeliveryChannel,
|
||||
sessionDeliveryOrigin,
|
||||
} from "../../utils/delivery-context.shared.js";
|
||||
import { isInternalMessageChannel, normalizeMessageChannel } from "../../utils/message-channel.js";
|
||||
import {
|
||||
isAuthorizedTextSlashCommandTurn,
|
||||
isNativeCommandTurn,
|
||||
@@ -194,13 +201,60 @@ export async function maybeResolveNativeSlashCommandFastReply(params: {
|
||||
if (command.commandBodyNormalized === "/status") {
|
||||
const targetSessionEntry =
|
||||
sessionState.sessionStore[sessionState.sessionKey] ?? sessionState.sessionEntry;
|
||||
const canApplyChannelModel =
|
||||
params.cfg.channels?.modelByChannel &&
|
||||
!isModelSelectionLocked(targetSessionEntry) &&
|
||||
!normalizeOptionalString(targetSessionEntry?.modelOverride) &&
|
||||
!normalizeOptionalString(targetSessionEntry?.providerOverride) &&
|
||||
params.provider === params.defaultProvider &&
|
||||
params.model === params.defaultModel;
|
||||
const deliveryChannel = normalizeMessageChannel(sessionDeliveryChannel(targetSessionEntry));
|
||||
// Shared sessions can retain another channel's peer; never let that stale
|
||||
// identity outrank the authorized current command's live sender.
|
||||
const deliveryOrigin =
|
||||
deliveryChannel && deliveryChannel === normalizeMessageChannel(command.channel)
|
||||
? sessionDeliveryOrigin(targetSessionEntry)
|
||||
: undefined;
|
||||
const channelModelOverride = canApplyChannelModel
|
||||
? resolveChannelModelOverride({
|
||||
cfg: params.cfg,
|
||||
channel: command.channel,
|
||||
groupId: targetSessionEntry?.groupId,
|
||||
groupChatType: targetSessionEntry?.chatType ?? params.ctx.ChatType,
|
||||
groupChannel: targetSessionEntry?.groupChannel ?? params.ctx.GroupChannel,
|
||||
groupSubject: targetSessionEntry?.subject ?? params.ctx.GroupSubject,
|
||||
parentSessionKey:
|
||||
params.ctx.ModelParentSessionKey ??
|
||||
params.ctx.ParentSessionKey ??
|
||||
targetSessionEntry?.parentSessionKey,
|
||||
directUserIds: [
|
||||
deliveryOrigin?.nativeDirectUserId,
|
||||
deliveryOrigin?.from,
|
||||
deliveryOrigin?.to,
|
||||
params.ctx.OriginatingTo,
|
||||
params.ctx.From,
|
||||
params.ctx.SenderId,
|
||||
],
|
||||
})
|
||||
: null;
|
||||
const resolvedChannelModel = channelModelOverride
|
||||
? resolveModelRefFromString({
|
||||
raw: channelModelOverride.model,
|
||||
defaultProvider: params.defaultProvider,
|
||||
aliasIndex: params.aliasIndex,
|
||||
})
|
||||
: null;
|
||||
// Native status returns before normal channel routing; select once before
|
||||
// preparing model-bound thinking, runtime, auth, context, or fast-mode facts.
|
||||
const statusProvider = resolvedChannelModel?.ref.provider ?? params.provider;
|
||||
const statusModel = resolvedChannelModel?.ref.model ?? params.model;
|
||||
let resolvedDefaultThinkingLevel: ThinkLevel | undefined;
|
||||
const resolveDefaultThinkingLevel = async () => {
|
||||
resolvedDefaultThinkingLevel ??= await resolveNativeSlashDefaultThinkingLevel({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
provider: statusProvider,
|
||||
model: statusModel,
|
||||
agentDir: params.agentDir,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
@@ -227,8 +281,8 @@ export async function maybeResolveNativeSlashCommandFastReply(params: {
|
||||
parentSessionKey: targetSessionEntry?.parentSessionKey ?? params.ctx.ParentSessionKey,
|
||||
sessionScope: sessionState.sessionScope,
|
||||
storePath: sessionState.storePath,
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
provider: statusProvider,
|
||||
model: statusModel,
|
||||
workspaceDir: params.workspaceDir,
|
||||
thinkingCatalog,
|
||||
resolvedThinkLevel,
|
||||
|
||||
Reference in New Issue
Block a user