From 097e66391f7c30c9982c3fed90d4a84b70de901f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 12 Jan 2026 07:31:20 +0000 Subject: [PATCH] fix(auto-reply): show config models in /model --- CHANGELOG.md | 3 +- src/auto-reply/reply.directive.test.ts | 52 ++++++++++++++++++++++ src/auto-reply/reply.ts | 13 +----- src/auto-reply/reply/directive-handling.ts | 31 +++++++++---- 4 files changed, 78 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b0526818..f5313350d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,7 +66,8 @@ - Config: expand `~` in `CLAWDBOT_CONFIG_PATH` and common path-like config fields (including `plugins.load.paths`); guard invalid `$include` paths. (#731) — thanks @pasogott. - Agents: stop pre-creating session transcripts so first user messages persist in JSONL history. - Agents: skip pre-compaction memory flush when the session workspace is read-only. -- Auto-reply: ignore inline `/status` directives unless the message is directive-only. +- Auto-reply: allow inline `/status` for allowlisted senders (stripped before the model); unauthorized senders see it as plain text. +- Auto-reply: include config-only allowlisted models in `/model` even when the catalog is partial. - Auto-reply: align `/think` default display with model reasoning defaults. (#751) — thanks @gabriel-trigo. - Auto-reply: flush block reply buffers on tool boundaries. (#750) — thanks @sebslight. - Auto-reply: allow sender fallback for command authorization when `SenderId` is empty (WhatsApp self-chat). (#755) — thanks @juanpablodlc. diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index 84b6c55d9..7c6502624 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -1551,6 +1551,58 @@ describe("directive behavior", () => { }); }); + it("merges config allowlist models even when catalog is present", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockReset(); + // Catalog present but missing custom providers: /model should still include + // allowlisted provider/model keys from config. + vi.mocked(loadModelCatalog).mockResolvedValueOnce([ + { + provider: "anthropic", + id: "claude-opus-4-5", + name: "Claude Opus 4.5", + }, + { provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 mini" }, + ]); + const storePath = path.join(home, "sessions.json"); + + const res = await getReplyFromConfig( + { Body: "/model list", From: "+1222", To: "+1222" }, + {}, + { + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-5" }, + workspace: path.join(home, "clawd"), + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-4.1-mini": {}, + "minimax/MiniMax-M2.1": { alias: "minimax" }, + }, + }, + }, + models: { + mode: "merge", + providers: { + minimax: { + baseUrl: "https://api.minimax.io/anthropic", + api: "anthropic-messages", + models: [{ id: "MiniMax-M2.1", name: "MiniMax M2.1" }], + }, + }, + }, + session: { store: storePath }, + }, + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("claude-opus-4-5 — anthropic"); + expect(text).toContain("gpt-4.1-mini — openai"); + expect(text).toContain("MiniMax-M2.1 — minimax"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("does not repeat missing auth labels on /model list", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockReset(); diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index ddb78cc18..96ce9f1cc 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -64,7 +64,6 @@ import { persistInlineDirectives, resolveDefaultModel, } from "./reply/directive-handling.js"; -import { extractStatusDirective } from "./reply/directives.js"; import { buildGroupIntro, defaultGroupActivation, @@ -491,15 +490,6 @@ export async function getReplyFromConfig( let parsedDirectives = parseInlineDirectives(commandSource, { modelAliases: configuredAliases, }); - const hasInlineStatus = - parsedDirectives.hasStatusDirective && - parsedDirectives.cleaned.trim().length > 0; - if (hasInlineStatus) { - parsedDirectives = { - ...parsedDirectives, - hasStatusDirective: false, - }; - } if ( isGroup && ctx.WasMentioned !== true && @@ -702,7 +692,8 @@ export async function getReplyFromConfig( : directives.rawModelDirective; if (!command.isAuthorizedSender) { - cleanedBody = extractStatusDirective(existingBody).cleaned; + // Treat slash tokens as plain text for unauthorized senders. + cleanedBody = existingBody; sessionCtx.Body = cleanedBody; sessionCtx.BodyStripped = cleanedBody; directives = { diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts index 163b0d7ee..d1e4f4e45 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -664,9 +664,24 @@ export async function handleDirectiveOnly(params: { defaultModel, }); const pickerCatalog: ModelPickerCatalogEntry[] = (() => { - if (allowedModelCatalog.length > 0) return allowedModelCatalog; const keys = new Set(); const out: ModelPickerCatalogEntry[] = []; + + const push = (entry: ModelPickerCatalogEntry) => { + const provider = normalizeProviderId(entry.provider); + const id = String(entry.id ?? "").trim(); + if (!provider || !id) return; + const key = modelKey(provider, id); + if (keys.has(key)) return; + keys.add(key); + out.push({ provider, id, name: entry.name }); + }; + + // Prefer catalog entries (when available), but always merge in config-only + // allowlist entries. This keeps custom providers/models visible in /model. + for (const entry of allowedModelCatalog) push(entry); + + // Merge any configured allowlist keys that the catalog doesn't know about. for (const raw of Object.keys( params.cfg.agents?.defaults?.models ?? {}, )) { @@ -676,24 +691,22 @@ export async function handleDirectiveOnly(params: { aliasIndex, }); if (!resolved) continue; - const key = modelKey(resolved.ref.provider, resolved.ref.model); - if (keys.has(key)) continue; - keys.add(key); - out.push({ + push({ provider: resolved.ref.provider, id: resolved.ref.model, name: resolved.ref.model, }); } - if (out.length === 0 && resolvedDefault.model) { - const key = modelKey(resolvedDefault.provider, resolvedDefault.model); - keys.add(key); - out.push({ + + // Ensure the configured default is always present (even when no allowlist). + if (resolvedDefault.model) { + push({ provider: resolvedDefault.provider, id: resolvedDefault.model, name: resolvedDefault.model, }); } + return out; })();