diff --git a/extensions/discord/src/session-key-normalization.ts b/extensions/discord/src/session-key-normalization.ts index f63524428c0..ef3169e190a 100644 --- a/extensions/discord/src/session-key-normalization.ts +++ b/extensions/discord/src/session-key-normalization.ts @@ -1,12 +1,29 @@ -import { normalizeChatType } from "openclaw/plugin-sdk/account-resolution"; -import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime"; +type DiscordSessionKeyContext = { + ChatType?: string; + From?: string; + SenderId?: string; +}; + +function normalizeDiscordChatType(raw?: string): "direct" | "group" | "channel" | undefined { + const normalized = (raw ?? "").trim().toLowerCase(); + if (!normalized) { + return undefined; + } + if (normalized === "dm") { + return "direct"; + } + if (normalized === "group" || normalized === "channel" || normalized === "direct") { + return normalized; + } + return undefined; +} export function normalizeExplicitDiscordSessionKey( sessionKey: string, - ctx: Pick, + ctx: DiscordSessionKeyContext, ): string { let normalized = sessionKey.trim().toLowerCase(); - if (normalizeChatType(ctx.ChatType) !== "direct") { + if (normalizeDiscordChatType(ctx.ChatType) !== "direct") { return normalized; } diff --git a/src/agents/context-cache.ts b/src/agents/context-cache.ts new file mode 100644 index 00000000000..a64ab018854 --- /dev/null +++ b/src/agents/context-cache.ts @@ -0,0 +1,8 @@ +export const MODEL_CONTEXT_TOKEN_CACHE = new Map(); + +export function lookupCachedContextTokens(modelId?: string): number | undefined { + if (!modelId) { + return undefined; + } + return MODEL_CONTEXT_TOKEN_CACHE.get(modelId); +} diff --git a/src/agents/context.ts b/src/agents/context.ts index 21a6edeb8a2..1579cbd3cc4 100644 --- a/src/agents/context.ts +++ b/src/agents/context.ts @@ -7,6 +7,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { computeBackoff, type BackoffPolicy } from "../infra/backoff.js"; import { consumeRootOptionToken, FLAG_TERMINATOR } from "../infra/cli-root-options.js"; import { resolveOpenClawAgentDir } from "./agent-paths.js"; +import { lookupCachedContextTokens, MODEL_CONTEXT_TOKEN_CACHE } from "./context-cache.js"; import { normalizeProviderId } from "./model-selection.js"; type ModelEntry = { id: string; contextWindow?: number }; @@ -78,7 +79,6 @@ export function applyConfiguredContextWindows(params: { } } -const MODEL_CACHE = new Map(); let loadPromise: Promise | null = null; let configuredConfig: OpenClawConfig | undefined; let configLoadFailures = 0; @@ -169,7 +169,7 @@ function primeConfiguredContextWindows(): OpenClawConfig | undefined { try { const cfg = loadConfig(); applyConfiguredContextWindows({ - cache: MODEL_CACHE, + cache: MODEL_CONTEXT_TOKEN_CACHE, modelsConfig: cfg.models as ModelsConfig | undefined, }); configuredConfig = cfg; @@ -213,7 +213,7 @@ function ensureContextWindowCacheLoaded(): Promise { ? modelRegistry.getAvailable() : modelRegistry.getAll(); applyDiscoveredContextWindows({ - cache: MODEL_CACHE, + cache: MODEL_CONTEXT_TOKEN_CACHE, models, }); } catch { @@ -221,7 +221,7 @@ function ensureContextWindowCacheLoaded(): Promise { } applyConfiguredContextWindows({ - cache: MODEL_CACHE, + cache: MODEL_CONTEXT_TOKEN_CACHE, modelsConfig: cfg.models as ModelsConfig | undefined, }); })().catch(() => { @@ -241,7 +241,7 @@ export function lookupContextTokens( if (options?.allowAsyncLoad !== false) { void ensureContextWindowCacheLoaded(); } - return MODEL_CACHE.get(modelId); + return lookupCachedContextTokens(modelId); } if (shouldEagerWarmContextWindowCache()) { diff --git a/src/agents/openclaw-tools.runtime.ts b/src/agents/openclaw-tools.runtime.ts new file mode 100644 index 00000000000..732d6cfd32a --- /dev/null +++ b/src/agents/openclaw-tools.runtime.ts @@ -0,0 +1 @@ +export { createOpenClawTools } from "./openclaw-tools.js"; diff --git a/src/auto-reply/command-detection.ts b/src/auto-reply/command-detection.ts index 5a295a82989..f186ad771ee 100644 --- a/src/auto-reply/command-detection.ts +++ b/src/auto-reply/command-detection.ts @@ -5,7 +5,7 @@ import { listChatCommandsForConfig, normalizeCommandBody, } from "./commands-registry.js"; -import { isAbortTrigger } from "./reply/abort.js"; +import { isAbortTrigger } from "./reply/abort-primitives.js"; export function hasControlCommand( text?: string, diff --git a/src/auto-reply/reply/abort-cutoff.runtime.ts b/src/auto-reply/reply/abort-cutoff.runtime.ts new file mode 100644 index 00000000000..3c02e74242c --- /dev/null +++ b/src/auto-reply/reply/abort-cutoff.runtime.ts @@ -0,0 +1,33 @@ +import { updateSessionStore } from "../../config/sessions/store.js"; +import type { SessionEntry } from "../../config/sessions/types.js"; +import { applyAbortCutoffToSessionEntry, hasAbortCutoff } from "./abort-cutoff.js"; + +export async function clearAbortCutoffInSessionRuntime(params: { + sessionEntry?: SessionEntry; + sessionStore?: Record; + sessionKey?: string; + storePath?: string; +}): Promise { + const { sessionEntry, sessionStore, sessionKey, storePath } = params; + if (!sessionEntry || !sessionStore || !sessionKey || !hasAbortCutoff(sessionEntry)) { + return false; + } + + applyAbortCutoffToSessionEntry(sessionEntry, undefined); + sessionEntry.updatedAt = Date.now(); + sessionStore[sessionKey] = sessionEntry; + + if (storePath) { + await updateSessionStore(storePath, (store) => { + const existing = store[sessionKey] ?? sessionEntry; + if (!existing) { + return; + } + applyAbortCutoffToSessionEntry(existing, undefined); + existing.updatedAt = Date.now(); + store[sessionKey] = existing; + }); + } + + return true; +} diff --git a/src/auto-reply/reply/abort-cutoff.ts b/src/auto-reply/reply/abort-cutoff.ts index 44fb8b04ca3..4bbc98d5a90 100644 --- a/src/auto-reply/reply/abort-cutoff.ts +++ b/src/auto-reply/reply/abort-cutoff.ts @@ -1,5 +1,4 @@ -import type { SessionEntry } from "../../config/sessions.js"; -import { updateSessionStore } from "../../config/sessions.js"; +import type { SessionEntry } from "../../config/sessions/types.js"; import type { MsgContext } from "../templating.js"; export type AbortCutoff = { @@ -51,36 +50,6 @@ export function applyAbortCutoffToSessionEntry( entry.abortCutoffTimestamp = cutoff?.timestamp; } -export async function clearAbortCutoffInSession(params: { - sessionEntry?: SessionEntry; - sessionStore?: Record; - sessionKey?: string; - storePath?: string; -}): Promise { - const { sessionEntry, sessionStore, sessionKey, storePath } = params; - if (!sessionEntry || !sessionStore || !sessionKey || !hasAbortCutoff(sessionEntry)) { - return false; - } - - applyAbortCutoffToSessionEntry(sessionEntry, undefined); - sessionEntry.updatedAt = Date.now(); - sessionStore[sessionKey] = sessionEntry; - - if (storePath) { - await updateSessionStore(storePath, (store) => { - const existing = store[sessionKey] ?? sessionEntry; - if (!existing) { - return; - } - applyAbortCutoffToSessionEntry(existing, undefined); - existing.updatedAt = Date.now(); - store[sessionKey] = existing; - }); - } - - return true; -} - function toNumericMessageSid(value: string | undefined): bigint | undefined { const trimmed = value?.trim(); if (!trimmed || !/^\d+$/.test(trimmed)) { diff --git a/src/auto-reply/reply/abort-primitives.ts b/src/auto-reply/reply/abort-primitives.ts new file mode 100644 index 00000000000..cdb4d0d507c --- /dev/null +++ b/src/auto-reply/reply/abort-primitives.ts @@ -0,0 +1,130 @@ +import { normalizeCommandBody, type CommandNormalizeOptions } from "../commands-registry.js"; + +const ABORT_TRIGGERS = new Set([ + "stop", + "esc", + "abort", + "wait", + "exit", + "interrupt", + "detente", + "deten", + "detén", + "arrete", + "arrête", + "停止", + "やめて", + "止めて", + "रुको", + "توقف", + "стоп", + "остановись", + "останови", + "остановить", + "прекрати", + "halt", + "anhalten", + "aufhören", + "hoer auf", + "stopp", + "pare", + "stop openclaw", + "openclaw stop", + "stop action", + "stop current action", + "stop run", + "stop current run", + "stop agent", + "stop the agent", + "stop don't do anything", + "stop dont do anything", + "stop do not do anything", + "stop doing anything", + "do not do that", + "please stop", + "stop please", +]); +const ABORT_MEMORY = new Map(); +const ABORT_MEMORY_MAX = 2000; +const TRAILING_ABORT_PUNCTUATION_RE = /[.!?…,,。;;::'"’”)\]}]+$/u; + +function normalizeAbortTriggerText(text: string): string { + return text + .trim() + .toLowerCase() + .replace(/[’`]/g, "'") + .replace(/\s+/g, " ") + .replace(TRAILING_ABORT_PUNCTUATION_RE, "") + .trim(); +} + +export function isAbortTrigger(text?: string): boolean { + if (!text) { + return false; + } + const normalized = normalizeAbortTriggerText(text); + return ABORT_TRIGGERS.has(normalized); +} + +export function isAbortRequestText(text?: string, options?: CommandNormalizeOptions): boolean { + if (!text) { + return false; + } + const normalized = normalizeCommandBody(text, options).trim(); + if (!normalized) { + return false; + } + const normalizedLower = normalized.toLowerCase(); + return ( + normalizedLower === "/stop" || + normalizeAbortTriggerText(normalizedLower) === "/stop" || + isAbortTrigger(normalizedLower) + ); +} + +export function getAbortMemory(key: string): boolean | undefined { + const normalized = key.trim(); + if (!normalized) { + return undefined; + } + return ABORT_MEMORY.get(normalized); +} + +function pruneAbortMemory(): void { + if (ABORT_MEMORY.size <= ABORT_MEMORY_MAX) { + return; + } + const excess = ABORT_MEMORY.size - ABORT_MEMORY_MAX; + let removed = 0; + for (const entryKey of ABORT_MEMORY.keys()) { + ABORT_MEMORY.delete(entryKey); + removed += 1; + if (removed >= excess) { + break; + } + } +} + +export function setAbortMemory(key: string, value: boolean): void { + const normalized = key.trim(); + if (!normalized) { + return; + } + if (!value) { + ABORT_MEMORY.delete(normalized); + return; + } + if (ABORT_MEMORY.has(normalized)) { + ABORT_MEMORY.delete(normalized); + } + ABORT_MEMORY.set(normalized, true); + pruneAbortMemory(); +} + +export function getAbortMemorySizeForTest(): number { + return ABORT_MEMORY.size; +} + +export function resetAbortMemoryForTest(): void { + ABORT_MEMORY.clear(); +} diff --git a/src/auto-reply/reply/abort.ts b/src/auto-reply/reply/abort.ts index 58ea5e59fa6..327c2c74334 100644 --- a/src/auto-reply/reply/abort.ts +++ b/src/auto-reply/reply/abort.ts @@ -20,147 +20,32 @@ import { import { logVerbose } from "../../globals.js"; import { parseAgentSessionKey } from "../../routing/session-key.js"; import { resolveCommandAuthorization } from "../command-auth.js"; -import { normalizeCommandBody, type CommandNormalizeOptions } from "../commands-registry.js"; import type { FinalizedMsgContext, MsgContext } from "../templating.js"; import { applyAbortCutoffToSessionEntry, resolveAbortCutoffFromContext, shouldPersistAbortCutoff, } from "./abort-cutoff.js"; +import { + getAbortMemory, + getAbortMemorySizeForTest, + isAbortRequestText, + isAbortTrigger, + resetAbortMemoryForTest, + setAbortMemory, +} from "./abort-primitives.js"; import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; import { clearSessionQueues } from "./queue.js"; export { resolveAbortCutoffFromContext, shouldSkipMessageByAbortCutoff } from "./abort-cutoff.js"; - -const ABORT_TRIGGERS = new Set([ - "stop", - "esc", - "abort", - "wait", - "exit", - "interrupt", - "detente", - "deten", - "detén", - "arrete", - "arrête", - "停止", - "やめて", - "止めて", - "रुको", - "توقف", - "стоп", - "остановись", - "останови", - "остановить", - "прекрати", - "halt", - "anhalten", - "aufhören", - "hoer auf", - "stopp", - "pare", - "stop openclaw", - "openclaw stop", - "stop action", - "stop current action", - "stop run", - "stop current run", - "stop agent", - "stop the agent", - "stop don't do anything", - "stop dont do anything", - "stop do not do anything", - "stop doing anything", - "do not do that", - "please stop", - "stop please", -]); -const ABORT_MEMORY = new Map(); -const ABORT_MEMORY_MAX = 2000; -const TRAILING_ABORT_PUNCTUATION_RE = /[.!?…,,。;;::'"’”)\]}]+$/u; - -function normalizeAbortTriggerText(text: string): string { - return text - .trim() - .toLowerCase() - .replace(/[’`]/g, "'") - .replace(/\s+/g, " ") - .replace(TRAILING_ABORT_PUNCTUATION_RE, "") - .trim(); -} - -export function isAbortTrigger(text?: string): boolean { - if (!text) { - return false; - } - const normalized = normalizeAbortTriggerText(text); - return ABORT_TRIGGERS.has(normalized); -} - -export function isAbortRequestText(text?: string, options?: CommandNormalizeOptions): boolean { - if (!text) { - return false; - } - const normalized = normalizeCommandBody(text, options).trim(); - if (!normalized) { - return false; - } - const normalizedLower = normalized.toLowerCase(); - return ( - normalizedLower === "/stop" || - normalizeAbortTriggerText(normalizedLower) === "/stop" || - isAbortTrigger(normalizedLower) - ); -} - -export function getAbortMemory(key: string): boolean | undefined { - const normalized = key.trim(); - if (!normalized) { - return undefined; - } - return ABORT_MEMORY.get(normalized); -} - -function pruneAbortMemory(): void { - if (ABORT_MEMORY.size <= ABORT_MEMORY_MAX) { - return; - } - const excess = ABORT_MEMORY.size - ABORT_MEMORY_MAX; - let removed = 0; - for (const entryKey of ABORT_MEMORY.keys()) { - ABORT_MEMORY.delete(entryKey); - removed += 1; - if (removed >= excess) { - break; - } - } -} - -export function setAbortMemory(key: string, value: boolean): void { - const normalized = key.trim(); - if (!normalized) { - return; - } - if (!value) { - ABORT_MEMORY.delete(normalized); - return; - } - // Refresh insertion order so active keys are less likely to be evicted. - if (ABORT_MEMORY.has(normalized)) { - ABORT_MEMORY.delete(normalized); - } - ABORT_MEMORY.set(normalized, true); - pruneAbortMemory(); -} - -export function getAbortMemorySizeForTest(): number { - return ABORT_MEMORY.size; -} - -export function resetAbortMemoryForTest(): void { - ABORT_MEMORY.clear(); -} +export { + getAbortMemory, + getAbortMemorySizeForTest, + isAbortRequestText, + isAbortTrigger, + resetAbortMemoryForTest, + setAbortMemory, +}; export function formatAbortReplyText(stoppedSubagents?: number): string { if (typeof stoppedSubagents !== "number" || stoppedSubagents <= 0) { diff --git a/src/auto-reply/reply/body.ts b/src/auto-reply/reply/body.ts index 23af7bbba9d..900c8148c30 100644 --- a/src/auto-reply/reply/body.ts +++ b/src/auto-reply/reply/body.ts @@ -1,6 +1,6 @@ import type { SessionEntry } from "../../config/sessions.js"; import { updateSessionStore } from "../../config/sessions.js"; -import { setAbortMemory } from "./abort.js"; +import { setAbortMemory } from "./abort-primitives.js"; export async function applySessionHints(params: { baseBody: string; diff --git a/src/auto-reply/reply/commands-core.runtime.ts b/src/auto-reply/reply/commands-core.runtime.ts new file mode 100644 index 00000000000..56d793b26f8 --- /dev/null +++ b/src/auto-reply/reply/commands-core.runtime.ts @@ -0,0 +1 @@ +export { emitResetCommandHooks } from "./commands-core.js"; diff --git a/src/auto-reply/reply/commands.runtime.ts b/src/auto-reply/reply/commands.runtime.ts new file mode 100644 index 00000000000..80c25c3f8e0 --- /dev/null +++ b/src/auto-reply/reply/commands.runtime.ts @@ -0,0 +1 @@ +export { buildStatusReply, handleCommands } from "./commands.js"; diff --git a/src/auto-reply/reply/directive-handling.auth-profile.ts b/src/auto-reply/reply/directive-handling.auth-profile.ts new file mode 100644 index 00000000000..0d03939ec80 --- /dev/null +++ b/src/auto-reply/reply/directive-handling.auth-profile.ts @@ -0,0 +1,27 @@ +import { ensureAuthProfileStore } from "../../agents/auth-profiles.js"; +import type { OpenClawConfig } from "../../config/config.js"; + +export function resolveProfileOverride(params: { + rawProfile?: string; + provider: string; + cfg: OpenClawConfig; + agentDir?: string; +}): { profileId?: string; error?: string } { + const raw = params.rawProfile?.trim(); + if (!raw) { + return {}; + } + const store = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + const profile = store.profiles[raw]; + if (!profile) { + return { error: `Auth profile "${raw}" not found.` }; + } + if (profile.provider !== params.provider) { + return { + error: `Auth profile "${raw}" is for ${profile.provider}, not ${params.provider}.`, + }; + } + return { profileId: raw }; +} diff --git a/src/auto-reply/reply/directive-handling.auth.ts b/src/auto-reply/reply/directive-handling.auth.ts index 604e7473ae8..c05fa218dab 100644 --- a/src/auto-reply/reply/directive-handling.auth.ts +++ b/src/auto-reply/reply/directive-handling.auth.ts @@ -217,27 +217,4 @@ export const formatAuthLabel = (auth: { label: string; source: string }) => { return `${auth.label} (${auth.source})`; }; -export const resolveProfileOverride = (params: { - rawProfile?: string; - provider: string; - cfg: OpenClawConfig; - agentDir?: string; -}): { profileId?: string; error?: string } => { - const raw = params.rawProfile?.trim(); - if (!raw) { - return {}; - } - const store = ensureAuthProfileStore(params.agentDir, { - allowKeychainPrompt: false, - }); - const profile = store.profiles[raw]; - if (!profile) { - return { error: `Auth profile "${raw}" not found.` }; - } - if (profile.provider !== params.provider) { - return { - error: `Auth profile "${raw}" is for ${profile.provider}, not ${params.provider}.`, - }; - } - return { profileId: raw }; -}; +export { resolveProfileOverride } from "./directive-handling.auth-profile.js"; diff --git a/src/auto-reply/reply/directive-handling.defaults.ts b/src/auto-reply/reply/directive-handling.defaults.ts new file mode 100644 index 00000000000..8cea7cbb8bf --- /dev/null +++ b/src/auto-reply/reply/directive-handling.defaults.ts @@ -0,0 +1,24 @@ +import { + buildModelAliasIndex, + type ModelAliasIndex, + resolveDefaultModelForAgent, +} from "../../agents/model-selection.js"; +import type { OpenClawConfig } from "../../config/config.js"; + +export function resolveDefaultModel(params: { cfg: OpenClawConfig; agentId?: string }): { + defaultProvider: string; + defaultModel: string; + aliasIndex: ModelAliasIndex; +} { + const mainModel = resolveDefaultModelForAgent({ + cfg: params.cfg, + agentId: params.agentId, + }); + const defaultProvider = mainModel.provider; + const defaultModel = mainModel.model; + const aliasIndex = buildModelAliasIndex({ + cfg: params.cfg, + defaultProvider, + }); + return { defaultProvider, defaultModel, aliasIndex }; +} diff --git a/src/auto-reply/reply/directive-handling.impl.ts b/src/auto-reply/reply/directive-handling.impl.ts index 423f54d6fb6..5fd0682ac93 100644 --- a/src/auto-reply/reply/directive-handling.impl.ts +++ b/src/auto-reply/reply/directive-handling.impl.ts @@ -13,10 +13,8 @@ import { applyVerboseOverride } from "../../sessions/level-overrides.js"; import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js"; import { formatThinkingLevels, formatXHighModelHint, supportsXHighThinking } from "../thinking.js"; import type { ReplyPayload } from "../types.js"; -import { - maybeHandleModelDirectiveInfo, - resolveModelSelectionFromDirective, -} from "./directive-handling.model.js"; +import { resolveModelSelectionFromDirective } from "./directive-handling.model-selection.js"; +import { maybeHandleModelDirectiveInfo } from "./directive-handling.model.js"; import type { HandleDirectiveOnlyParams } from "./directive-handling.params.js"; import { maybeHandleQueueDirective } from "./directive-handling.queue-validation.js"; import { diff --git a/src/auto-reply/reply/directive-handling.model-selection.ts b/src/auto-reply/reply/directive-handling.model-selection.ts new file mode 100644 index 00000000000..2657c4e84a7 --- /dev/null +++ b/src/auto-reply/reply/directive-handling.model-selection.ts @@ -0,0 +1,159 @@ +import { ensureAuthProfileStore } from "../../agents/auth-profiles.js"; +import { + type ModelAliasIndex, + modelKey, + normalizeProviderIdForAuth, + resolveModelRefFromString, +} from "../../agents/model-selection.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { resolveProfileOverride } from "./directive-handling.auth-profile.js"; +import type { InlineDirectives } from "./directive-handling.parse.js"; +import { type ModelDirectiveSelection, resolveModelDirectiveSelection } from "./model-selection.js"; + +function resolveStoredNumericProfileModelDirective(params: { raw: string; agentDir: string }): { + modelRaw: string; + profileId: string; + profileProvider: string; +} | null { + const trimmed = params.raw.trim(); + const lastSlash = trimmed.lastIndexOf("/"); + const profileDelimiter = trimmed.indexOf("@", lastSlash + 1); + if (profileDelimiter <= 0) { + return null; + } + + const profileId = trimmed.slice(profileDelimiter + 1).trim(); + if (!/^\d{8}$/.test(profileId)) { + return null; + } + + const modelRaw = trimmed.slice(0, profileDelimiter).trim(); + if (!modelRaw) { + return null; + } + + const store = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + const profile = store.profiles[profileId]; + if (!profile) { + return null; + } + + return { modelRaw, profileId, profileProvider: profile.provider }; +} + +export function resolveModelSelectionFromDirective(params: { + directives: InlineDirectives; + cfg: OpenClawConfig; + agentDir: string; + defaultProvider: string; + defaultModel: string; + aliasIndex: ModelAliasIndex; + allowedModelKeys: Set; + allowedModelCatalog: Array<{ provider: string; id?: string; name?: string }>; + provider: string; +}): { + modelSelection?: ModelDirectiveSelection; + profileOverride?: string; + errorText?: string; +} { + if (!params.directives.hasModelDirective || !params.directives.rawModelDirective) { + if (params.directives.rawModelProfile) { + return { errorText: "Auth profile override requires a model selection." }; + } + return {}; + } + + const raw = params.directives.rawModelDirective.trim(); + const storedNumericProfile = + params.directives.rawModelProfile === undefined + ? resolveStoredNumericProfileModelDirective({ + raw, + agentDir: params.agentDir, + }) + : null; + const storedNumericProfileSelection = storedNumericProfile + ? resolveModelDirectiveSelection({ + raw: storedNumericProfile.modelRaw, + defaultProvider: params.defaultProvider, + defaultModel: params.defaultModel, + aliasIndex: params.aliasIndex, + allowedModelKeys: params.allowedModelKeys, + }) + : null; + const useStoredNumericProfile = + Boolean(storedNumericProfileSelection?.selection) && + normalizeProviderIdForAuth(storedNumericProfileSelection?.selection?.provider ?? "") === + normalizeProviderIdForAuth(storedNumericProfile?.profileProvider ?? ""); + const modelRaw = + useStoredNumericProfile && storedNumericProfile ? storedNumericProfile.modelRaw : raw; + let modelSelection: ModelDirectiveSelection | undefined; + + if (/^[0-9]+$/.test(raw)) { + return { + errorText: [ + "Numeric model selection is not supported in chat.", + "", + "Browse: /models or /models ", + "Switch: /model ", + ].join("\n"), + }; + } + + const explicit = resolveModelRefFromString({ + raw: modelRaw, + defaultProvider: params.defaultProvider, + aliasIndex: params.aliasIndex, + }); + if (explicit) { + const explicitKey = modelKey(explicit.ref.provider, explicit.ref.model); + if (params.allowedModelKeys.size === 0 || params.allowedModelKeys.has(explicitKey)) { + modelSelection = { + provider: explicit.ref.provider, + model: explicit.ref.model, + isDefault: + explicit.ref.provider === params.defaultProvider && + explicit.ref.model === params.defaultModel, + ...(explicit.alias ? { alias: explicit.alias } : {}), + }; + } + } + + if (!modelSelection) { + const resolved = resolveModelDirectiveSelection({ + raw: modelRaw, + defaultProvider: params.defaultProvider, + defaultModel: params.defaultModel, + aliasIndex: params.aliasIndex, + allowedModelKeys: params.allowedModelKeys, + }); + + if (resolved.error) { + return { errorText: resolved.error }; + } + + if (resolved.selection) { + modelSelection = resolved.selection; + } + } + + let profileOverride: string | undefined; + const rawProfile = + params.directives.rawModelProfile ?? + (useStoredNumericProfile ? storedNumericProfile?.profileId : undefined); + if (modelSelection && rawProfile) { + const profileResolved = resolveProfileOverride({ + rawProfile, + provider: modelSelection.provider, + cfg: params.cfg, + agentDir: params.agentDir, + }); + if (profileResolved.error) { + return { errorText: profileResolved.error }; + } + profileOverride = profileResolved.profileId; + } + + return { modelSelection, profileOverride }; +} diff --git a/src/auto-reply/reply/directive-handling.persist.ts b/src/auto-reply/reply/directive-handling.persist.ts index c54fa7342a9..3c6815a0f62 100644 --- a/src/auto-reply/reply/directive-handling.persist.ts +++ b/src/auto-reply/reply/directive-handling.persist.ts @@ -3,20 +3,16 @@ import { resolveDefaultAgentId, resolveSessionAgentId, } from "../../agents/agent-scope.js"; -import { lookupContextTokens } from "../../agents/context.js"; +import { lookupCachedContextTokens } from "../../agents/context-cache.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; -import { - buildModelAliasIndex, - type ModelAliasIndex, - resolveDefaultModelForAgent, -} from "../../agents/model-selection.js"; +import type { ModelAliasIndex } from "../../agents/model-selection.js"; import type { OpenClawConfig } from "../../config/config.js"; import { updateSessionStore } from "../../config/sessions/store.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; import { applyVerboseOverride } from "../../sessions/level-overrides.js"; import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js"; -import { resolveModelSelectionFromDirective } from "./directive-handling.model.js"; +import { resolveModelSelectionFromDirective } from "./directive-handling.model-selection.js"; import type { InlineDirectives } from "./directive-handling.parse.js"; import { enqueueModeSwitchEvents } from "./directive-handling.shared.js"; import type { ElevatedLevel, ReasoningLevel } from "./directives.js"; @@ -203,24 +199,7 @@ export async function persistInlineDirectives(params: { return { provider, model, - contextTokens: agentCfg?.contextTokens ?? lookupContextTokens(model) ?? DEFAULT_CONTEXT_TOKENS, + contextTokens: + agentCfg?.contextTokens ?? lookupCachedContextTokens(model) ?? DEFAULT_CONTEXT_TOKENS, }; } - -export function resolveDefaultModel(params: { cfg: OpenClawConfig; agentId?: string }): { - defaultProvider: string; - defaultModel: string; - aliasIndex: ModelAliasIndex; -} { - const mainModel = resolveDefaultModelForAgent({ - cfg: params.cfg, - agentId: params.agentId, - }); - const defaultProvider = mainModel.provider; - const defaultModel = mainModel.model; - const aliasIndex = buildModelAliasIndex({ - cfg: params.cfg, - defaultProvider, - }); - return { defaultProvider, defaultModel, aliasIndex }; -} diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts index 92c1783bcc1..6c8d7160d38 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -2,5 +2,6 @@ export { applyInlineDirectivesFastLane } from "./directive-handling.fast-lane.js export * from "./directive-handling.impl.js"; export type { InlineDirectives } from "./directive-handling.parse.js"; export { isDirectiveOnly, parseInlineDirectives } from "./directive-handling.parse.js"; -export { persistInlineDirectives, resolveDefaultModel } from "./directive-handling.persist.js"; +export { persistInlineDirectives } from "./directive-handling.persist.js"; +export { resolveDefaultModel } from "./directive-handling.defaults.js"; export { formatDirectiveAck } from "./directive-handling.shared.js"; diff --git a/src/auto-reply/reply/get-reply-inline-actions.ts b/src/auto-reply/reply/get-reply-inline-actions.ts index 7d933779864..c9408d4632b 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.ts @@ -1,5 +1,4 @@ import { collectTextContentBlocks } from "../../agents/content-blocks.js"; -import { createOpenClawTools } from "../../agents/openclaw-tools.js"; import type { BlockReplyChunking } from "../../agents/pi-embedded-block-chunker.js"; import type { SkillCommandSpec } from "../../agents/skills.js"; import { applyOwnerOnlyToolPolicy } from "../../agents/tool-policy.js"; @@ -11,20 +10,18 @@ import { generateSecureToken } from "../../infra/secure-random.js"; import { resolveGatewayMessageChannel } from "../../utils/message-channel.js"; import { listReservedChatSlashCommandNames, - listSkillCommandsForWorkspace, resolveSkillCommandInvocation, -} from "../skill-commands.js"; +} from "../skill-commands-base.js"; import type { MsgContext, TemplateContext } from "../templating.js"; import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; import { - clearAbortCutoffInSession, readAbortCutoffFromSessionEntry, resolveAbortCutoffFromContext, shouldSkipMessageByAbortCutoff, } from "./abort-cutoff.js"; -import { getAbortMemory, isAbortRequestText } from "./abort.js"; -import { buildStatusReply, handleCommands } from "./commands.js"; +import { getAbortMemory, isAbortRequestText } from "./abort-primitives.js"; +import type { buildStatusReply, handleCommands } from "./commands.runtime.js"; import type { InlineDirectives } from "./directive-handling.parse.js"; import { isDirectiveOnly } from "./directive-handling.parse.js"; import type { createModelSelectionState } from "./model-selection.js"; @@ -191,7 +188,7 @@ export async function handleInlineActions(params: { shouldLoadSkillCommands && params.skillCommands ? params.skillCommands : shouldLoadSkillCommands - ? listSkillCommandsForWorkspace({ + ? (await import("../skill-commands.runtime.js")).listSkillCommandsForWorkspace({ workspaceDir, cfg, skillFilter, @@ -222,6 +219,7 @@ export async function handleInlineActions(params: { resolveGatewayMessageChannel(ctx.Provider) ?? undefined; + const { createOpenClawTools } = await import("../../agents/openclaw-tools.runtime.js"); const tools = createOpenClawTools({ agentSessionKey: sessionKey, agentChannel: channel, @@ -305,7 +303,9 @@ export async function handleInlineActions(params: { return { kind: "reply", reply: undefined }; } if (cutoff) { - await clearAbortCutoffInSession({ + await ( + await import("./abort-cutoff.runtime.js") + ).clearAbortCutoffInSessionRuntime({ sessionEntry, sessionStore, sessionKey, @@ -335,6 +335,7 @@ export async function handleInlineActions(params: { isGroup, }) && inlineStatusRequested; if (handleInlineStatus) { + const { buildStatusReply } = await import("./commands.runtime.js"); const inlineStatusReply = await buildStatusReply({ cfg, command, @@ -358,8 +359,9 @@ export async function handleInlineActions(params: { directives = { ...directives, hasStatusDirective: false }; } - const runCommands = (commandInput: typeof command) => - handleCommands({ + const runCommands = async (commandInput: typeof command) => { + const { handleCommands } = await import("./commands.runtime.js"); + return handleCommands({ // Pass sessionCtx so command handlers can mutate stripped body for same-turn continuation. ctx: sessionCtx, // Keep original finalized context in sync when command handlers need outer-dispatch side effects. @@ -397,6 +399,7 @@ export async function handleInlineActions(params: { skillCommands, typing, }); + }; if (inlineCommand) { const inlineCommandContext = { diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 34b8c86353f..f5e0b32e5c4 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -15,8 +15,7 @@ import { resolveCommandAuthorization } from "../command-auth.js"; import type { MsgContext } from "../templating.js"; import { SILENT_REPLY_TOKEN } from "../tokens.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; -import { emitResetCommandHooks, type ResetCommandAction } from "./commands-core.js"; -import { resolveDefaultModel } from "./directive-handling.persist.js"; +import { resolveDefaultModel } from "./directive-handling.defaults.js"; import { resolveReplyDirectives } from "./get-reply-directives.js"; import { handleInlineActions } from "./get-reply-inline-actions.js"; import { runPreparedReply } from "./get-reply-run.js"; @@ -31,6 +30,8 @@ function shouldLogCoreIngressTiming(): boolean { return process.env.OPENCLAW_DEBUG_INGRESS_TIMING === "1"; } +type ResetCommandAction = "new" | "reset"; + function mergeSkillFilters(channelFilter?: string[], agentFilter?: string[]): string[] | undefined { const normalize = (list?: string[]) => { if (!Array.isArray(list)) { @@ -355,6 +356,7 @@ export async function getReplyFromConfig( if (!resetMatch) { return; } + const { emitResetCommandHooks } = await import("./commands-core.runtime.js"); const action: ResetCommandAction = resetMatch[1] === "reset" ? "reset" : "new"; await emitResetCommandHooks({ action, diff --git a/src/auto-reply/reply/session-fork.runtime.ts b/src/auto-reply/reply/session-fork.runtime.ts new file mode 100644 index 00000000000..736a1a7792c --- /dev/null +++ b/src/auto-reply/reply/session-fork.runtime.ts @@ -0,0 +1,52 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent"; +import { resolveSessionFilePath } from "../../config/sessions/paths.js"; +import type { SessionEntry } from "../../config/sessions/types.js"; + +export function forkSessionFromParentRuntime(params: { + parentEntry: SessionEntry; + agentId: string; + sessionsDir: string; +}): { sessionId: string; sessionFile: string } | null { + const parentSessionFile = resolveSessionFilePath( + params.parentEntry.sessionId, + params.parentEntry, + { agentId: params.agentId, sessionsDir: params.sessionsDir }, + ); + if (!parentSessionFile || !fs.existsSync(parentSessionFile)) { + return null; + } + try { + const manager = SessionManager.open(parentSessionFile); + const leafId = manager.getLeafId(); + if (leafId) { + const sessionFile = manager.createBranchedSession(leafId) ?? manager.getSessionFile(); + const sessionId = manager.getSessionId(); + if (sessionFile && sessionId) { + return { sessionId, sessionFile }; + } + } + const sessionId = crypto.randomUUID(); + const timestamp = new Date().toISOString(); + const fileTimestamp = timestamp.replace(/[:.]/g, "-"); + const sessionFile = path.join(manager.getSessionDir(), `${fileTimestamp}_${sessionId}.jsonl`); + const header = { + type: "session", + version: CURRENT_SESSION_VERSION, + id: sessionId, + timestamp, + cwd: manager.getCwd(), + parentSession: parentSessionFile, + }; + fs.writeFileSync(sessionFile, `${JSON.stringify(header)}\n`, { + encoding: "utf-8", + mode: 0o600, + flag: "wx", + }); + return { sessionId, sessionFile }; + } catch { + return null; + } +} diff --git a/src/auto-reply/reply/session-fork.ts b/src/auto-reply/reply/session-fork.ts index 1a4389542fa..49d592970ef 100644 --- a/src/auto-reply/reply/session-fork.ts +++ b/src/auto-reply/reply/session-fork.ts @@ -1,9 +1,4 @@ -import crypto from "node:crypto"; -import fs from "node:fs"; -import path from "node:path"; -import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent"; import type { OpenClawConfig } from "../../config/config.js"; -import { resolveSessionFilePath } from "../../config/sessions/paths.js"; import type { SessionEntry } from "../../config/sessions/types.js"; /** @@ -21,44 +16,11 @@ export function resolveParentForkMaxTokens(cfg: OpenClawConfig): number { return DEFAULT_PARENT_FORK_MAX_TOKENS; } -export function forkSessionFromParent(params: { +export async function forkSessionFromParent(params: { parentEntry: SessionEntry; agentId: string; sessionsDir: string; -}): { sessionId: string; sessionFile: string } | null { - const parentSessionFile = resolveSessionFilePath( - params.parentEntry.sessionId, - params.parentEntry, - { agentId: params.agentId, sessionsDir: params.sessionsDir }, - ); - if (!parentSessionFile || !fs.existsSync(parentSessionFile)) { - return null; - } - try { - const manager = SessionManager.open(parentSessionFile); - const leafId = manager.getLeafId(); - if (leafId) { - const sessionFile = manager.createBranchedSession(leafId) ?? manager.getSessionFile(); - const sessionId = manager.getSessionId(); - if (sessionFile && sessionId) { - return { sessionId, sessionFile }; - } - } - const sessionId = crypto.randomUUID(); - const timestamp = new Date().toISOString(); - const fileTimestamp = timestamp.replace(/[:.]/g, "-"); - const sessionFile = path.join(manager.getSessionDir(), `${fileTimestamp}_${sessionId}.jsonl`); - const header = { - type: "session", - version: CURRENT_SESSION_VERSION, - id: sessionId, - timestamp, - cwd: manager.getCwd(), - parentSession: parentSessionFile, - }; - fs.writeFileSync(sessionFile, `${JSON.stringify(header)}\n`, "utf-8"); - return { sessionId, sessionFile }; - } catch { - return null; - } +}): Promise<{ sessionId: string; sessionFile: string } | null> { + const runtime = await import("./session-fork.runtime.js"); + return runtime.forkSessionFromParentRuntime(params); } diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 38748ebf0b3..70ee1cc60c5 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -501,7 +501,7 @@ export async function initSessionState(params: { `forking from parent session: parentKey=${parentSessionKey} → sessionKey=${sessionKey} ` + `parentTokens=${parentTokens}`, ); - const forked = forkSessionFromParent({ + const forked = await forkSessionFromParent({ parentEntry: sessionStore[parentSessionKey], agentId, sessionsDir: path.dirname(storePath), diff --git a/src/auto-reply/skill-commands-base.ts b/src/auto-reply/skill-commands-base.ts new file mode 100644 index 00000000000..66530c5e4ed --- /dev/null +++ b/src/auto-reply/skill-commands-base.ts @@ -0,0 +1,96 @@ +import type { SkillCommandSpec } from "../agents/skills.js"; +import { getChatCommands } from "./commands-registry.data.js"; + +export function listReservedChatSlashCommandNames(extraNames: string[] = []): Set { + const reserved = new Set(); + for (const command of getChatCommands()) { + if (command.nativeName) { + reserved.add(command.nativeName.toLowerCase()); + } + for (const alias of command.textAliases) { + const trimmed = alias.trim(); + if (!trimmed.startsWith("/")) { + continue; + } + reserved.add(trimmed.slice(1).toLowerCase()); + } + } + for (const name of extraNames) { + const trimmed = name.trim().toLowerCase(); + if (trimmed) { + reserved.add(trimmed); + } + } + return reserved; +} + +function normalizeSkillCommandLookup(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[\s_]+/g, "-"); +} + +function findSkillCommand( + skillCommands: SkillCommandSpec[], + rawName: string, +): SkillCommandSpec | undefined { + const trimmed = rawName.trim(); + if (!trimmed) { + return undefined; + } + const lowered = trimmed.toLowerCase(); + const normalized = normalizeSkillCommandLookup(trimmed); + return skillCommands.find((entry) => { + if (entry.name.toLowerCase() === lowered) { + return true; + } + if (entry.skillName.toLowerCase() === lowered) { + return true; + } + return ( + normalizeSkillCommandLookup(entry.name) === normalized || + normalizeSkillCommandLookup(entry.skillName) === normalized + ); + }); +} + +export function resolveSkillCommandInvocation(params: { + commandBodyNormalized: string; + skillCommands: SkillCommandSpec[]; +}): { command: SkillCommandSpec; args?: string } | null { + const trimmed = params.commandBodyNormalized.trim(); + if (!trimmed.startsWith("/")) { + return null; + } + const match = trimmed.match(/^\/([^\s]+)(?:\s+([\s\S]+))?$/); + if (!match) { + return null; + } + const commandName = match[1]?.trim().toLowerCase(); + if (!commandName) { + return null; + } + if (commandName === "skill") { + const remainder = match[2]?.trim(); + if (!remainder) { + return null; + } + const skillMatch = remainder.match(/^([^\s]+)(?:\s+([\s\S]+))?$/); + if (!skillMatch) { + return null; + } + const skillCommand = findSkillCommand(params.skillCommands, skillMatch[1] ?? ""); + if (!skillCommand) { + return null; + } + const args = skillMatch[2]?.trim(); + return { command: skillCommand, args: args || undefined }; + } + const command = params.skillCommands.find((entry) => entry.name.toLowerCase() === commandName); + if (!command) { + return null; + } + const args = match[2]?.trim(); + return { command, args: args || undefined }; +} diff --git a/src/auto-reply/skill-commands.runtime.ts b/src/auto-reply/skill-commands.runtime.ts index 212f74fd707..b7c13ffc44b 100644 --- a/src/auto-reply/skill-commands.runtime.ts +++ b/src/auto-reply/skill-commands.runtime.ts @@ -1,5 +1 @@ -export { - listReservedChatSlashCommandNames, - listSkillCommandsForWorkspace, - resolveSkillCommandInvocation, -} from "./skill-commands.js"; +export { listSkillCommandsForAgents, listSkillCommandsForWorkspace } from "./skill-commands.js"; diff --git a/src/auto-reply/skill-commands.ts b/src/auto-reply/skill-commands.ts index 4a184ecd3d2..6e2e84b87f0 100644 --- a/src/auto-reply/skill-commands.ts +++ b/src/auto-reply/skill-commands.ts @@ -8,30 +8,11 @@ import { buildWorkspaceSkillCommandSpecs, type SkillCommandSpec } from "../agent import type { OpenClawConfig } from "../config/config.js"; import { logVerbose } from "../globals.js"; import { getRemoteSkillEligibility } from "../infra/skills-remote.js"; -import { listChatCommands } from "./commands-registry.js"; - -export function listReservedChatSlashCommandNames(extraNames: string[] = []): Set { - const reserved = new Set(); - for (const command of listChatCommands()) { - if (command.nativeName) { - reserved.add(command.nativeName.toLowerCase()); - } - for (const alias of command.textAliases) { - const trimmed = alias.trim(); - if (!trimmed.startsWith("/")) { - continue; - } - reserved.add(trimmed.slice(1).toLowerCase()); - } - } - for (const name of extraNames) { - const trimmed = name.trim().toLowerCase(); - if (trimmed) { - reserved.add(trimmed); - } - } - return reserved; -} +import { listReservedChatSlashCommandNames } from "./skill-commands-base.js"; +export { + listReservedChatSlashCommandNames, + resolveSkillCommandInvocation, +} from "./skill-commands-base.js"; export function listSkillCommandsForWorkspace(params: { workspaceDir: string; @@ -131,74 +112,3 @@ export function listSkillCommandsForAgents(params: { export const __testing = { dedupeBySkillName, }; - -function normalizeSkillCommandLookup(value: string): string { - return value - .trim() - .toLowerCase() - .replace(/[\s_]+/g, "-"); -} - -function findSkillCommand( - skillCommands: SkillCommandSpec[], - rawName: string, -): SkillCommandSpec | undefined { - const trimmed = rawName.trim(); - if (!trimmed) { - return undefined; - } - const lowered = trimmed.toLowerCase(); - const normalized = normalizeSkillCommandLookup(trimmed); - return skillCommands.find((entry) => { - if (entry.name.toLowerCase() === lowered) { - return true; - } - if (entry.skillName.toLowerCase() === lowered) { - return true; - } - return ( - normalizeSkillCommandLookup(entry.name) === normalized || - normalizeSkillCommandLookup(entry.skillName) === normalized - ); - }); -} - -export function resolveSkillCommandInvocation(params: { - commandBodyNormalized: string; - skillCommands: SkillCommandSpec[]; -}): { command: SkillCommandSpec; args?: string } | null { - const trimmed = params.commandBodyNormalized.trim(); - if (!trimmed.startsWith("/")) { - return null; - } - const match = trimmed.match(/^\/([^\s]+)(?:\s+([\s\S]+))?$/); - if (!match) { - return null; - } - const commandName = match[1]?.trim().toLowerCase(); - if (!commandName) { - return null; - } - if (commandName === "skill") { - const remainder = match[2]?.trim(); - if (!remainder) { - return null; - } - const skillMatch = remainder.match(/^([^\s]+)(?:\s+([\s\S]+))?$/); - if (!skillMatch) { - return null; - } - const skillCommand = findSkillCommand(params.skillCommands, skillMatch[1] ?? ""); - if (!skillCommand) { - return null; - } - const args = skillMatch[2]?.trim(); - return { command: skillCommand, args: args || undefined }; - } - const command = params.skillCommands.find((entry) => entry.name.toLowerCase() === commandName); - if (!command) { - return null; - } - const args = match[2]?.trim(); - return { command, args: args || undefined }; -} diff --git a/src/gateway/chat-abort.ts b/src/gateway/chat-abort.ts index 4be479153f6..e74439f13df 100644 --- a/src/gateway/chat-abort.ts +++ b/src/gateway/chat-abort.ts @@ -1,4 +1,4 @@ -import { isAbortRequestText } from "../auto-reply/reply/abort.js"; +import { isAbortRequestText } from "../auto-reply/reply/abort-primitives.js"; export type ChatAbortControllerEntry = { controller: AbortController; diff --git a/src/infra/session-maintenance-warning.test.ts b/src/infra/session-maintenance-warning.test.ts index 4395a46df89..25adad09fb8 100644 --- a/src/infra/session-maintenance-warning.test.ts +++ b/src/infra/session-maintenance-warning.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ resolveSessionAgentId: vi.fn(() => "agent-from-key"), - resolveSessionDeliveryTarget: vi.fn(() => ({ + deliveryContextFromSession: vi.fn(() => ({ channel: "whatsapp", to: "+15550001", accountId: "acct-1", @@ -50,7 +50,7 @@ describe("deliverSessionMaintenanceWarning", () => { process.env.NODE_ENV = "development"; vi.resetModules(); mocks.resolveSessionAgentId.mockClear(); - mocks.resolveSessionDeliveryTarget.mockClear(); + mocks.deliveryContextFromSession.mockClear(); mocks.normalizeMessageChannel.mockClear(); mocks.isDeliverableMessageChannel.mockClear(); mocks.deliverOutboundPayloads.mockClear(); @@ -62,10 +62,10 @@ describe("deliverSessionMaintenanceWarning", () => { normalizeMessageChannel: mocks.normalizeMessageChannel, isDeliverableMessageChannel: mocks.isDeliverableMessageChannel, })); - vi.doMock("./outbound/targets.js", () => ({ - resolveSessionDeliveryTarget: mocks.resolveSessionDeliveryTarget, + vi.doMock("../utils/delivery-context.js", () => ({ + deliveryContextFromSession: mocks.deliveryContextFromSession, })); - vi.doMock("./outbound/deliver.js", () => ({ + vi.doMock("./outbound/deliver-runtime.js", () => ({ deliverOutboundPayloads: mocks.deliverOutboundPayloads, })); vi.doMock("./system-events.js", () => ({ @@ -112,7 +112,7 @@ describe("deliverSessionMaintenanceWarning", () => { }); it("falls back to a system event when the last target is not deliverable", async () => { - mocks.resolveSessionDeliveryTarget.mockReturnValueOnce({ + mocks.deliveryContextFromSession.mockReturnValueOnce({ channel: "debug", to: "+15550001", accountId: "acct-1", @@ -143,7 +143,7 @@ describe("deliverSessionMaintenanceWarning", () => { await deliverSessionMaintenanceWarning(createParams()); - expect(mocks.resolveSessionDeliveryTarget).not.toHaveBeenCalled(); + expect(mocks.deliveryContextFromSession).not.toHaveBeenCalled(); expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled(); expect(mocks.enqueueSystemEvent).not.toHaveBeenCalled(); }); diff --git a/src/infra/session-maintenance-warning.ts b/src/infra/session-maintenance-warning.ts index 9f53379b23c..048dfcd213b 100644 --- a/src/infra/session-maintenance-warning.ts +++ b/src/infra/session-maintenance-warning.ts @@ -2,9 +2,9 @@ import type { OpenClawConfig } from "../config/config.js"; import type { SessionMaintenanceWarning } from "../config/sessions/store-maintenance.js"; import type { SessionEntry } from "../config/sessions/types.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { deliveryContextFromSession } from "../utils/delivery-context.js"; import { isDeliverableMessageChannel, normalizeMessageChannel } from "../utils/message-channel.js"; import { buildOutboundSessionContext } from "./outbound/session-context.js"; -import { resolveSessionDeliveryTarget } from "./outbound/targets.js"; import { enqueueSystemEvent } from "./system-events.js"; type WarningParams = { @@ -73,6 +73,24 @@ function buildWarningText(warning: SessionMaintenanceWarning): string { ); } +function resolveWarningDeliveryTarget(entry: SessionEntry): { + channel?: string; + to?: string; + accountId?: string; + threadId?: string | number; +} { + const context = deliveryContextFromSession(entry); + const channel = context?.channel + ? (normalizeMessageChannel(context.channel) ?? context.channel) + : undefined; + return { + channel: channel && isDeliverableMessageChannel(channel) ? channel : undefined, + to: context?.to, + accountId: context?.accountId, + threadId: context?.threadId, + }; +} + export async function deliverSessionMaintenanceWarning(params: WarningParams): Promise { if (!shouldSendWarning()) { return; @@ -85,10 +103,7 @@ export async function deliverSessionMaintenanceWarning(params: WarningParams): P warnedContexts.set(params.sessionKey, contextKey); const text = buildWarningText(params.warning); - const target = resolveSessionDeliveryTarget({ - entry: params.entry, - requestedChannel: "last", - }); + const target = resolveWarningDeliveryTarget(params.entry); if (!target.channel || !target.to) { enqueueSystemEvent(text, { sessionKey: params.sessionKey });