diff --git a/CHANGELOG.md b/CHANGELOG.md index 352d9c591..e14fb7fc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.clawd.bot - Doctor: warn when gateway.mode is unset with configure/config guidance. - OpenCode Zen: route models to the Zen API shape per family so proxy endpoints are used. (#1416) - Browser: suppress Chrome restore prompts for managed profiles. (#1419) Thanks @jamesgroat. +- Models: inherit session model overrides in thread/topic sessions (Telegram topics, Slack/Discord threads). (#1376) - macOS: include Textual syntax highlighting resources in packaged app to prevent chat crashes. (#1362) - Cron: cap reminder context history to 10 messages and honor `contextMessages`. (#1103) Thanks @mkbehr. - Exec approvals: treat main as the default agent + migrate legacy default allowlists. (#1417) Thanks @czekaj. diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index d7aaa218f..1ddcbc913 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -706,6 +706,7 @@ export function createExecTool( : clampNumber(params.yieldMs ?? defaultBackgroundMs, defaultBackgroundMs, 10, 120_000) : null; const elevatedDefaults = defaults?.elevated; + const elevatedAllowed = Boolean(elevatedDefaults?.enabled && elevatedDefaults.allowed); const elevatedDefaultMode = elevatedDefaults?.defaultLevel === "full" ? "full" @@ -714,6 +715,7 @@ export function createExecTool( : elevatedDefaults?.defaultLevel === "on" ? "ask" : "off"; + const effectiveDefaultMode = elevatedAllowed ? elevatedDefaultMode : "off"; const elevatedMode = typeof params.elevated === "boolean" ? params.elevated @@ -721,7 +723,7 @@ export function createExecTool( ? "full" : "ask" : "off" - : elevatedDefaultMode; + : effectiveDefaultMode; const elevatedRequested = elevatedMode !== "off"; if (elevatedRequested) { if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) { diff --git a/src/auto-reply/reply/get-reply-directives.ts b/src/auto-reply/reply/get-reply-directives.ts index bbdf033cc..04782c9eb 100644 --- a/src/auto-reply/reply/get-reply-directives.ts +++ b/src/auto-reply/reply/get-reply-directives.ts @@ -379,6 +379,7 @@ export async function resolveReplyDirectives(params: { sessionEntry, sessionStore, sessionKey, + parentSessionKey: ctx.ParentSessionKey, storePath, defaultProvider, defaultModel, diff --git a/src/auto-reply/reply/model-selection.inherit-parent.test.ts b/src/auto-reply/reply/model-selection.inherit-parent.test.ts new file mode 100644 index 000000000..8a06f47a4 --- /dev/null +++ b/src/auto-reply/reply/model-selection.inherit-parent.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { ClawdbotConfig } from "../../config/config.js"; +import { createModelSelectionState } from "./model-selection.js"; + +vi.mock("../../agents/model-catalog.js", () => ({ + loadModelCatalog: vi.fn(async () => [ + { provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" }, + { provider: "openai", id: "gpt-4o", name: "GPT-4o" }, + { provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus 4.5" }, + ]), +})); + +const defaultProvider = "openai"; +const defaultModel = "gpt-4o-mini"; + +const makeEntry = (overrides: Record = {}) => ({ + sessionId: "session-id", + updatedAt: Date.now(), + ...overrides, +}); + +async function resolveState(params: { + cfg: ClawdbotConfig; + sessionEntry: ReturnType; + sessionStore: Record>; + sessionKey: string; + parentSessionKey?: string; +}) { + return createModelSelectionState({ + cfg: params.cfg, + agentCfg: params.cfg.agents?.defaults, + sessionEntry: params.sessionEntry, + sessionStore: params.sessionStore, + sessionKey: params.sessionKey, + parentSessionKey: params.parentSessionKey, + defaultProvider, + defaultModel, + provider: defaultProvider, + model: defaultModel, + hasModelDirective: false, + }); +} + +describe("createModelSelectionState parent inheritance", () => { + it("inherits parent override from explicit parentSessionKey", async () => { + const cfg = {} as ClawdbotConfig; + const parentKey = "agent:main:discord:channel:C1"; + const sessionKey = "agent:main:discord:channel:C1:thread:123"; + const parentEntry = makeEntry({ + providerOverride: "openai", + modelOverride: "gpt-4o", + }); + const sessionEntry = makeEntry(); + const sessionStore = { + [parentKey]: parentEntry, + [sessionKey]: sessionEntry, + }; + + const state = await resolveState({ + cfg, + sessionEntry, + sessionStore, + sessionKey, + parentSessionKey: parentKey, + }); + + expect(state.provider).toBe("openai"); + expect(state.model).toBe("gpt-4o"); + }); + + it("derives parent key from topic session suffix", async () => { + const cfg = {} as ClawdbotConfig; + const parentKey = "agent:main:telegram:group:123"; + const sessionKey = "agent:main:telegram:group:123:topic:99"; + const parentEntry = makeEntry({ + providerOverride: "openai", + modelOverride: "gpt-4o", + }); + const sessionEntry = makeEntry(); + const sessionStore = { + [parentKey]: parentEntry, + [sessionKey]: sessionEntry, + }; + + const state = await resolveState({ + cfg, + sessionEntry, + sessionStore, + sessionKey, + }); + + expect(state.provider).toBe("openai"); + expect(state.model).toBe("gpt-4o"); + }); + + it("prefers child override over parent", async () => { + const cfg = {} as ClawdbotConfig; + const parentKey = "agent:main:telegram:group:123"; + const sessionKey = "agent:main:telegram:group:123:topic:99"; + const parentEntry = makeEntry({ + providerOverride: "openai", + modelOverride: "gpt-4o", + }); + const sessionEntry = makeEntry({ + providerOverride: "anthropic", + modelOverride: "claude-opus-4-5", + }); + const sessionStore = { + [parentKey]: parentEntry, + [sessionKey]: sessionEntry, + }; + + const state = await resolveState({ + cfg, + sessionEntry, + sessionStore, + sessionKey, + }); + + expect(state.provider).toBe("anthropic"); + expect(state.model).toBe("claude-opus-4-5"); + }); + + it("ignores parent override when disallowed", async () => { + const cfg = { + agents: { + defaults: { + models: { + "openai/gpt-4o-mini": {}, + }, + }, + }, + } as ClawdbotConfig; + const parentKey = "agent:main:slack:channel:C1"; + const sessionKey = "agent:main:slack:channel:C1:thread:123"; + const parentEntry = makeEntry({ + providerOverride: "anthropic", + modelOverride: "claude-opus-4-5", + }); + const sessionEntry = makeEntry(); + const sessionStore = { + [parentKey]: parentEntry, + [sessionKey]: sessionEntry, + }; + + const state = await resolveState({ + cfg, + sessionEntry, + sessionStore, + sessionKey, + }); + + expect(state.provider).toBe(defaultProvider); + expect(state.model).toBe(defaultModel); + }); +}); diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index 7bee7c8bd..6d34d29c5 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -13,6 +13,7 @@ import type { ClawdbotConfig } from "../../config/config.js"; import { type SessionEntry, updateSessionStore } from "../../config/sessions.js"; import { clearSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js"; import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js"; +import { resolveThreadParentSessionKey } from "../../sessions/session-key-utils.js"; import type { ThinkLevel } from "./directives.js"; export type ModelDirectiveSelection = { @@ -78,6 +79,52 @@ function boundedLevenshteinDistance(a: string, b: string, maxDistance: number): return dist; } +type StoredModelOverride = { + provider?: string; + model: string; + source: "session" | "parent"; +}; + +function resolveModelOverrideFromEntry(entry?: SessionEntry): { + provider?: string; + model: string; +} | null { + const model = entry?.modelOverride?.trim(); + if (!model) return null; + const provider = entry?.providerOverride?.trim() || undefined; + return { provider, model }; +} + +function resolveParentSessionKeyCandidate(params: { + sessionKey?: string; + parentSessionKey?: string; +}): string | null { + const explicit = params.parentSessionKey?.trim(); + if (explicit && explicit !== params.sessionKey) return explicit; + const derived = resolveThreadParentSessionKey(params.sessionKey); + if (derived && derived !== params.sessionKey) return derived; + return null; +} + +function resolveStoredModelOverride(params: { + sessionEntry?: SessionEntry; + sessionStore?: Record; + sessionKey?: string; + parentSessionKey?: string; +}): StoredModelOverride | null { + const direct = resolveModelOverrideFromEntry(params.sessionEntry); + if (direct) return { ...direct, source: "session" }; + const parentKey = resolveParentSessionKeyCandidate({ + sessionKey: params.sessionKey, + parentSessionKey: params.parentSessionKey, + }); + if (!parentKey || !params.sessionStore) return null; + const parentEntry = params.sessionStore[parentKey]; + const parentOverride = resolveModelOverrideFromEntry(parentEntry); + if (!parentOverride) return null; + return { ...parentOverride, source: "parent" }; +} + function scoreFuzzyMatch(params: { provider: string; model: string; @@ -177,6 +224,7 @@ export async function createModelSelectionState(params: { sessionEntry?: SessionEntry; sessionStore?: Record; sessionKey?: string; + parentSessionKey?: string; storePath?: string; defaultProvider: string; defaultModel: string; @@ -190,6 +238,7 @@ export async function createModelSelectionState(params: { sessionEntry, sessionStore, sessionKey, + parentSessionKey, storePath, defaultProvider, defaultModel, @@ -199,7 +248,13 @@ export async function createModelSelectionState(params: { let model = params.model; const hasAllowlist = agentCfg?.models && Object.keys(agentCfg.models).length > 0; - const hasStoredOverride = Boolean(sessionEntry?.modelOverride || sessionEntry?.providerOverride); + const initialStoredOverride = resolveStoredModelOverride({ + sessionEntry, + sessionStore, + sessionKey, + parentSessionKey, + }); + const hasStoredOverride = Boolean(initialStoredOverride); const needsModelCatalog = params.hasModelDirective || hasAllowlist || hasStoredOverride; let allowedModelKeys = new Set(); @@ -242,14 +297,18 @@ export async function createModelSelectionState(params: { } } - const storedProviderOverride = sessionEntry?.providerOverride?.trim(); - const storedModelOverride = sessionEntry?.modelOverride?.trim(); - if (storedModelOverride) { - const candidateProvider = storedProviderOverride || defaultProvider; - const key = modelKey(candidateProvider, storedModelOverride); + const storedOverride = resolveStoredModelOverride({ + sessionEntry, + sessionStore, + sessionKey, + parentSessionKey, + }); + if (storedOverride?.model) { + const candidateProvider = storedOverride.provider || defaultProvider; + const key = modelKey(candidateProvider, storedOverride.model); if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) { provider = candidateProvider; - model = storedModelOverride; + model = storedOverride.model; } } diff --git a/src/sessions/session-key-utils.ts b/src/sessions/session-key-utils.ts index 2bc90591d..10baeb607 100644 --- a/src/sessions/session-key-utils.ts +++ b/src/sessions/session-key-utils.ts @@ -33,3 +33,21 @@ export function isAcpSessionKey(sessionKey: string | undefined | null): boolean const parsed = parseAgentSessionKey(raw); return Boolean((parsed?.rest ?? "").toLowerCase().startsWith("acp:")); } + +const THREAD_SESSION_MARKERS = [":thread:", ":topic:"]; + +export function resolveThreadParentSessionKey( + sessionKey: string | undefined | null, +): string | null { + const raw = (sessionKey ?? "").trim(); + if (!raw) return null; + const normalized = raw.toLowerCase(); + let idx = -1; + for (const marker of THREAD_SESSION_MARKERS) { + const candidate = normalized.lastIndexOf(marker); + if (candidate > idx) idx = candidate; + } + if (idx <= 0) return null; + const parent = raw.slice(0, idx).trim(); + return parent ? parent : null; +} diff --git a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts index 902662e33..4fea3521a 100644 --- a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts +++ b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts @@ -120,7 +120,6 @@ const getOnHandler = (event: string) => { }; const ORIGINAL_TZ = process.env.TZ; - describe("createTelegramBot", () => { beforeEach(() => { process.env.TZ = "UTC"; @@ -153,6 +152,11 @@ describe("createTelegramBot", () => { replySpy.mockReset(); loadConfig.mockReturnValue({ + agents: { + defaults: { + envelopeTimezone: "utc", + }, + }, identity: { name: "Bert" }, messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, channels: { @@ -195,6 +199,11 @@ describe("createTelegramBot", () => { replySpy.mockReset(); loadConfig.mockReturnValue({ + agents: { + defaults: { + envelopeTimezone: "utc", + }, + }, channels: { telegram: { groupPolicy: "open", diff --git a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts index 84541cf98..ab43c4269 100644 --- a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts +++ b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts @@ -129,6 +129,11 @@ describe("createTelegramBot", () => { process.env.TZ = "UTC"; resetInboundDedupe(); loadConfig.mockReturnValue({ + agents: { + defaults: { + envelopeTimezone: "utc", + }, + }, channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] }, }, diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 315147ddc..7833ded93 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -149,12 +149,16 @@ const getOnHandler = (event: string) => { }; const ORIGINAL_TZ = process.env.TZ; - describe("createTelegramBot", () => { beforeEach(() => { process.env.TZ = "UTC"; resetInboundDedupe(); loadConfig.mockReturnValue({ + agents: { + defaults: { + envelopeTimezone: "utc", + }, + }, channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] }, }, @@ -562,106 +566,104 @@ describe("createTelegramBot", () => { }); it("accepts group messages when mentionPatterns match (without @botUsername)", async () => { - const originalTz = process.env.TZ; - process.env.TZ = "UTC"; onSpy.mockReset(); const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); - try { - loadConfig.mockReturnValue({ - identity: { name: "Bert" }, - messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: true } }, - }, + loadConfig.mockReturnValue({ + agents: { + defaults: { + envelopeTimezone: "utc", }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: 7, type: "group", title: "Test Group" }, - text: "bert: introduce yourself", - date: 1736380800, - message_id: 1, - from: { id: 9, first_name: "Ada" }, + }, + identity: { name: "Bert" }, + messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: true } }, }, - me: { username: "clawdbot_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); + }, + }); - expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0][0]; - expectInboundContextContract(payload); - expect(payload.WasMentioned).toBe(true); - const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z")); - const timestampPattern = escapeRegExp(expectedTimestamp); - expect(payload.Body).toMatch( - new RegExp(`^\\[Telegram Test Group id:7 (\\+\\d+[smhd] )?${timestampPattern}\\]`), - ); - expect(payload.SenderName).toBe("Ada"); - expect(payload.SenderId).toBe("9"); - } finally { - process.env.TZ = originalTz; - } + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 7, type: "group", title: "Test Group" }, + text: "bert: introduce yourself", + date: 1736380800, + message_id: 1, + from: { id: 9, first_name: "Ada" }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expectInboundContextContract(payload); + expect(payload.WasMentioned).toBe(true); + const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z")); + const timestampPattern = escapeRegExp(expectedTimestamp); + expect(payload.Body).toMatch( + new RegExp(`^\\[Telegram Test Group id:7 (\\+\\d+[smhd] )?${timestampPattern}\\]`), + ); + expect(payload.SenderName).toBe("Ada"); + expect(payload.SenderId).toBe("9"); }); it("includes sender identity in group envelope headers", async () => { - const originalTz = process.env.TZ; - process.env.TZ = "UTC"; onSpy.mockReset(); const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); - try { - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: false } }, - }, + loadConfig.mockReturnValue({ + agents: { + defaults: { + envelopeTimezone: "utc", }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: 42, type: "group", title: "Ops" }, - text: "hello", - date: 1736380800, - message_id: 2, - from: { - id: 99, - first_name: "Ada", - last_name: "Lovelace", - username: "ada", - }, + }, + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, }, - me: { username: "clawdbot_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); + }, + }); - expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0][0]; - expectInboundContextContract(payload); - const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z")); - const timestampPattern = escapeRegExp(expectedTimestamp); - expect(payload.Body).toMatch( - new RegExp(`^\\[Telegram Ops id:42 (\\+\\d+[smhd] )?${timestampPattern}\\]`), - ); - expect(payload.SenderName).toBe("Ada Lovelace"); - expect(payload.SenderId).toBe("99"); - expect(payload.SenderUsername).toBe("ada"); - } finally { - process.env.TZ = originalTz; - } + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 42, type: "group", title: "Ops" }, + text: "hello", + date: 1736380800, + message_id: 2, + from: { + id: 99, + first_name: "Ada", + last_name: "Lovelace", + username: "ada", + }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expectInboundContextContract(payload); + const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z")); + const timestampPattern = escapeRegExp(expectedTimestamp); + expect(payload.Body).toMatch( + new RegExp(`^\\[Telegram Ops id:42 (\\+\\d+[smhd] )?${timestampPattern}\\]`), + ); + expect(payload.SenderName).toBe("Ada Lovelace"); + expect(payload.SenderId).toBe("99"); + expect(payload.SenderUsername).toBe("ada"); }); it("reacts to mention-gated group messages when ackReaction is enabled", async () => { diff --git a/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts b/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts index f40e4e3ab..8995d3ec8 100644 --- a/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts +++ b/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts @@ -298,6 +298,11 @@ describe("web auto-reply", () => { }; setLoadConfigMock(() => ({ + agents: { + defaults: { + envelopeTimezone: "utc", + }, + }, session: { store: store.storePath }, }));