Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src-tauri/src/commands/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/web/handlers/folders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ pub async fn open_settings_window(
) -> Result<Json<SettingsNavigationResult>, 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",
Expand Down
120 changes: 120 additions & 0 deletions src/components/chat/composer/agent-mention-hint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { beforeEach, describe, expect, it, vi } from "vitest"

const { getSettingsMock } = vi.hoisted(() => ({
getSettingsMock: vi.fn(),
}))

vi.mock("@/lib/api", () => ({
getDelegationSettings: (...args: unknown[]) => getSettingsMock(...args),
}))

import type { AcpAgentInfo } from "@/lib/types"

import {
extractMentionedAgentTypes,
findBlockedAgentMentions,
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", () => {
// 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([], AGENTS)
expect(blocked).toEqual({ delegationOff: false, disabledAgents: [] })
expect(getSettingsMock).not.toHaveBeenCalled()
})

it("reports delegationOff alone when multi-agent delegation is disabled", async () => {
getSettingsMock.mockResolvedValue({ enabled: false })
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"], AGENTS)
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"], 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: [] })
})
})
91 changes: 91 additions & 0 deletions src/components/chat/composer/agent-mention-hint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { getDelegationSettings } from "@/lib/api"

import type { AcpAgentInfo, PromptInputBlock } from "@/lib/types"

/**
* An `@<agent>` 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/<agent_type>)` (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<string>()
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)
}

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.
*
* `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[],
agents: AcpAgentInfo[]
): Promise<BlockedAgentMentions> {
if (mentionedTypes.length === 0) {
return { delegationOff: false, disabledAgents: [] }
}
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<string, string>()
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) => disabled.has(type))
.map((type) => ({ type, label: disabled.get(type) ?? type })),
}
}
55 changes: 55 additions & 0 deletions src/components/chat/message-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -92,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 {
Expand Down Expand Up @@ -155,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
Expand Down Expand Up @@ -1222,6 +1231,51 @@ 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, 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"),
},
})
return
}
const firstDisabled = blocked.disabledAgents[0]
if (firstDisabled) {
const names = blocked.disabledAgents
.map((agent) => agent.label)
.join(", ")
toast.warning(
t("mentionDelegateDisabledAgentHint", { agent: names }),
{
id: MENTION_DELEGATION_HINT_TOAST_ID,
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)
Expand All @@ -1238,6 +1292,7 @@ export function MessageInput({
disabled,
hasUploadingImage,
tAttach,
t,
buildDraft,
isEditingQueueItem,
isPrompting,
Expand Down
12 changes: 12 additions & 0 deletions src/hooks/use-acp-agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -2758,6 +2758,9 @@
"sendMessage": "أرسل رسالة..."
},
"messageInput": {
"mentionDelegateOffHint": "لقد أشرت إلى وكيل آخر عبر @، لكن «تعاون متعدد الوكلاء» معطّل حاليًا، لذا لن يُفوَّض هذا الطلب فعليًا. يمكنك تفعيله من الإعدادات ← عام ← تعاون متعدد الوكلاء (يسري على الجلسات الجديدة).",
"mentionDelegateDisabledAgentHint": "لقد أشرت إلى {agent}: هذا الوكيل معطّل في الإعدادات ← الوكلاء، لذا لن يُفوَّض إليه الطلب. يمكنك إعادة تفعيله من هناك (يسري على الجلسات الجديدة).",
"mentionHintOpenSettings": "فتح الإعدادات",
"askAnything": "اسأل أي شيء...",
"removeAttachmentAria": "إزالة {name}",
"attachFiles": "إرفاق ملفات",
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 (wirkt für neue Sitzungen).",
"mentionHintOpenSettings": "Einstellungen öffnen",
"askAnything": "Fragen Sie alles...",
"removeAttachmentAria": "{name} entfernen",
"attachFiles": "Dateien anhängen",
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 (takes effect for new sessions).",
"mentionHintOpenSettings": "Open Settings",
"askAnything": "Ask anything...",
"removeAttachmentAria": "Remove {name}",
"attachFiles": "Attach files",
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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í (surte efecto en sesiones nuevas).",
"mentionHintOpenSettings": "Abrir Configuración",
"askAnything": "Pregunta lo que sea...",
"removeAttachmentAria": "Quitar {name}",
"attachFiles": "Adjuntar archivos",
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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à (effectif pour les nouvelles sessions).",
"mentionHintOpenSettings": "Ouvrir les paramètres",
"askAnything": "Posez n'importe quelle question...",
"removeAttachmentAria": "Retirer {name}",
"attachFiles": "Joindre des fichiers",
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/messages/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -2758,6 +2758,9 @@
"sendMessage": "メッセージを送信..."
},
"messageInput": {
"mentionDelegateOffHint": "エージェントを @ でメンションしていますが、「マルチエージェント連携」が無効のため、メッセージは実際には委譲されません。設定 → 一般 → マルチエージェント連携 で有効にできます(新しいセッションから有効)。",
"mentionDelegateDisabledAgentHint": "{agent} を @ でメンションしていますが、このエージェントは 設定 → エージェント で無効になっているため委譲されません。そちらで再度有効にしてください(新しいセッションから有効)。",
"mentionHintOpenSettings": "設定を開く",
"askAnything": "何でも質問してください...",
"removeAttachmentAria": "{name} を削除",
"attachFiles": "ファイルを添付",
Expand Down
Loading
Loading