From 75c0269150221cff6b7e41a9aed2d3df7fcd1fbe Mon Sep 17 00:00:00 2001 From: asteroida123 <264808420+asteroida123@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:43:30 +0800 Subject: [PATCH 1/2] fix(chat): tell the sender when an @agent mention can't delegate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An @-mention only routes work to another agent when BOTH gates are open: multi-agent delegation is enabled in settings, and the target agent is enabled in Agents management. With a gate closed, delegate_to_agent was never injected into the host session, so the host model just answers the mention itself and the sender never learns why — @ looks silently broken (#545). Warn at send time instead. The composer scans the outgoing text blocks for codeg://agent/ routing URIs (free-standing @prose never triggers), then best-effort + fire-and-forget classifies the mentions against the delegation settings and the agent list (30s module-level cache). A blocked mention raises a toast with a deep link: Settings → General → Multi-Agent Collaboration when delegation is off, Settings → Agents (preselected on the agent) when the target is disabled. A lookup failure is swallowed — the hint must never block the send. Deep-linking General required threading a "general" section through open_settings_window: the desktop route resolver and the web handler both fell back to appearance; the TS SettingsSection union gains it too. i18n: 3 keys x 10 locales; entry names are lifted from each locale's own UI labels so the path in the hint matches what the user sees. Related to #545 — this covers only the "gate closed" failure mode; deterministic mention routing as proposed there is a larger design question left to that discussion. --- src-tauri/src/commands/windows.rs | 1 + src-tauri/src/web/handlers/folders.rs | 1 + .../chat/composer/agent-mention-hint.test.ts | 109 ++++++++++++++++++ .../chat/composer/agent-mention-hint.ts | 101 ++++++++++++++++ src/components/chat/message-input.tsx | 46 ++++++++ src/i18n/messages/ar.json | 3 + src/i18n/messages/de.json | 3 + src/i18n/messages/en.json | 3 + src/i18n/messages/es.json | 3 + src/i18n/messages/fr.json | 3 + src/i18n/messages/ja.json | 3 + src/i18n/messages/ko.json | 3 + src/i18n/messages/pt.json | 3 + src/i18n/messages/zh-CN.json | 3 + src/i18n/messages/zh-TW.json | 3 + src/lib/api.ts | 1 + 16 files changed, 289 insertions(+) create mode 100644 src/components/chat/composer/agent-mention-hint.test.ts create mode 100644 src/components/chat/composer/agent-mention-hint.ts diff --git a/src-tauri/src/commands/windows.rs b/src-tauri/src/commands/windows.rs index 7ea73da104..46beed450a 100644 --- a/src-tauri/src/commands/windows.rs +++ b/src-tauri/src/commands/windows.rs @@ -332,6 +332,7 @@ impl Default for CommitWindowState { fn resolve_settings_route(section: Option<&str>) -> &'static str { match section { Some("appearance") => "settings/appearance", + Some("general") => "settings/general", Some("agents") => "settings/agents", Some("mcp") => "settings/mcp", Some("skills") => "settings/skills", diff --git a/src-tauri/src/web/handlers/folders.rs b/src-tauri/src/web/handlers/folders.rs index f2da50999c..049f5b4d8a 100644 --- a/src-tauri/src/web/handlers/folders.rs +++ b/src-tauri/src/web/handlers/folders.rs @@ -314,6 +314,7 @@ pub async fn open_settings_window( ) -> Result, AppCommandError> { let route = match params.section.as_deref() { Some("appearance") => "settings/appearance", + Some("general") => "settings/general", Some("agents") => "settings/agents", Some("mcp") => "settings/mcp", Some("skills") => "settings/skills", diff --git a/src/components/chat/composer/agent-mention-hint.test.ts b/src/components/chat/composer/agent-mention-hint.test.ts new file mode 100644 index 0000000000..f057818966 --- /dev/null +++ b/src/components/chat/composer/agent-mention-hint.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +const { listAgentsMock, getSettingsMock } = vi.hoisted(() => ({ + listAgentsMock: vi.fn(), + getSettingsMock: vi.fn(), +})) + +vi.mock("@/lib/api", () => ({ + acpListAgents: (...args: unknown[]) => listAgentsMock(...args), + getDelegationSettings: (...args: unknown[]) => getSettingsMock(...args), +})) + +import { + extractMentionedAgentTypes, + mentionedAgentTypesFromBlocks, +} from "./agent-mention-hint" + +describe("extractMentionedAgentTypes", () => { + it("returns empty for text without agent mentions", () => { + expect( + extractMentionedAgentTypes("plain @text and [a link](file://x)") + ).toEqual([]) + }) + + it("does not fire on free-standing @label prose without a routing uri", () => { + expect(extractMentionedAgentTypes("ask @codex about it")).toEqual([]) + }) + + it("extracts the agent_type from a serialized mention", () => { + expect( + extractMentionedAgentTypes("请 [@Codex](codeg://agent/codex) 看看") + ).toEqual(["codex"]) + }) + + it("deduplicates repeat mentions and keeps order of first appearance", () => { + const text = [ + "[@Codex](codeg://agent/codex)", + "[@Antigravity](codeg://agent/antigravity)", + "[@Codex again](codeg://agent/codex)", + ].join(" then ") + expect(extractMentionedAgentTypes(text)).toEqual(["codex", "antigravity"]) + }) + + it("matches the wire form embedded in larger prose", () => { + expect( + extractMentionedAgentTypes( + "see [@Claude](codeg://agent/claude_code) and [@pi](codeg://agent/pi)" + ) + ).toEqual(["claude_code", "pi"]) + }) + + it("scans only text blocks when reading prompt blocks", () => { + expect( + mentionedAgentTypesFromBlocks([ + { type: "text", text: "check [@Codex](codeg://agent/codex)" }, + { type: "image", data: "codeg://agent/fake", mime_type: "image/png" }, + { type: "resource", uri: "codeg://agent/also-fake" }, + ]) + ).toEqual(["codex"]) + }) +}) + +describe("findBlockedAgentMentions", () => { + // Dynamic import per test: the module keeps a TTL cache in module state, and + // vi.resetModules() only affects imports resolved AFTER the reset — so each + // test needs its own fresh module instance to see its own mocked backend. + let findBlockedAgentMentions: typeof import("./agent-mention-hint").findBlockedAgentMentions + + beforeEach(async () => { + vi.resetModules() + listAgentsMock.mockResolvedValue([ + { agent_type: "codex", name: "Codex", enabled: true }, + { agent_type: "qoder", name: "Qoder", enabled: false }, + ]) + getSettingsMock.mockResolvedValue({ enabled: true }) + ;({ findBlockedAgentMentions } = await import("./agent-mention-hint")) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it("short-circuits without mentions and never calls the backend", async () => { + const blocked = await findBlockedAgentMentions([]) + expect(blocked).toEqual({ delegationOff: false, disabledAgents: [] }) + expect(getSettingsMock).not.toHaveBeenCalled() + expect(listAgentsMock).not.toHaveBeenCalled() + }) + + it("reports delegationOff alone when multi-agent delegation is disabled", async () => { + getSettingsMock.mockResolvedValue({ enabled: false }) + const blocked = await findBlockedAgentMentions(["qoder", "codex"]) + expect(blocked.delegationOff).toBe(true) + expect(blocked.disabledAgents).toEqual([]) + }) + + it("lists only the mentioned agents that are disabled", async () => { + const blocked = await findBlockedAgentMentions(["qoder", "codex"]) + expect(blocked).toEqual({ + delegationOff: false, + disabledAgents: [{ type: "qoder", label: "Qoder" }], + }) + }) + + it("returns nothing when every mentioned agent is enabled", async () => { + const blocked = await findBlockedAgentMentions(["codex"]) + expect(blocked).toEqual({ delegationOff: false, disabledAgents: [] }) + }) +}) diff --git a/src/components/chat/composer/agent-mention-hint.ts b/src/components/chat/composer/agent-mention-hint.ts new file mode 100644 index 0000000000..639de57b50 --- /dev/null +++ b/src/components/chat/composer/agent-mention-hint.ts @@ -0,0 +1,101 @@ +import { acpListAgents, getDelegationSettings } from "@/lib/api" + +import type { PromptInputBlock } from "@/lib/types" + +/** + * An `@` mention delegates work to another agent only when BOTH gates + * are open: multi-agent delegation is enabled in settings (the tool is + * injected per connection), and the target agent itself is enabled. When a + * gate is closed the host model silently answers the mention itself — to the + * sender `@` just looks broken (upstream issue #545). These helpers let the + * send path surface that as a hint at the exact moment it becomes true. + */ + +/** + * Agent mentions serialize as `[@label](codeg://agent/)` (see + * `reference-text.ts`), so we anchor on the routing URI — free-standing + * `@label` prose the user typed must not trigger the hint. Clean URIs stay + * unescaped in the destination; ids containing spaces/parens (rare custom + * agents) end up in the `<…>` form whose captures we don't attempt to parse. + */ +const AGENT_MENTION_URI = /codeg:\/\/agent\/([^)\s\\]+)/g + +export function extractMentionedAgentTypes(text: string): string[] { + const found = new Set() + for (const match of text.matchAll(AGENT_MENTION_URI)) { + if (match[1]) found.add(match[1]) + } + return [...found] +} + +/** Scan the SEND wire blocks — agent mentions live in the prose text block. */ +export function mentionedAgentTypesFromBlocks( + blocks: PromptInputBlock[] +): string[] { + const prose = blocks + .flatMap((block) => (block.type === "text" ? [block.text] : [])) + .join("\n") + return extractMentionedAgentTypes(prose) +} + +interface GateSnapshot { + delegationEnabled: boolean + /** `agent_type → display name` for every agent disabled in Agents 管理. */ + disabledAgents: Map +} + +const SNAPSHOT_TTL_MS = 30_000 +let cache: { at: number; value: Promise } | null = null + +function gateSnapshot(): Promise { + const now = Date.now() + if (cache && now - cache.at < SNAPSHOT_TTL_MS) return cache.value + const value = (async () => { + const [settings, agents] = await Promise.all([ + getDelegationSettings(), + acpListAgents(), + ]) + const disabledAgents = new Map() + for (const agent of agents) { + if (!agent.enabled) { + disabledAgents.set(agent.agent_type, agent.name || agent.agent_type) + } + } + return { delegationEnabled: settings.enabled, disabledAgents } + })() + cache = { at: now, value } + return value +} + +export interface BlockedAgentMentions { + /** Multi-agent delegation is off — no mention can delegate. */ + delegationOff: boolean + /** Delegation is on, but these mentioned agents are disabled in settings. */ + disabledAgents: Array<{ type: string; label: string }> +} + +/** + * Best-effort classification of the mentions in a sent draft. Throws only if + * the settings/agents lookup fails — callers are expected to swallow that + * (a missing hint must never break the send). + */ +export async function findBlockedAgentMentions( + mentionedTypes: string[] +): Promise { + if (mentionedTypes.length === 0) { + return { delegationOff: false, disabledAgents: [] } + } + const snapshot = await gateSnapshot() + if (!snapshot.delegationEnabled) { + return { delegationOff: true, disabledAgents: [] } + } + return { + delegationOff: false, + disabledAgents: mentionedTypes + .filter((type) => snapshot.disabledAgents.has(type)) + .map((type) => ({ + type, + label: snapshot.disabledAgents.get(type) ?? type, + })), + } +} diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 23abc40225..617cd81a8c 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -48,6 +48,11 @@ import { imageFilesFromClipboardApi } from "@/lib/clipboard-images" import { toErrorMessage } from "@/lib/app-error" import { isNoActiveTurnRejection } from "@/lib/turn-busy" import { ServerFileBrowserDialog } from "@/components/shared/server-file-browser-dialog" +import { + findBlockedAgentMentions, + mentionedAgentTypesFromBlocks, +} from "@/components/chat/composer/agent-mention-hint" +import { openSettingsWindow } from "@/lib/api" import { toast } from "sonner" import type { AgentSkillItem, @@ -1222,6 +1227,46 @@ export function MessageInput({ return } + // An @-mention only delegates when multi-agent delegation is on and the + // target agent is enabled; with a gate closed the host model otherwise + // answers the mention itself and the sender never learns why (upstream + // #545). Best-effort and fire-and-forget — a lookup failure must never + // block or break the send. + const mentionedTypes = mentionedAgentTypesFromBlocks(draft.blocks) + if (mentionedTypes.length > 0) { + void findBlockedAgentMentions(mentionedTypes) + .then((blocked) => { + if (blocked.delegationOff) { + toast.warning(t("mentionDelegateOffHint"), { + action: { + label: t("mentionHintOpenSettings"), + onClick: () => void openSettingsWindow("general"), + }, + }) + return + } + const firstDisabled = blocked.disabledAgents[0] + if (firstDisabled) { + const names = blocked.disabledAgents + .map((agent) => agent.label) + .join(", ") + toast.warning( + t("mentionDelegateDisabledAgentHint", { agent: names }), + { + action: { + label: t("mentionHintOpenSettings"), + onClick: () => + void openSettingsWindow("agents", { + agentType: firstDisabled.type, + }), + }, + } + ) + } + }) + .catch(() => {}) + } + // Prompting mode: enqueue instead of sending if (isPrompting && onEnqueue) { onEnqueue(draft, showModeSelector ? effectiveModeId : null) @@ -1238,6 +1283,7 @@ export function MessageInput({ disabled, hasUploadingImage, tAttach, + t, buildDraft, isEditingQueueItem, isPrompting, diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 7e2e02d290..b31fe17282 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2758,6 +2758,9 @@ "sendMessage": "أرسل رسالة..." }, "messageInput": { + "mentionDelegateOffHint": "لقد أشرت إلى وكيل آخر عبر @، لكن «تعاون متعدد الوكلاء» معطّل حاليًا، لذا لن يُفوَّض هذا الطلب فعليًا. يمكنك تفعيله من الإعدادات ← عام ← تعاون متعدد الوكلاء (يسري على الجلسات الجديدة).", + "mentionDelegateDisabledAgentHint": "لقد أشرت إلى {agent}: هذا الوكيل معطّل في الإعدادات ← الوكلاء، لذا لن يُفوَّض إليه الطلب. يمكنك إعادة تفعيله من هناك.", + "mentionHintOpenSettings": "فتح الإعدادات", "askAnything": "اسأل أي شيء...", "removeAttachmentAria": "إزالة {name}", "attachFiles": "إرفاق ملفات", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 5a21b00075..b119189529 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -2758,6 +2758,9 @@ "sendMessage": "Nachricht senden..." }, "messageInput": { + "mentionDelegateOffHint": "Du hast einen anderen Agenten mit @ erwähnt, aber die Multi-Agent-Zusammenarbeit ist derzeit deaktiviert – die Nachricht wird nicht wirklich delegiert. Aktiviere sie unter Einstellungen → Allgemein → Multi-Agent-Zusammenarbeit (wirkt für neue Sitzungen).", + "mentionDelegateDisabledAgentHint": "Du hast {agent} erwähnt: Dieser Agent ist unter Einstellungen → Agenten deaktiviert, daher wird die Nachricht nicht delegiert. Dort wieder aktivieren.", + "mentionHintOpenSettings": "Einstellungen öffnen", "askAnything": "Fragen Sie alles...", "removeAttachmentAria": "{name} entfernen", "attachFiles": "Dateien anhängen", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index cbeadcb02a..020debfb39 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2758,6 +2758,9 @@ "sendMessage": "Send a message..." }, "messageInput": { + "mentionDelegateOffHint": "You @-mentioned another agent, but Multi-Agent Collaboration is currently off, so this message won't actually be handed off. Enable it under Settings → General → Multi-Agent Collaboration (takes effect for new sessions).", + "mentionDelegateDisabledAgentHint": "You @-mentioned {agent}: that agent is disabled under Settings → Agents, so the message won't be delegated to it. Re-enable it there.", + "mentionHintOpenSettings": "Open Settings", "askAnything": "Ask anything...", "removeAttachmentAria": "Remove {name}", "attachFiles": "Attach files", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index e665c5e125..cea0ea226d 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2758,6 +2758,9 @@ "sendMessage": "Enviar un mensaje..." }, "messageInput": { + "mentionDelegateOffHint": "Has mencionado a otro agente con @, pero la Colaboración multiagente está desactivada, por lo que el mensaje no se delegará realmente. Actívala en Configuración → General → Colaboración multiagente (surte efecto en sesiones nuevas).", + "mentionDelegateDisabledAgentHint": "Has mencionado a {agent}: ese agente está desactivado en Configuración → Agentes, por lo que el mensaje no se le delegará. Reactívalo allí.", + "mentionHintOpenSettings": "Abrir Configuración", "askAnything": "Pregunta lo que sea...", "removeAttachmentAria": "Quitar {name}", "attachFiles": "Adjuntar archivos", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index abb2187e3b..7155a80752 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2758,6 +2758,9 @@ "sendMessage": "Envoyer un message..." }, "messageInput": { + "mentionDelegateOffHint": "Vous avez mentionné un autre agent avec @, mais la Collaboration multi-agent est actuellement désactivée : le message ne sera pas réellement délégué. Activez-la dans Paramètres → Général → Collaboration multi-agent (effectif pour les nouvelles sessions).", + "mentionDelegateDisabledAgentHint": "Vous avez mentionné {agent} : cet agent est désactivé dans Paramètres → Agents IA, le message ne lui sera pas délégué. Réactivez-le là.", + "mentionHintOpenSettings": "Ouvrir les paramètres", "askAnything": "Posez n'importe quelle question...", "removeAttachmentAria": "Retirer {name}", "attachFiles": "Joindre des fichiers", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index fe1a9dd9a2..011259a4d2 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2758,6 +2758,9 @@ "sendMessage": "メッセージを送信..." }, "messageInput": { + "mentionDelegateOffHint": "エージェントを @ でメンションしていますが、「マルチエージェント連携」が無効のため、メッセージは実際には委譲されません。設定 → 一般 → マルチエージェント連携 で有効にできます(新しいセッションから有効)。", + "mentionDelegateDisabledAgentHint": "{agent} を @ でメンションしていますが、このエージェントは 設定 → エージェント で無効になっているため委譲されません。そちらで再度有効にしてください。", + "mentionHintOpenSettings": "設定を開く", "askAnything": "何でも質問してください...", "removeAttachmentAria": "{name} を削除", "attachFiles": "ファイルを添付", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 1c006a6f6d..3f50819a85 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2758,6 +2758,9 @@ "sendMessage": "메시지 보내기..." }, "messageInput": { + "mentionDelegateOffHint": "에이전트를 @ 멘션했지만 다중 에이전트 협업이 꺼져 있어 메시지가 실제로 위임되지 않습니다. 설정 → 일반 → 다중 에이전트 협업에서 켤 수 있습니다(새 세션부터 적용).", + "mentionDelegateDisabledAgentHint": "{agent}을(를) @ 멘션했지만 해당 에이전트가 설정 → 에이전트에서 비활성화되어 위임되지 않습니다. 설정 → 에이전트에서 다시 활성화할 수 있습니다.", + "mentionHintOpenSettings": "설정 열기", "askAnything": "무엇이든 물어보세요...", "removeAttachmentAria": "{name} 제거", "attachFiles": "파일 첨부", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 086c801c01..047599db18 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2758,6 +2758,9 @@ "sendMessage": "Envie uma mensagem..." }, "messageInput": { + "mentionDelegateOffHint": "Você mencionou outro agente com @, mas a Colaboração multiagente está desativada, então a mensagem não será realmente delegada. Ative em Configurações → Geral → Colaboração multiagente (válido para novas sessões).", + "mentionDelegateDisabledAgentHint": "Você mencionou {agent}: esse agente está desativado em Configurações → Agentes, então a mensagem não será delegada a ele. Reative-o lá.", + "mentionHintOpenSettings": "Abrir configurações", "askAnything": "Pergunte qualquer coisa...", "removeAttachmentAria": "Remover {name}", "attachFiles": "Anexar arquivos", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 6a8e8e292a..bfc7f22ed9 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2758,6 +2758,9 @@ "sendMessage": "发送消息..." }, "messageInput": { + "mentionDelegateOffHint": "已 @ 其他智能体:当前「多智能体协同」未开启,消息不会真正委托给对方。可在 设置 → 常规 → 多智能体协同 中开启(新会话生效)。", + "mentionDelegateDisabledAgentHint": "已 @ {agent}:该智能体已在 设置 → 智能体 中禁用,消息不会委托给它。可前往重新启用。", + "mentionHintOpenSettings": "前往设置", "askAnything": "请开始输入...", "removeAttachmentAria": "移除 {name}", "attachFiles": "附加文件", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 7dec729e77..8665b16f2a 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2758,6 +2758,9 @@ "sendMessage": "傳送訊息..." }, "messageInput": { + "mentionDelegateOffHint": "已 @ 其他智慧體:目前「多智慧體協同」未開啟,訊息不會真正委派給對方。可在 設定 → 一般 → 多智慧體協同 中開啟(新會話生效)。", + "mentionDelegateDisabledAgentHint": "已 @ {agent}:該智慧體已在 設定 → 智能體 中停用,訊息不會委派給它。可前往重新啟用。", + "mentionHintOpenSettings": "前往設定", "askAnything": "請開始輸入...", "removeAttachmentAria": "移除 {name}", "attachFiles": "附加檔案", diff --git a/src/lib/api.ts b/src/lib/api.ts index 57fa6f9e21..87d36e8b70 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -2787,6 +2787,7 @@ export async function openCommitWindow(folderId: number): Promise { export type SettingsSection = | "appearance" + | "general" | "agents" | "mcp" | "skills" From e10bca5a6116e564b2eaedcf0d2b4b0f3cfcaabf Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sat, 29 Aug 2026 07:35:56 +0800 Subject: [PATCH 2/2] fix(chat): read the mention gates from the shared agent registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The send-time hint fetched `acp_list_agents` itself behind a private 30s TTL cache. Two problems: that call probes npm prefixes and shells out for binary versions (the app deliberately coalesces it behind ONE ref-counted store), and the TTL could not be invalidated — so the flow the hint exists for (warn → open settings → enable → send again) re-nagged with stale gates for up to 30 seconds after the user fixed exactly what it asked them to. Read the registry snapshot from that shared store instead, and re-read the delegation toggle per call (a handful of app_metadata reads, fire-and-forget off the send path). `acpAgentsSnapshot()` reads the store without subscribing, so the composer gains no re-render on registry reloads. Also: one stable sonner id for both branches, so a user who keeps delegation off replaces the warning per send instead of stacking identical toasts; and the disabled-agent hint now carries the same "new sessions only" caveat the delegation-off hint already had — `--disabled-agents` is computed at injection time too, so re-enabling an agent does not reach a live session. Co-Authored-By: Claude Opus 5 (1M context) --- .../chat/composer/agent-mention-hint.test.ts | 61 ++++++++------- .../chat/composer/agent-mention-hint.ts | 74 ++++++++----------- src/components/chat/message-input.tsx | 11 ++- src/hooks/use-acp-agents.ts | 12 +++ src/i18n/messages/ar.json | 2 +- src/i18n/messages/de.json | 2 +- src/i18n/messages/en.json | 2 +- src/i18n/messages/es.json | 2 +- src/i18n/messages/fr.json | 2 +- src/i18n/messages/ja.json | 2 +- src/i18n/messages/ko.json | 2 +- src/i18n/messages/pt.json | 2 +- src/i18n/messages/zh-CN.json | 2 +- src/i18n/messages/zh-TW.json | 2 +- 14 files changed, 100 insertions(+), 78 deletions(-) diff --git a/src/components/chat/composer/agent-mention-hint.test.ts b/src/components/chat/composer/agent-mention-hint.test.ts index f057818966..a0208a5be5 100644 --- a/src/components/chat/composer/agent-mention-hint.test.ts +++ b/src/components/chat/composer/agent-mention-hint.test.ts @@ -1,17 +1,18 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { beforeEach, describe, expect, it, vi } from "vitest" -const { listAgentsMock, getSettingsMock } = vi.hoisted(() => ({ - listAgentsMock: vi.fn(), +const { getSettingsMock } = vi.hoisted(() => ({ getSettingsMock: vi.fn(), })) vi.mock("@/lib/api", () => ({ - acpListAgents: (...args: unknown[]) => listAgentsMock(...args), getDelegationSettings: (...args: unknown[]) => getSettingsMock(...args), })) +import type { AcpAgentInfo } from "@/lib/types" + import { extractMentionedAgentTypes, + findBlockedAgentMentions, mentionedAgentTypesFromBlocks, } from "./agent-mention-hint" @@ -61,41 +62,33 @@ describe("extractMentionedAgentTypes", () => { }) describe("findBlockedAgentMentions", () => { - // Dynamic import per test: the module keeps a TTL cache in module state, and - // vi.resetModules() only affects imports resolved AFTER the reset — so each - // test needs its own fresh module instance to see its own mocked backend. - let findBlockedAgentMentions: typeof import("./agent-mention-hint").findBlockedAgentMentions - - beforeEach(async () => { - vi.resetModules() - listAgentsMock.mockResolvedValue([ - { agent_type: "codex", name: "Codex", enabled: true }, - { agent_type: "qoder", name: "Qoder", enabled: false }, - ]) - getSettingsMock.mockResolvedValue({ enabled: true }) - ;({ findBlockedAgentMentions } = await import("./agent-mention-hint")) - }) - - afterEach(() => { + // Only the shape the classifier reads; the registry snapshot is supplied by + // the caller (`useAcpAgents`), never fetched here. + const AGENTS = [ + { agent_type: "codex", name: "Codex", enabled: true }, + { agent_type: "qoder", name: "Qoder", enabled: false }, + ] as unknown as AcpAgentInfo[] + + beforeEach(() => { vi.clearAllMocks() + getSettingsMock.mockResolvedValue({ enabled: true }) }) it("short-circuits without mentions and never calls the backend", async () => { - const blocked = await findBlockedAgentMentions([]) + const blocked = await findBlockedAgentMentions([], AGENTS) expect(blocked).toEqual({ delegationOff: false, disabledAgents: [] }) expect(getSettingsMock).not.toHaveBeenCalled() - expect(listAgentsMock).not.toHaveBeenCalled() }) it("reports delegationOff alone when multi-agent delegation is disabled", async () => { getSettingsMock.mockResolvedValue({ enabled: false }) - const blocked = await findBlockedAgentMentions(["qoder", "codex"]) + const blocked = await findBlockedAgentMentions(["qoder", "codex"], AGENTS) expect(blocked.delegationOff).toBe(true) expect(blocked.disabledAgents).toEqual([]) }) it("lists only the mentioned agents that are disabled", async () => { - const blocked = await findBlockedAgentMentions(["qoder", "codex"]) + const blocked = await findBlockedAgentMentions(["qoder", "codex"], AGENTS) expect(blocked).toEqual({ delegationOff: false, disabledAgents: [{ type: "qoder", label: "Qoder" }], @@ -103,7 +96,25 @@ describe("findBlockedAgentMentions", () => { }) it("returns nothing when every mentioned agent is enabled", async () => { - const blocked = await findBlockedAgentMentions(["codex"]) + const blocked = await findBlockedAgentMentions(["codex"], AGENTS) + expect(blocked).toEqual({ delegationOff: false, disabledAgents: [] }) + }) + + it("re-reads the delegation toggle on every call, so flipping it on stops the hint immediately", async () => { + getSettingsMock.mockResolvedValueOnce({ enabled: false }) + expect( + (await findBlockedAgentMentions(["codex"], AGENTS)).delegationOff + ).toBe(true) + // The user opened the deep link and turned the switch on. The very next + // send must not repeat the warning — no TTL window may hide the new value. + expect( + (await findBlockedAgentMentions(["codex"], AGENTS)).delegationOff + ).toBe(false) + expect(getSettingsMock).toHaveBeenCalledTimes(2) + }) + + it("reports nothing disabled against a cold/empty registry snapshot", async () => { + const blocked = await findBlockedAgentMentions(["qoder"], []) expect(blocked).toEqual({ delegationOff: false, disabledAgents: [] }) }) }) diff --git a/src/components/chat/composer/agent-mention-hint.ts b/src/components/chat/composer/agent-mention-hint.ts index 639de57b50..7841c40096 100644 --- a/src/components/chat/composer/agent-mention-hint.ts +++ b/src/components/chat/composer/agent-mention-hint.ts @@ -1,6 +1,6 @@ -import { acpListAgents, getDelegationSettings } from "@/lib/api" +import { getDelegationSettings } from "@/lib/api" -import type { PromptInputBlock } from "@/lib/types" +import type { AcpAgentInfo, PromptInputBlock } from "@/lib/types" /** * An `@` mention delegates work to another agent only when BOTH gates @@ -38,35 +38,6 @@ export function mentionedAgentTypesFromBlocks( return extractMentionedAgentTypes(prose) } -interface GateSnapshot { - delegationEnabled: boolean - /** `agent_type → display name` for every agent disabled in Agents 管理. */ - disabledAgents: Map -} - -const SNAPSHOT_TTL_MS = 30_000 -let cache: { at: number; value: Promise } | null = null - -function gateSnapshot(): Promise { - const now = Date.now() - if (cache && now - cache.at < SNAPSHOT_TTL_MS) return cache.value - const value = (async () => { - const [settings, agents] = await Promise.all([ - getDelegationSettings(), - acpListAgents(), - ]) - const disabledAgents = new Map() - for (const agent of agents) { - if (!agent.enabled) { - disabledAgents.set(agent.agent_type, agent.name || agent.agent_type) - } - } - return { delegationEnabled: settings.enabled, disabledAgents } - })() - cache = { at: now, value } - return value -} - export interface BlockedAgentMentions { /** Multi-agent delegation is off — no mention can delegate. */ delegationOff: boolean @@ -75,27 +46,46 @@ export interface BlockedAgentMentions { } /** - * Best-effort classification of the mentions in a sent draft. Throws only if - * the settings/agents lookup fails — callers are expected to swallow that - * (a missing hint must never break the send). + * Best-effort classification of the mentions in a sent draft. + * + * `agents` is the caller's already-subscribed registry snapshot + * (`useAcpAgents`) rather than a fetch of our own: `acp_list_agents` probes npm + * prefixes and shells out for binary versions, and the app deliberately + * coalesces it behind ONE ref-counted store that reloads on window focus, + * `app://acp-agents-updated` and transport reconnect. Reading that store keeps + * this free AND fresh — a private TTL cache would re-nag with stale gates for + * the whole TTL right after the user flipped the toggle the hint sent them to. + * An empty/cold list simply reports nothing disabled, which is the fail-safe + * direction (a missed hint, never a false one). + * + * The delegation toggle IS read per call: it is a handful of `app_metadata` + * reads, it has no change event to invalidate against, and this runs + * fire-and-forget off the send path. Throws only if that lookup fails — + * callers are expected to swallow that (a missing hint must never break the + * send). */ export async function findBlockedAgentMentions( - mentionedTypes: string[] + mentionedTypes: string[], + agents: AcpAgentInfo[] ): Promise { if (mentionedTypes.length === 0) { return { delegationOff: false, disabledAgents: [] } } - const snapshot = await gateSnapshot() - if (!snapshot.delegationEnabled) { + const settings = await getDelegationSettings() + if (!settings.enabled) { return { delegationOff: true, disabledAgents: [] } } + /** `agent_type → display name` for every agent disabled in Agents 管理. */ + const disabled = new Map() + for (const agent of agents) { + if (!agent.enabled) { + disabled.set(agent.agent_type, agent.name || agent.agent_type) + } + } return { delegationOff: false, disabledAgents: mentionedTypes - .filter((type) => snapshot.disabledAgents.has(type)) - .map((type) => ({ - type, - label: snapshot.disabledAgents.get(type) ?? type, - })), + .filter((type) => disabled.has(type)) + .map((type) => ({ type, label: disabled.get(type) ?? type })), } } diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 617cd81a8c..a1908dbaf9 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -97,6 +97,7 @@ import { MODEL_LIST_VIRTUALIZE_THRESHOLD, type ModelOptionGroup, } from "@/lib/model-config-groups" +import { acpAgentsSnapshot } from "@/hooks/use-acp-agents" import { useAgentSkills } from "@/hooks/use-agent-skills" import { useScrollbarSafeDismiss } from "@/hooks/use-scrollbar-safe-dismiss" import { @@ -160,6 +161,9 @@ export interface ComposerInjectContent { mode?: "replace" | "append" } +/** Shared sonner id for the "@-mention can't delegate" warning (see `handleSend`). */ +const MENTION_DELEGATION_HINT_TOAST_ID = "agent-mention-delegation-hint" + interface MessageInputProps { onSend: (draft: PromptDraft, modeId?: string | null) => void placeholder?: string @@ -1234,10 +1238,14 @@ export function MessageInput({ // block or break the send. const mentionedTypes = mentionedAgentTypesFromBlocks(draft.blocks) if (mentionedTypes.length > 0) { - void findBlockedAgentMentions(mentionedTypes) + void findBlockedAgentMentions(mentionedTypes, acpAgentsSnapshot()) .then((blocked) => { + // One stable id for both branches: they are mutually exclusive, and a + // user who keeps delegation off would otherwise stack an identical + // warning per send. Same id → sonner replaces rather than piles up. if (blocked.delegationOff) { toast.warning(t("mentionDelegateOffHint"), { + id: MENTION_DELEGATION_HINT_TOAST_ID, action: { label: t("mentionHintOpenSettings"), onClick: () => void openSettingsWindow("general"), @@ -1253,6 +1261,7 @@ export function MessageInput({ toast.warning( t("mentionDelegateDisabledAgentHint", { agent: names }), { + id: MENTION_DELEGATION_HINT_TOAST_ID, action: { label: t("mentionHintOpenSettings"), onClick: () => diff --git a/src/hooks/use-acp-agents.ts b/src/hooks/use-acp-agents.ts index a15b01ba63..19bcb9b120 100644 --- a/src/hooks/use-acp-agents.ts +++ b/src/hooks/use-acp-agents.ts @@ -205,6 +205,18 @@ export function useAcpAgents(): UseAcpAgentsResult { return { agents, fresh, refresh } } +/** + * Read the shared registry WITHOUT subscribing — for event handlers (a send, a + * menu action) that need the current list at the moment they run and must not + * re-render their host on every reload. In the running app TabProvider + + * SidebarConversationList hold the refcount ≥ 1 for the whole session, so this + * is warm; a cold store answers `[]`, which callers must read as "registry + * unknown" rather than "no agents exist". + */ +export function acpAgentsSnapshot(): AcpAgentInfo[] { + return useAcpAgentsStore.getState().agents +} + /** Test-only: reset the shared store + module race/refcount state to a clean * slate (disposing any live subscription). */ export function resetAcpAgentsStore(): void { diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index b31fe17282..e806bb4a14 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2759,7 +2759,7 @@ }, "messageInput": { "mentionDelegateOffHint": "لقد أشرت إلى وكيل آخر عبر @، لكن «تعاون متعدد الوكلاء» معطّل حاليًا، لذا لن يُفوَّض هذا الطلب فعليًا. يمكنك تفعيله من الإعدادات ← عام ← تعاون متعدد الوكلاء (يسري على الجلسات الجديدة).", - "mentionDelegateDisabledAgentHint": "لقد أشرت إلى {agent}: هذا الوكيل معطّل في الإعدادات ← الوكلاء، لذا لن يُفوَّض إليه الطلب. يمكنك إعادة تفعيله من هناك.", + "mentionDelegateDisabledAgentHint": "لقد أشرت إلى {agent}: هذا الوكيل معطّل في الإعدادات ← الوكلاء، لذا لن يُفوَّض إليه الطلب. يمكنك إعادة تفعيله من هناك (يسري على الجلسات الجديدة).", "mentionHintOpenSettings": "فتح الإعدادات", "askAnything": "اسأل أي شيء...", "removeAttachmentAria": "إزالة {name}", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index b119189529..e8d2c86676 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -2759,7 +2759,7 @@ }, "messageInput": { "mentionDelegateOffHint": "Du hast einen anderen Agenten mit @ erwähnt, aber die Multi-Agent-Zusammenarbeit ist derzeit deaktiviert – die Nachricht wird nicht wirklich delegiert. Aktiviere sie unter Einstellungen → Allgemein → Multi-Agent-Zusammenarbeit (wirkt für neue Sitzungen).", - "mentionDelegateDisabledAgentHint": "Du hast {agent} erwähnt: Dieser Agent ist unter Einstellungen → Agenten deaktiviert, daher wird die Nachricht nicht delegiert. Dort wieder aktivieren.", + "mentionDelegateDisabledAgentHint": "Du hast {agent} erwähnt: Dieser Agent ist unter Einstellungen → Agenten deaktiviert, daher wird die Nachricht nicht delegiert. Dort wieder aktivieren (wirkt für neue Sitzungen).", "mentionHintOpenSettings": "Einstellungen öffnen", "askAnything": "Fragen Sie alles...", "removeAttachmentAria": "{name} entfernen", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 020debfb39..96f930d617 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2759,7 +2759,7 @@ }, "messageInput": { "mentionDelegateOffHint": "You @-mentioned another agent, but Multi-Agent Collaboration is currently off, so this message won't actually be handed off. Enable it under Settings → General → Multi-Agent Collaboration (takes effect for new sessions).", - "mentionDelegateDisabledAgentHint": "You @-mentioned {agent}: that agent is disabled under Settings → Agents, so the message won't be delegated to it. Re-enable it there.", + "mentionDelegateDisabledAgentHint": "You @-mentioned {agent}: that agent is disabled under Settings → Agents, so the message won't be delegated to it. Re-enable it there (takes effect for new sessions).", "mentionHintOpenSettings": "Open Settings", "askAnything": "Ask anything...", "removeAttachmentAria": "Remove {name}", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index cea0ea226d..00e7ee8d25 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2759,7 +2759,7 @@ }, "messageInput": { "mentionDelegateOffHint": "Has mencionado a otro agente con @, pero la Colaboración multiagente está desactivada, por lo que el mensaje no se delegará realmente. Actívala en Configuración → General → Colaboración multiagente (surte efecto en sesiones nuevas).", - "mentionDelegateDisabledAgentHint": "Has mencionado a {agent}: ese agente está desactivado en Configuración → Agentes, por lo que el mensaje no se le delegará. Reactívalo allí.", + "mentionDelegateDisabledAgentHint": "Has mencionado a {agent}: ese agente está desactivado en Configuración → Agentes, por lo que el mensaje no se le delegará. Reactívalo allí (surte efecto en sesiones nuevas).", "mentionHintOpenSettings": "Abrir Configuración", "askAnything": "Pregunta lo que sea...", "removeAttachmentAria": "Quitar {name}", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 7155a80752..9f53afbf00 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2759,7 +2759,7 @@ }, "messageInput": { "mentionDelegateOffHint": "Vous avez mentionné un autre agent avec @, mais la Collaboration multi-agent est actuellement désactivée : le message ne sera pas réellement délégué. Activez-la dans Paramètres → Général → Collaboration multi-agent (effectif pour les nouvelles sessions).", - "mentionDelegateDisabledAgentHint": "Vous avez mentionné {agent} : cet agent est désactivé dans Paramètres → Agents IA, le message ne lui sera pas délégué. Réactivez-le là.", + "mentionDelegateDisabledAgentHint": "Vous avez mentionné {agent} : cet agent est désactivé dans Paramètres → Agents IA, le message ne lui sera pas délégué. Réactivez-le là (effectif pour les nouvelles sessions).", "mentionHintOpenSettings": "Ouvrir les paramètres", "askAnything": "Posez n'importe quelle question...", "removeAttachmentAria": "Retirer {name}", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 011259a4d2..563fa5ed26 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2759,7 +2759,7 @@ }, "messageInput": { "mentionDelegateOffHint": "エージェントを @ でメンションしていますが、「マルチエージェント連携」が無効のため、メッセージは実際には委譲されません。設定 → 一般 → マルチエージェント連携 で有効にできます(新しいセッションから有効)。", - "mentionDelegateDisabledAgentHint": "{agent} を @ でメンションしていますが、このエージェントは 設定 → エージェント で無効になっているため委譲されません。そちらで再度有効にしてください。", + "mentionDelegateDisabledAgentHint": "{agent} を @ でメンションしていますが、このエージェントは 設定 → エージェント で無効になっているため委譲されません。そちらで再度有効にしてください(新しいセッションから有効)。", "mentionHintOpenSettings": "設定を開く", "askAnything": "何でも質問してください...", "removeAttachmentAria": "{name} を削除", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 3f50819a85..52a6d63cf5 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2759,7 +2759,7 @@ }, "messageInput": { "mentionDelegateOffHint": "에이전트를 @ 멘션했지만 다중 에이전트 협업이 꺼져 있어 메시지가 실제로 위임되지 않습니다. 설정 → 일반 → 다중 에이전트 협업에서 켤 수 있습니다(새 세션부터 적용).", - "mentionDelegateDisabledAgentHint": "{agent}을(를) @ 멘션했지만 해당 에이전트가 설정 → 에이전트에서 비활성화되어 위임되지 않습니다. 설정 → 에이전트에서 다시 활성화할 수 있습니다.", + "mentionDelegateDisabledAgentHint": "{agent}을(를) @ 멘션했지만 해당 에이전트가 설정 → 에이전트에서 비활성화되어 위임되지 않습니다. 설정 → 에이전트에서 다시 활성화할 수 있습니다(새 세션부터 적용).", "mentionHintOpenSettings": "설정 열기", "askAnything": "무엇이든 물어보세요...", "removeAttachmentAria": "{name} 제거", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 047599db18..5c6d38c3ae 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2759,7 +2759,7 @@ }, "messageInput": { "mentionDelegateOffHint": "Você mencionou outro agente com @, mas a Colaboração multiagente está desativada, então a mensagem não será realmente delegada. Ative em Configurações → Geral → Colaboração multiagente (válido para novas sessões).", - "mentionDelegateDisabledAgentHint": "Você mencionou {agent}: esse agente está desativado em Configurações → Agentes, então a mensagem não será delegada a ele. Reative-o lá.", + "mentionDelegateDisabledAgentHint": "Você mencionou {agent}: esse agente está desativado em Configurações → Agentes, então a mensagem não será delegada a ele. Reative-o lá (válido para novas sessões).", "mentionHintOpenSettings": "Abrir configurações", "askAnything": "Pergunte qualquer coisa...", "removeAttachmentAria": "Remover {name}", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index bfc7f22ed9..c275d5dd23 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2759,7 +2759,7 @@ }, "messageInput": { "mentionDelegateOffHint": "已 @ 其他智能体:当前「多智能体协同」未开启,消息不会真正委托给对方。可在 设置 → 常规 → 多智能体协同 中开启(新会话生效)。", - "mentionDelegateDisabledAgentHint": "已 @ {agent}:该智能体已在 设置 → 智能体 中禁用,消息不会委托给它。可前往重新启用。", + "mentionDelegateDisabledAgentHint": "已 @ {agent}:该智能体已在 设置 → 智能体 中禁用,消息不会委托给它。可前往重新启用(新会话生效)。", "mentionHintOpenSettings": "前往设置", "askAnything": "请开始输入...", "removeAttachmentAria": "移除 {name}", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 8665b16f2a..9cbc694616 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2759,7 +2759,7 @@ }, "messageInput": { "mentionDelegateOffHint": "已 @ 其他智慧體:目前「多智慧體協同」未開啟,訊息不會真正委派給對方。可在 設定 → 一般 → 多智慧體協同 中開啟(新會話生效)。", - "mentionDelegateDisabledAgentHint": "已 @ {agent}:該智慧體已在 設定 → 智能體 中停用,訊息不會委派給它。可前往重新啟用。", + "mentionDelegateDisabledAgentHint": "已 @ {agent}:該智慧體已在 設定 → 智能體 中停用,訊息不會委派給它。可前往重新啟用(新會話生效)。", "mentionHintOpenSettings": "前往設定", "askAnything": "請開始輸入...", "removeAttachmentAria": "移除 {name}",