From d20893235e6ecff8aa186150fa210e43b9e81bf6 Mon Sep 17 00:00:00 2001 From: zhangmo8 Date: Thu, 28 May 2026 16:07:52 +0800 Subject: [PATCH] feat(remote-control): improve ACP agent alias resolution and add workdir error handling --- .../remote-acp-default-agent-flicker/plan.md | 28 ++++++ .../remote-acp-default-agent-flicker/spec.md | 23 +++++ .../remote-acp-default-agent-flicker/tasks.md | 8 ++ .../configPresenter/acpRegistryConstants.ts | 10 +-- src/main/presenter/index.ts | 51 ++++++----- .../presenter/remoteControlPresenter/index.ts | 35 ++++---- .../settings/components/RemoteSettings.vue | 59 +++++++----- src/renderer/src/i18n/da-DK/settings.json | 4 +- src/renderer/src/i18n/de-DE/settings.json | 4 +- src/renderer/src/i18n/en-US/settings.json | 4 +- src/renderer/src/i18n/es-ES/settings.json | 4 +- src/renderer/src/i18n/fa-IR/settings.json | 4 +- src/renderer/src/i18n/fr-FR/settings.json | 4 +- src/renderer/src/i18n/he-IL/settings.json | 4 +- src/renderer/src/i18n/id-ID/settings.json | 4 +- src/renderer/src/i18n/it-IT/settings.json | 4 +- src/renderer/src/i18n/ja-JP/settings.json | 4 +- src/renderer/src/i18n/ko-KR/settings.json | 4 +- src/renderer/src/i18n/ms-MY/settings.json | 4 +- src/renderer/src/i18n/pl-PL/settings.json | 4 +- src/renderer/src/i18n/pt-BR/settings.json | 4 +- src/renderer/src/i18n/ru-RU/settings.json | 4 +- src/renderer/src/i18n/tr-TR/settings.json | 4 +- src/renderer/src/i18n/vi-VN/settings.json | 4 +- src/renderer/src/i18n/zh-CN/settings.json | 4 +- src/renderer/src/i18n/zh-HK/settings.json | 4 +- src/renderer/src/i18n/zh-TW/settings.json | 4 +- src/shared/contracts/remoteControlErrors.ts | 10 +++ src/shared/utils/acpAgentAlias.ts | 9 ++ .../remoteControlPresenter.test.ts | 90 +++++++++++++++++++ .../components/RemoteSettings.test.ts | 16 ++++ 31 files changed, 329 insertions(+), 90 deletions(-) create mode 100644 docs/issues/remote-acp-default-agent-flicker/plan.md create mode 100644 docs/issues/remote-acp-default-agent-flicker/spec.md create mode 100644 docs/issues/remote-acp-default-agent-flicker/tasks.md create mode 100644 src/shared/contracts/remoteControlErrors.ts create mode 100644 src/shared/utils/acpAgentAlias.ts diff --git a/docs/issues/remote-acp-default-agent-flicker/plan.md b/docs/issues/remote-acp-default-agent-flicker/plan.md new file mode 100644 index 000000000..6c7c0eb98 --- /dev/null +++ b/docs/issues/remote-acp-default-agent-flicker/plan.md @@ -0,0 +1,28 @@ +# Plan + +## Implementation Approach +- Treat `resolveAcpAgentAlias` strictly as an *equivalence-matching* helper. Never let an alias-flattened id leak out as a stored or returned `defaultAgentId`. +- Rewrite `sanitizeDefaultAgentId` so it locates the matching `enabledAgent` (using alias for equivalence) and returns that agent's **own** id — i.e. the id the renderer's `availableAgents` will contain. +- Promote `resolveAcpAgentAlias` and `ACP_LEGACY_AGENT_ID_ALIASES` to `src/shared/utils/acpAgentAlias.ts` so renderer code can reuse the exact same alias table that main relies on. `src/main/presenter/configPresenter/acpRegistryConstants.ts` re-exports them to keep all current `import` sites working. +- Patch `defaultAgentOptions` in `RemoteSettings.vue` to recognise an alias-equivalent agent and use its real id instead of unshifting a bare-id ghost option. + +## Affected Interfaces +- `sanitizeDefaultAgentId(channel, candidate)` — internal to `RemoteControlPresenter`. Return type unchanged (`Promise`); semantics tightened to "return one of the SQLite agent ids, or the channel default". +- New module `src/shared/utils/acpAgentAlias.ts` exporting `ACP_LEGACY_AGENT_ID_ALIASES` and `resolveAcpAgentAlias`. +- `src/main/presenter/configPresenter/acpRegistryConstants.ts` becomes a thin re-export for the alias pieces; the rest of its constants stay co-located. + +## Data Flow +1. Renderer Select emits real SQLite agent id → `updateXxxDefaultAgentId(value)` writes it into `xxxSettings.value.defaultAgentId`. +2. `persistChannelSettings` calls `saveXxxSettings`. Main `sanitizeDefaultAgentId` matches via alias but returns the *real* enabledAgent id and writes that into `bindingStore`. +3. `getXxxSettings` returns the same real id. `syncXxxFields(saved)` overwrites the renderer state with the same id the user picked → no flicker. +4. For legacy bindings whose stored id is an alias-table key, `sanitizeDefaultAgentId` translates it into the real id during the next get/save, and the renderer's `defaultAgentOptions` resolves it via alias-equivalence so the Select stays bound to the canonical option instead of fabricating a ghost row. + +## Compatibility +- All existing `acpRegistryConstants` import sites continue to compile via re-export. +- Existing alias-equivalent behavior preserved: a candidate that maps via the alias table to an enabled agent is still considered a match. +- No data migrations. Bindings carrying historical alias-key ids self-heal on first sanitize call. + +## Test Strategy +- New main-side unit test: `sanitizeDefaultAgentId` returns the SQLite agent id for (a) exact-match candidate, (b) alias-key candidate against an alias-value agent, and (c) alias-value candidate against an alias-key agent. Falls back to channel default only when no alias-equivalent agent exists. +- New renderer-side unit test: `defaultAgentOptions` returned to the Select reconciles a legacy `currentAgentId` to the corresponding modern entry without inserting a ghost row. +- Run `pnpm run typecheck`, `pnpm run lint`, `pnpm run format`, `pnpm run i18n` (no new strings — runs as a no-op confirmation per repo guideline). diff --git a/docs/issues/remote-acp-default-agent-flicker/spec.md b/docs/issues/remote-acp-default-agent-flicker/spec.md new file mode 100644 index 000000000..cfbad49ec --- /dev/null +++ b/docs/issues/remote-acp-default-agent-flicker/spec.md @@ -0,0 +1,23 @@ +# Remote ACP Default Agent Flicker + +## User Need +Users who pick an ACP agent in `Settings → Remote → → Default Agent` must see their selection persist instead of silently flicking back to a non-ACP agent. + +## Goal +Stop `RemoteControlPresenter.sanitizeDefaultAgentId` from returning an alias-flattened id that the renderer cannot map back to a real `availableAgents` entry. + +## Acceptance Criteria +- Selecting any ACP agent (registry-sourced or manually created) in any of the five remote channel settings (Telegram, Feishu, QQ Bot, Discord, Weixin iLink) keeps the selection after `syncXxxFields(saved)` runs. +- The persisted `defaultAgentId` always matches an `agent.id` returned by `agentSessionPresenter.getAgents()` (i.e. the SQLite-stored id), never a virtual alias-only id. +- Legacy bindings whose persisted `defaultAgentId` uses an alias-table key (e.g. `claude-code-acp` from older builds) get reconciled to the matching modern agent on first save/load instead of orphaning the Select control. +- Renderer falls back to a stable id when the binding refers to an agent that no longer exists, without producing a bare-id "ghost" option. + +## Constraints +- No SQLite schema or stored-id migration. Keep `AgentRepository.syncRegistryAgents` / `createManualAcpAgent` insertion ids as-is. +- No IPC contract changes. `saveXxxSettings` input/output shapes stay the same. +- Reuse the existing `resolveAcpAgentAlias` helper rather than introducing parallel mapping logic. The helper itself can move to `src/shared/` but its semantics must not change. + +## Non-Goals +- Adding inline "ACP requires a project directory" UX hint to the agent picker (separate follow-up). +- Reworking `agentSessionPresenter.getAgentType` alias handling — that layer is already correct and should not change. +- Migrating historical agent ids stored in chat sessions or LLM provider configs. diff --git a/docs/issues/remote-acp-default-agent-flicker/tasks.md b/docs/issues/remote-acp-default-agent-flicker/tasks.md new file mode 100644 index 000000000..f6246ea1d --- /dev/null +++ b/docs/issues/remote-acp-default-agent-flicker/tasks.md @@ -0,0 +1,8 @@ +# Tasks + +- [x] Move `ACP_LEGACY_AGENT_ID_ALIASES` and `resolveAcpAgentAlias` into `src/shared/utils/acpAgentAlias.ts`; re-export from `src/main/presenter/configPresenter/acpRegistryConstants.ts`. +- [x] Rewrite `sanitizeDefaultAgentId` to return the SQLite agent's own id (alias used only for matching). +- [x] Patch `RemoteSettings.vue` `defaultAgentOptions` to alias-reconcile a legacy `currentAgentId` to the canonical option. +- [x] Add main-side unit tests covering the four sanitize outcomes (exact / alias-key / alias-value / no-match). +- [x] Add renderer-side unit test covering `defaultAgentOptions` legacy-id reconciliation. +- [x] Run formatting, i18n generation, lint, typecheck, and the new tests. diff --git a/src/main/presenter/configPresenter/acpRegistryConstants.ts b/src/main/presenter/configPresenter/acpRegistryConstants.ts index ce0171b64..47d94c003 100644 --- a/src/main/presenter/configPresenter/acpRegistryConstants.ts +++ b/src/main/presenter/configPresenter/acpRegistryConstants.ts @@ -15,15 +15,7 @@ export const ACP_REGISTRY_ICON_CACHE_DIRNAME = 'icons' const ACP_REGISTRY_FILE_SEGMENT_PATTERN = /^[A-Za-z0-9._-]+$/ -export const ACP_LEGACY_AGENT_ID_ALIASES: Record = { - 'kimi-cli': 'kimi', - 'claude-code-acp': 'claude-acp', - 'codex-acp': 'codex-acp', - 'dimcode-acp': 'dimcode' -} - -export const resolveAcpAgentAlias = (agentId: string): string => - ACP_LEGACY_AGENT_ID_ALIASES[agentId] ?? agentId +export { ACP_LEGACY_AGENT_ID_ALIASES, resolveAcpAgentAlias } from '@shared/utils/acpAgentAlias' export const isAcpRegistryIconUrl = (iconUrl: string): boolean => iconUrl.startsWith(ACP_REGISTRY_ICON_PREFIX) && iconUrl.endsWith('.svg') diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index 3825633b7..a3033282c 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -1080,8 +1080,8 @@ ipcMain.handle( ipcMain.handle( 'remoteControlPresenter:call', async (event: IpcMainInvokeEvent, method: string, ...payloads: unknown[]) => { + const webContentsId = event.sender.id try { - const webContentsId = event.sender.id const windowId = BrowserWindow.fromWebContents(event.sender)?.id if (import.meta.env.VITE_LOG_IPC_CALL === '1') { @@ -1109,35 +1109,38 @@ ipcMain.handle( method.startsWith('getDiscord') || method.startsWith('getWeixinIlink')) - if (!shouldTrackRemoteRuntime) { - return await presenter.callRemoteControl( - method as keyof IRemoteControlPresenter, - ...payloads - ) - } + const result = shouldTrackRemoteRuntime + ? presenter.startupWorkloadCoordinator.scheduleTask({ + id: `settings.remote.runtime:${method}`, + target: 'settings', + phase: 'deferred', + resource: 'io', + labelKey: 'startup.settings.remote.runtime', + visibleId: 'settings.remote.runtime', + runId: presenter.startupWorkloadCoordinator.getRunId('settings'), + run: async () => { + return await presenter.callRemoteControl( + method as keyof IRemoteControlPresenter, + ...payloads + ) + } + }) + : presenter.callRemoteControl(method as keyof IRemoteControlPresenter, ...payloads) - return await presenter.startupWorkloadCoordinator.scheduleTask({ - id: `settings.remote.runtime:${method}`, - target: 'settings', - phase: 'deferred', - resource: 'io', - labelKey: 'startup.settings.remote.runtime', - visibleId: 'settings.remote.runtime', - runId: presenter.startupWorkloadCoordinator.getRunId('settings'), - run: async () => { - return await presenter.callRemoteControl( - method as keyof IRemoteControlPresenter, - ...payloads - ) - } + return handlePresenterCallResult(result, { + webContentsId, + presenterName: 'remoteControlPresenter', + methodName: method }) } catch ( // eslint-disable-next-line @typescript-eslint/no-explicit-any e: any ) { - const webContentsId = event.sender.id - console.error(`[IPC Error] WebContents:${webContentsId} remoteControlPresenter.${method}:`, e) - return { error: e.message || String(e) } + return handlePresenterCallError(e, { + webContentsId, + presenterName: 'remoteControlPresenter', + methodName: method + }) } } ) diff --git a/src/main/presenter/remoteControlPresenter/index.ts b/src/main/presenter/remoteControlPresenter/index.ts index 3180a0e11..c4e1f2cd4 100644 --- a/src/main/presenter/remoteControlPresenter/index.ts +++ b/src/main/presenter/remoteControlPresenter/index.ts @@ -46,6 +46,7 @@ import { } from './types' import type { ChannelAdapterConfig } from './types/channel' import { resolveAcpAgentAlias } from '../configPresenter/acpRegistryConstants' +import { REMOTE_CONTROL_ERROR_MESSAGES } from '@shared/contracts/remoteControlErrors' import type { RemoteControlPresenterDeps } from './interface' import { RemoteBindingStore } from './services/remoteBindingStore' import { RemoteConversationRunner } from './services/remoteConversationRunner' @@ -1777,24 +1778,24 @@ export class RemoteControlPresenter { channel: RemoteChannel, candidate: string | null | undefined ): Promise { - const normalizedCandidate = resolveAcpAgentAlias( - candidate?.trim() || - (channel === 'qqbot' - ? QQBOT_REMOTE_DEFAULT_AGENT_ID - : channel === 'discord' - ? DISCORD_REMOTE_DEFAULT_AGENT_ID - : channel === 'weixin-ilink' - ? WEIXIN_ILINK_REMOTE_DEFAULT_AGENT_ID - : TELEGRAM_REMOTE_DEFAULT_AGENT_ID) - ) + const channelDefault = + channel === 'qqbot' + ? QQBOT_REMOTE_DEFAULT_AGENT_ID + : channel === 'discord' + ? DISCORD_REMOTE_DEFAULT_AGENT_ID + : channel === 'weixin-ilink' + ? WEIXIN_ILINK_REMOTE_DEFAULT_AGENT_ID + : TELEGRAM_REMOTE_DEFAULT_AGENT_ID + const rawCandidate = candidate?.trim() || channelDefault + const normalizedCandidate = resolveAcpAgentAlias(rawCandidate) const agents = await this.deps.configPresenter.listAgents() const enabledAgents = agents.filter((agent) => agent.enabled !== false) - const enabledAgentIds = new Set(enabledAgents.map((agent) => resolveAcpAgentAlias(agent.id))) - const nextDefaultAgentId = enabledAgentIds.has(normalizedCandidate) - ? normalizedCandidate - : enabledAgentIds.has(TELEGRAM_REMOTE_DEFAULT_AGENT_ID) - ? TELEGRAM_REMOTE_DEFAULT_AGENT_ID - : enabledAgents[0]?.id || TELEGRAM_REMOTE_DEFAULT_AGENT_ID + const matchedAgent = + enabledAgents.find((agent) => agent.id === rawCandidate) ?? + enabledAgents.find((agent) => resolveAcpAgentAlias(agent.id) === normalizedCandidate) + const fallbackAgent = enabledAgents.find((agent) => agent.id === channelDefault) + const nextDefaultAgentId = + matchedAgent?.id ?? fallbackAgent?.id ?? enabledAgents[0]?.id ?? channelDefault if (channel === 'telegram') { if (this.bindingStore.getTelegramDefaultAgentId() !== nextDefaultAgentId) { @@ -1843,7 +1844,7 @@ export class RemoteControlPresenter { return } - throw new Error('ACP remote agent requires a channel default directory.') + throw new Error(REMOTE_CONTROL_ERROR_MESSAGES.acpDefaultWorkdirRequired) } private async registerTelegramCommands(client: TelegramClient): Promise { diff --git a/src/renderer/settings/components/RemoteSettings.vue b/src/renderer/settings/components/RemoteSettings.vue index ef2eec863..c0fcb388f 100644 --- a/src/renderer/settings/components/RemoteSettings.vue +++ b/src/renderer/settings/components/RemoteSettings.vue @@ -1547,6 +1547,8 @@ import { import { Tabs, TabsContent, TabsList, TabsTrigger } from '@shadcn/components/ui/tabs' import { useLegacyPresenter, useLegacyRemoteControlPresenter } from '@api/legacy/presenters' import { useToast } from '@/components/use-toast' +import { resolveAcpAgentAlias } from '@shared/utils/acpAgentAlias' +import { isAcpDefaultWorkdirRequiredError } from '@shared/contracts/remoteControlErrors' import type { Agent, Project } from '@shared/types/agent-interface' import type { DiscordPairingSnapshot, @@ -1623,7 +1625,7 @@ const fallbackChannelDescriptors: RemoteChannelDescriptor[] = [ } ] -const remoteControlPresenter = useLegacyRemoteControlPresenter() +const remoteControlPresenter = useLegacyRemoteControlPresenter({ safeCall: false }) const agentSessionPresenter = useLegacyPresenter('agentSessionPresenter') const projectPresenter = useLegacyPresenter('projectPresenter', { safeCall: false }) const { t } = useI18n() @@ -2155,9 +2157,13 @@ const defaultAgentOptions = (currentAgentId: string) => { })) if (currentAgentId && !options.some((agent) => agent.id === currentAgentId)) { + const aliasMatch = availableAgents.value.find( + (agent) => + agent.enabled && resolveAcpAgentAlias(agent.id) === resolveAcpAgentAlias(currentAgentId) + ) options.unshift({ id: currentAgentId, - name: currentAgentId + name: aliasMatch ? formatAgentOptionName(aliasMatch) : currentAgentId }) } @@ -2378,24 +2384,28 @@ const getSnapshotPrincipalIds = ( .pairedChannelIds const refreshStatus = async () => { - const [ - nextTelegramStatus, - nextFeishuStatus, - nextQQBotStatus, - nextDiscordStatus, - nextWeixinIlinkStatus - ] = await Promise.all([ - getChannelStatusCompat('telegram'), - getChannelStatusCompat('feishu'), - getChannelStatusCompat('qqbot'), - getChannelStatusCompat('discord'), - getChannelStatusCompat('weixin-ilink') - ]) - telegramStatus.value = nextTelegramStatus - feishuStatus.value = nextFeishuStatus - qqbotStatus.value = nextQQBotStatus - discordStatus.value = nextDiscordStatus - weixinIlinkStatus.value = nextWeixinIlinkStatus + try { + const [ + nextTelegramStatus, + nextFeishuStatus, + nextQQBotStatus, + nextDiscordStatus, + nextWeixinIlinkStatus + ] = await Promise.all([ + getChannelStatusCompat('telegram'), + getChannelStatusCompat('feishu'), + getChannelStatusCompat('qqbot'), + getChannelStatusCompat('discord'), + getChannelStatusCompat('weixin-ilink') + ]) + telegramStatus.value = nextTelegramStatus + feishuStatus.value = nextFeishuStatus + qqbotStatus.value = nextQQBotStatus + discordStatus.value = nextDiscordStatus + weixinIlinkStatus.value = nextWeixinIlinkStatus + } catch (error) { + console.warn('Failed to refresh remote channel status:', error) + } } const refreshPairingSnapshot = async ( @@ -2538,6 +2548,15 @@ const buildWeixinIlinkDraftSettings = (): WeixinIlinkRemoteSettings | null => { } const toastSaveError = (error: unknown) => { + if (isAcpDefaultWorkdirRequiredError(error)) { + toast({ + title: t('settings.remote.remoteControl.acpDefaultWorkdirRequiredTitle'), + description: t('settings.remote.remoteControl.acpDefaultWorkdirRequiredDescription'), + variant: 'destructive' + }) + return + } + toast({ title: t('common.error.operationFailed'), description: error instanceof Error ? error.message : String(error), diff --git a/src/renderer/src/i18n/da-DK/settings.json b/src/renderer/src/i18n/da-DK/settings.json index 65931ae39..6b0fdf789 100644 --- a/src/renderer/src/i18n/da-DK/settings.json +++ b/src/renderer/src/i18n/da-DK/settings.json @@ -2022,7 +2022,9 @@ "authorizedPrincipalsDescription": "Disse {channel}-konti er autoriseret til at parre og styre sessioner.", "authorizedPrincipalsEmpty": "Der er endnu ingen autoriserede identiteter.", "sessionBindingsTitle": "Sessionsbindinger", - "sessionBindingsDescription": "Hvert fjernendepunkt nedenfor er i øjeblikket bundet til en DeepChat-session." + "sessionBindingsDescription": "Hvert fjernendepunkt nedenfor er i øjeblikket bundet til en DeepChat-session.", + "acpDefaultWorkdirRequiredTitle": "Standardmappe påkrævet", + "acpDefaultWorkdirRequiredDescription": "ACP-agenter har brug for et lokalt arbejdsområde. Vælg en standardmappe, før du gemmer." }, "hooks": { "title": "Telegram-hooks", diff --git a/src/renderer/src/i18n/de-DE/settings.json b/src/renderer/src/i18n/de-DE/settings.json index 1842ed21c..c8dee2982 100644 --- a/src/renderer/src/i18n/de-DE/settings.json +++ b/src/renderer/src/i18n/de-DE/settings.json @@ -2016,7 +2016,9 @@ "authorizedPrincipalsDescription": "Diese {channel}-Konten sind autorisiert und können Sitzungen koppeln und steuern.", "authorizedPrincipalsEmpty": "Aktuell keine autorisierten Subjekte.", "sessionBindingsTitle": "Sitzungsbindungen", - "sessionBindingsDescription": "Jeder Remote-Einstieg unten ist aktuell an eine DeepChat-Sitzung gebunden." + "sessionBindingsDescription": "Jeder Remote-Einstieg unten ist aktuell an eine DeepChat-Sitzung gebunden.", + "acpDefaultWorkdirRequiredTitle": "Standardverzeichnis erforderlich", + "acpDefaultWorkdirRequiredDescription": "ACP-Agenten benötigen einen lokalen Arbeitsbereich. Wähle vor dem Speichern ein Standardverzeichnis." }, "hooks": { "title": "Telegram Hook-Benachrichtigungen", diff --git a/src/renderer/src/i18n/en-US/settings.json b/src/renderer/src/i18n/en-US/settings.json index 2749ef0e1..27d310987 100644 --- a/src/renderer/src/i18n/en-US/settings.json +++ b/src/renderer/src/i18n/en-US/settings.json @@ -2076,7 +2076,9 @@ "authorizedPrincipalsDescription": "These {channel} accounts are authorized to pair and control sessions.", "authorizedPrincipalsEmpty": "No authorized principals yet.", "sessionBindingsTitle": "Session Bindings", - "sessionBindingsDescription": "Each remote endpoint below is currently bound to one DeepChat session." + "sessionBindingsDescription": "Each remote endpoint below is currently bound to one DeepChat session.", + "acpDefaultWorkdirRequiredTitle": "Default Directory Required", + "acpDefaultWorkdirRequiredDescription": "ACP agents need a local workspace. Pick a Default Directory before saving." }, "hooks": { "title": "Telegram Hooks", diff --git a/src/renderer/src/i18n/es-ES/settings.json b/src/renderer/src/i18n/es-ES/settings.json index a630e301f..80dcce35f 100644 --- a/src/renderer/src/i18n/es-ES/settings.json +++ b/src/renderer/src/i18n/es-ES/settings.json @@ -2016,7 +2016,9 @@ "authorizedPrincipalsDescription": "Estas cuentas {channel} están autorizadas para emparejar y controlar sesiones.", "authorizedPrincipalsEmpty": "Aún no hay directores autorizados.", "sessionBindingsTitle": "Enlaces de sesión", - "sessionBindingsDescription": "Cada punto final remoto a continuación está actualmente vinculado a una sesión de DeepChat." + "sessionBindingsDescription": "Cada punto final remoto a continuación está actualmente vinculado a una sesión de DeepChat.", + "acpDefaultWorkdirRequiredTitle": "Se requiere un directorio predeterminado", + "acpDefaultWorkdirRequiredDescription": "Los agentes ACP necesitan un espacio de trabajo local. Selecciona un directorio predeterminado antes de guardar." }, "hooks": { "title": "Telegram Ganchos", diff --git a/src/renderer/src/i18n/fa-IR/settings.json b/src/renderer/src/i18n/fa-IR/settings.json index 16a2a6bcd..e24663454 100644 --- a/src/renderer/src/i18n/fa-IR/settings.json +++ b/src/renderer/src/i18n/fa-IR/settings.json @@ -2022,7 +2022,9 @@ "authorizedPrincipalsDescription": "این حساب‌های {channel} مجاز هستند و می‌توانند جفت‌سازی و کنترل نشست‌ها را انجام دهند.", "authorizedPrincipalsEmpty": "هنوز هیچ شناسهٔ مجاز ثبت نشده است.", "sessionBindingsTitle": "پیوندهای نشست", - "sessionBindingsDescription": "هر نقطهٔ ورودی راه‌دورِ زیر در حال حاضر به یک نشست DeepChat متصل است." + "sessionBindingsDescription": "هر نقطهٔ ورودی راه‌دورِ زیر در حال حاضر به یک نشست DeepChat متصل است.", + "acpDefaultWorkdirRequiredTitle": "شاخه پیش‌فرض الزامی است", + "acpDefaultWorkdirRequiredDescription": "عامل‌های ACP به فضای کاری محلی نیاز دارند. پیش از ذخیره، یک شاخه پیش‌فرض انتخاب کنید." }, "hooks": { "title": "هوک‌های تلگرام", diff --git a/src/renderer/src/i18n/fr-FR/settings.json b/src/renderer/src/i18n/fr-FR/settings.json index ce856188e..8353ffe71 100644 --- a/src/renderer/src/i18n/fr-FR/settings.json +++ b/src/renderer/src/i18n/fr-FR/settings.json @@ -2022,7 +2022,9 @@ "authorizedPrincipalsDescription": "Ces comptes {channel} sont autorisés à s’associer et à piloter des sessions.", "authorizedPrincipalsEmpty": "Aucune identité autorisée pour le moment.", "sessionBindingsTitle": "Liaisons de session", - "sessionBindingsDescription": "Chaque point d’accès distant ci-dessous est actuellement lié à une session DeepChat." + "sessionBindingsDescription": "Chaque point d’accès distant ci-dessous est actuellement lié à une session DeepChat.", + "acpDefaultWorkdirRequiredTitle": "Répertoire par défaut requis", + "acpDefaultWorkdirRequiredDescription": "Les agents ACP ont besoin d’un espace de travail local. Choisissez un répertoire par défaut avant d’enregistrer." }, "hooks": { "title": "Hooks Telegram", diff --git a/src/renderer/src/i18n/he-IL/settings.json b/src/renderer/src/i18n/he-IL/settings.json index 1bb7fdb80..79cfd34c4 100644 --- a/src/renderer/src/i18n/he-IL/settings.json +++ b/src/renderer/src/i18n/he-IL/settings.json @@ -2022,7 +2022,9 @@ "authorizedPrincipalsDescription": "חשבונות {channel} האלה מורשים לבצע צימוד ולשלוט בסשנים.", "authorizedPrincipalsEmpty": "עדיין אין ישויות מורשות.", "sessionBindingsTitle": "קישורי סשנים", - "sessionBindingsDescription": "כל נקודת קצה מרוחקת שלהלן מקושרת כעת לסשן אחד של DeepChat." + "sessionBindingsDescription": "כל נקודת קצה מרוחקת שלהלן מקושרת כעת לסשן אחד של DeepChat.", + "acpDefaultWorkdirRequiredTitle": "נדרשת תיקיית ברירת מחדל", + "acpDefaultWorkdirRequiredDescription": "סוכני ACP זקוקים לסביבת עבודה מקומית. בחר תיקיית ברירת מחדל לפני השמירה." }, "hooks": { "title": "Hooks של Telegram", diff --git a/src/renderer/src/i18n/id-ID/settings.json b/src/renderer/src/i18n/id-ID/settings.json index deef01812..b43b8d0ec 100644 --- a/src/renderer/src/i18n/id-ID/settings.json +++ b/src/renderer/src/i18n/id-ID/settings.json @@ -2016,7 +2016,9 @@ "authorizedPrincipalsDescription": "Akun {channel} ini diberi wewenang untuk memasangkan dan mengoperasikan sesi.", "authorizedPrincipalsEmpty": "Saat ini tidak ada prinsipal yang berwenang.", "sessionBindingsTitle": "pengikatan sesi", - "sessionBindingsDescription": "Setiap portal jarak jauh di bawah saat ini terikat pada sesi DeepChat." + "sessionBindingsDescription": "Setiap portal jarak jauh di bawah saat ini terikat pada sesi DeepChat.", + "acpDefaultWorkdirRequiredTitle": "Direktori default diperlukan", + "acpDefaultWorkdirRequiredDescription": "Agen ACP memerlukan ruang kerja lokal. Pilih direktori default sebelum menyimpan." }, "hooks": { "title": "Telegram Pemberitahuan kait", diff --git a/src/renderer/src/i18n/it-IT/settings.json b/src/renderer/src/i18n/it-IT/settings.json index 48cee6ed6..9e99c94aa 100644 --- a/src/renderer/src/i18n/it-IT/settings.json +++ b/src/renderer/src/i18n/it-IT/settings.json @@ -2016,7 +2016,9 @@ "authorizedPrincipalsDescription": "Questi account {channel} sono autorizzati ad associarsi e controllare le sessioni.", "authorizedPrincipalsEmpty": "Nessun soggetto autorizzato al momento.", "sessionBindingsTitle": "Binding sessioni", - "sessionBindingsDescription": "Ogni ingresso remoto qui sotto è attualmente associato a una sessione DeepChat." + "sessionBindingsDescription": "Ogni ingresso remoto qui sotto è attualmente associato a una sessione DeepChat.", + "acpDefaultWorkdirRequiredTitle": "Directory predefinita richiesta", + "acpDefaultWorkdirRequiredDescription": "Gli agenti ACP necessitano di un’area di lavoro locale. Scegli una directory predefinita prima di salvare." }, "hooks": { "title": "Notifiche Telegram Hook", diff --git a/src/renderer/src/i18n/ja-JP/settings.json b/src/renderer/src/i18n/ja-JP/settings.json index 1bab9cf3c..ab8432578 100644 --- a/src/renderer/src/i18n/ja-JP/settings.json +++ b/src/renderer/src/i18n/ja-JP/settings.json @@ -2022,7 +2022,9 @@ "authorizedPrincipalsDescription": "これらの {channel} アカウントは認可済みで、ペアリングとセッション操作を行えます。", "authorizedPrincipalsEmpty": "認可済み主体はまだありません。", "sessionBindingsTitle": "セッション紐付け", - "sessionBindingsDescription": "下の各リモート入口は現在 1 つの DeepChat セッションに紐付いています。" + "sessionBindingsDescription": "下の各リモート入口は現在 1 つの DeepChat セッションに紐付いています。", + "acpDefaultWorkdirRequiredTitle": "既定のディレクトリが必要です", + "acpDefaultWorkdirRequiredDescription": "ACP エージェントにはローカルの作業ディレクトリが必要です。保存する前に「既定のディレクトリ」を選択してください。" }, "hooks": { "title": "Telegram Hook 通知", diff --git a/src/renderer/src/i18n/ko-KR/settings.json b/src/renderer/src/i18n/ko-KR/settings.json index 819ad7536..ae69e4eb3 100644 --- a/src/renderer/src/i18n/ko-KR/settings.json +++ b/src/renderer/src/i18n/ko-KR/settings.json @@ -2022,7 +2022,9 @@ "authorizedPrincipalsDescription": "이 {channel} 계정들은 승인되어 페어링과 세션 제어를 수행할 수 있습니다.", "authorizedPrincipalsEmpty": "아직 승인된 주체가 없습니다.", "sessionBindingsTitle": "세션 바인딩", - "sessionBindingsDescription": "아래의 각 원격 엔드포인트는 현재 하나의 DeepChat 세션에 바인딩되어 있습니다." + "sessionBindingsDescription": "아래의 각 원격 엔드포인트는 현재 하나의 DeepChat 세션에 바인딩되어 있습니다.", + "acpDefaultWorkdirRequiredTitle": "기본 디렉터리가 필요합니다", + "acpDefaultWorkdirRequiredDescription": "ACP 에이전트는 로컬 작업 공간이 필요합니다. 저장하기 전에 기본 디렉터리를 선택하세요." }, "hooks": { "title": "Telegram 훅 알림", diff --git a/src/renderer/src/i18n/ms-MY/settings.json b/src/renderer/src/i18n/ms-MY/settings.json index dbba327a5..17513433c 100644 --- a/src/renderer/src/i18n/ms-MY/settings.json +++ b/src/renderer/src/i18n/ms-MY/settings.json @@ -2016,7 +2016,9 @@ "authorizedPrincipalsDescription": "Akaun {channel} ini diberi kuasa untuk menggandingkan dan mengendalikan sesi.", "authorizedPrincipalsEmpty": "Pada masa ini tiada pengetua yang diberi kuasa.", "sessionBindingsTitle": "mengikat sesi", - "sessionBindingsDescription": "Setiap portal jauh di bawah terikat pada sesi DeepChat pada masa ini." + "sessionBindingsDescription": "Setiap portal jauh di bawah terikat pada sesi DeepChat pada masa ini.", + "acpDefaultWorkdirRequiredTitle": "Direktori lalai diperlukan", + "acpDefaultWorkdirRequiredDescription": "Ejen ACP memerlukan ruang kerja tempatan. Pilih direktori lalai sebelum menyimpan." }, "hooks": { "title": "Notis Telegram Hook", diff --git a/src/renderer/src/i18n/pl-PL/settings.json b/src/renderer/src/i18n/pl-PL/settings.json index 86a69046e..cef044dcf 100644 --- a/src/renderer/src/i18n/pl-PL/settings.json +++ b/src/renderer/src/i18n/pl-PL/settings.json @@ -2016,7 +2016,9 @@ "authorizedPrincipalsDescription": "Te konta {channel} są upoważnione do parowania i kontrolowania sesji.", "authorizedPrincipalsEmpty": "Brak jeszcze autoryzowanych podmiotów zabezpieczeń.", "sessionBindingsTitle": "Powiązania sesji", - "sessionBindingsDescription": "Każdy zdalny punkt końcowy poniżej jest obecnie powiązany z jedną sesją DeepChat." + "sessionBindingsDescription": "Każdy zdalny punkt końcowy poniżej jest obecnie powiązany z jedną sesją DeepChat.", + "acpDefaultWorkdirRequiredTitle": "Wymagany domyślny katalog", + "acpDefaultWorkdirRequiredDescription": "Agenci ACP potrzebują lokalnego obszaru roboczego. Wybierz domyślny katalog przed zapisaniem." }, "hooks": { "title": "Haki Telegram", diff --git a/src/renderer/src/i18n/pt-BR/settings.json b/src/renderer/src/i18n/pt-BR/settings.json index 4dabec081..4625ac0ef 100644 --- a/src/renderer/src/i18n/pt-BR/settings.json +++ b/src/renderer/src/i18n/pt-BR/settings.json @@ -2022,7 +2022,9 @@ "authorizedPrincipalsDescription": "Essas contas do {channel} estão autorizadas a fazer o pareamento e controlar sessões.", "authorizedPrincipalsEmpty": "Ainda não há identidades autorizadas.", "sessionBindingsTitle": "Vínculos de sessão", - "sessionBindingsDescription": "Cada ponto de acesso remoto abaixo está atualmente vinculado a uma sessão do DeepChat." + "sessionBindingsDescription": "Cada ponto de acesso remoto abaixo está atualmente vinculado a uma sessão do DeepChat.", + "acpDefaultWorkdirRequiredTitle": "Diretório padrão obrigatório", + "acpDefaultWorkdirRequiredDescription": "Agentes ACP precisam de um workspace local. Escolha um diretório padrão antes de salvar." }, "hooks": { "title": "Hooks do Telegram", diff --git a/src/renderer/src/i18n/ru-RU/settings.json b/src/renderer/src/i18n/ru-RU/settings.json index 02ab64a7a..181b20999 100644 --- a/src/renderer/src/i18n/ru-RU/settings.json +++ b/src/renderer/src/i18n/ru-RU/settings.json @@ -2022,7 +2022,9 @@ "authorizedPrincipalsDescription": "Эти аккаунты {channel} авторизованы и могут выполнять привязку и управлять сессиями.", "authorizedPrincipalsEmpty": "Пока нет авторизованных субъектов.", "sessionBindingsTitle": "Привязки сессий", - "sessionBindingsDescription": "Каждая удалённая точка входа ниже сейчас привязана к одной сессии DeepChat." + "sessionBindingsDescription": "Каждая удалённая точка входа ниже сейчас привязана к одной сессии DeepChat.", + "acpDefaultWorkdirRequiredTitle": "Требуется каталог по умолчанию", + "acpDefaultWorkdirRequiredDescription": "Агентам ACP нужен локальный рабочий каталог. Выберите каталог по умолчанию перед сохранением." }, "hooks": { "title": "Уведомления хуков Telegram", diff --git a/src/renderer/src/i18n/tr-TR/settings.json b/src/renderer/src/i18n/tr-TR/settings.json index 335b5f8e1..1cc5d8214 100644 --- a/src/renderer/src/i18n/tr-TR/settings.json +++ b/src/renderer/src/i18n/tr-TR/settings.json @@ -2016,7 +2016,9 @@ "authorizedPrincipalsDescription": "Bu {channel} hesapları oturumları eşleştirme ve kontrol etme yetkisine sahiptir.", "authorizedPrincipalsEmpty": "Henüz yetkili müdür yok.", "sessionBindingsTitle": "Oturum Bağlamaları", - "sessionBindingsDescription": "Aşağıdaki her uzak uç nokta şu anda bir DeepChat oturumuna bağlıdır." + "sessionBindingsDescription": "Aşağıdaki her uzak uç nokta şu anda bir DeepChat oturumuna bağlıdır.", + "acpDefaultWorkdirRequiredTitle": "Varsayılan dizin gerekli", + "acpDefaultWorkdirRequiredDescription": "ACP ajanları yerel bir çalışma alanı ister. Kaydetmeden önce varsayılan bir dizin seçin." }, "hooks": { "title": "Telegram Kancalar", diff --git a/src/renderer/src/i18n/vi-VN/settings.json b/src/renderer/src/i18n/vi-VN/settings.json index 1499387a9..47b64406a 100644 --- a/src/renderer/src/i18n/vi-VN/settings.json +++ b/src/renderer/src/i18n/vi-VN/settings.json @@ -2016,7 +2016,9 @@ "authorizedPrincipalsDescription": "Các tài khoản {channel} này được phép ghép nối và kiểm soát các phiên.", "authorizedPrincipalsEmpty": "Chưa có hiệu trưởng được ủy quyền.", "sessionBindingsTitle": "Ràng buộc phiên", - "sessionBindingsDescription": "Mỗi điểm cuối từ xa bên dưới hiện được liên kết với một phiên DeepChat." + "sessionBindingsDescription": "Mỗi điểm cuối từ xa bên dưới hiện được liên kết với một phiên DeepChat.", + "acpDefaultWorkdirRequiredTitle": "Cần thư mục mặc định", + "acpDefaultWorkdirRequiredDescription": "Tác tử ACP cần một không gian làm việc cục bộ. Hãy chọn thư mục mặc định trước khi lưu." }, "hooks": { "title": "Móc Telegram", diff --git a/src/renderer/src/i18n/zh-CN/settings.json b/src/renderer/src/i18n/zh-CN/settings.json index 1879035a3..111b2fb8c 100644 --- a/src/renderer/src/i18n/zh-CN/settings.json +++ b/src/renderer/src/i18n/zh-CN/settings.json @@ -2076,7 +2076,9 @@ "authorizedPrincipalsDescription": "这些 {channel} 账号已经授权,可以配对并操作会话。", "authorizedPrincipalsEmpty": "当前还没有已授权主体。", "sessionBindingsTitle": "会话绑定", - "sessionBindingsDescription": "下方每个远程入口当前都绑定到了一个 DeepChat 会话。" + "sessionBindingsDescription": "下方每个远程入口当前都绑定到了一个 DeepChat 会话。", + "acpDefaultWorkdirRequiredTitle": "需要先设置默认目录", + "acpDefaultWorkdirRequiredDescription": "ACP 智能体需要本地工作目录。请先在「默认目录」中选择一个目录,再保存。" }, "hooks": { "title": "Telegram Hook 通知", diff --git a/src/renderer/src/i18n/zh-HK/settings.json b/src/renderer/src/i18n/zh-HK/settings.json index 07d58bbcb..a7fe80ec7 100644 --- a/src/renderer/src/i18n/zh-HK/settings.json +++ b/src/renderer/src/i18n/zh-HK/settings.json @@ -2022,7 +2022,9 @@ "authorizedPrincipalsDescription": "這些 {channel} 帳號已獲授權,可以配對並操作會話。", "authorizedPrincipalsEmpty": "目前還沒有已授權主體。", "sessionBindingsTitle": "會話綁定", - "sessionBindingsDescription": "下方每個遠端入口目前都綁定到一個 DeepChat 會話。" + "sessionBindingsDescription": "下方每個遠端入口目前都綁定到一個 DeepChat 會話。", + "acpDefaultWorkdirRequiredTitle": "需要先設定預設目錄", + "acpDefaultWorkdirRequiredDescription": "ACP 智能體需要本機工作目錄。請先在「預設目錄」中選擇一個目錄,再儲存。" }, "hooks": { "title": "Telegram Hook 通知", diff --git a/src/renderer/src/i18n/zh-TW/settings.json b/src/renderer/src/i18n/zh-TW/settings.json index 195bdf724..8df583a43 100644 --- a/src/renderer/src/i18n/zh-TW/settings.json +++ b/src/renderer/src/i18n/zh-TW/settings.json @@ -2022,7 +2022,9 @@ "authorizedPrincipalsDescription": "這些 {channel} 帳號已獲授權,可以配對並操作會話。", "authorizedPrincipalsEmpty": "目前還沒有已授權主體。", "sessionBindingsTitle": "會話綁定", - "sessionBindingsDescription": "下方每個遠端入口目前都綁定到一個 DeepChat 會話。" + "sessionBindingsDescription": "下方每個遠端入口目前都綁定到一個 DeepChat 會話。", + "acpDefaultWorkdirRequiredTitle": "需要先設定預設目錄", + "acpDefaultWorkdirRequiredDescription": "ACP 智能體需要本機工作目錄。請先在「預設目錄」中選擇一個目錄,再儲存。" }, "hooks": { "title": "Telegram Hook 通知", diff --git a/src/shared/contracts/remoteControlErrors.ts b/src/shared/contracts/remoteControlErrors.ts new file mode 100644 index 000000000..89291fad2 --- /dev/null +++ b/src/shared/contracts/remoteControlErrors.ts @@ -0,0 +1,10 @@ +export const REMOTE_CONTROL_ERROR_MESSAGES = { + acpDefaultWorkdirRequired: 'ACP remote agent requires a channel default directory.' +} as const + +export const isAcpDefaultWorkdirRequiredError = (error: unknown): boolean => { + if (!(error instanceof Error)) { + return false + } + return error.message.includes(REMOTE_CONTROL_ERROR_MESSAGES.acpDefaultWorkdirRequired) +} diff --git a/src/shared/utils/acpAgentAlias.ts b/src/shared/utils/acpAgentAlias.ts new file mode 100644 index 000000000..55a90dac5 --- /dev/null +++ b/src/shared/utils/acpAgentAlias.ts @@ -0,0 +1,9 @@ +export const ACP_LEGACY_AGENT_ID_ALIASES: Record = { + 'kimi-cli': 'kimi', + 'claude-code-acp': 'claude-acp', + 'codex-acp': 'codex-acp', + 'dimcode-acp': 'dimcode' +} + +export const resolveAcpAgentAlias = (agentId: string): string => + ACP_LEGACY_AGENT_ID_ALIASES[agentId] ?? agentId diff --git a/test/main/presenter/remoteControlPresenter/remoteControlPresenter.test.ts b/test/main/presenter/remoteControlPresenter/remoteControlPresenter.test.ts index 4dcd2cfe8..ec72eeb67 100644 --- a/test/main/presenter/remoteControlPresenter/remoteControlPresenter.test.ts +++ b/test/main/presenter/remoteControlPresenter/remoteControlPresenter.test.ts @@ -423,6 +423,96 @@ describe('RemoteControlPresenter', () => { expect(saved.defaultAgentId).toBe('acp-agent') }) + it('returns the SQLite agent id when candidate uses the legacy alias key', async () => { + const configPresenter = createConfigPresenter() + const listAgents = vi.fn().mockResolvedValue([ + { id: 'deepchat', name: 'DeepChat', type: 'deepchat', enabled: true }, + { id: 'claude-acp', name: 'Claude (ACP)', type: 'acp', enabled: true } + ]) + const getAgentType = vi.fn(async (agentId: string) => + agentId === 'claude-acp' ? 'acp' : 'deepchat' + ) + + const presenter = new RemoteControlPresenter({ + configPresenter: { + ...configPresenter, + listAgents, + getAgentType + } as any, + agentSessionPresenter: {} as any, + agentRuntimePresenter: {} as any, + windowPresenter: {} as any, + tabPresenter: {} as any + }) + + const saved = await presenter.saveTelegramSettings({ + botToken: 'test-bot-token', + remoteEnabled: true, + defaultAgentId: 'claude-code-acp', + defaultWorkdir: '/workspace' + }) + + expect(saved.defaultAgentId).toBe('claude-acp') + }) + + it('keeps a legacy SQLite agent id intact when the candidate matches it', async () => { + const configPresenter = createConfigPresenter() + const listAgents = vi.fn().mockResolvedValue([ + { id: 'deepchat', name: 'DeepChat', type: 'deepchat', enabled: true }, + { id: 'claude-code-acp', name: 'Claude Code (ACP)', type: 'acp', enabled: true } + ]) + const getAgentType = vi.fn(async (agentId: string) => + agentId === 'claude-code-acp' ? 'acp' : 'deepchat' + ) + + const presenter = new RemoteControlPresenter({ + configPresenter: { + ...configPresenter, + listAgents, + getAgentType + } as any, + agentSessionPresenter: {} as any, + agentRuntimePresenter: {} as any, + windowPresenter: {} as any, + tabPresenter: {} as any + }) + + const saved = await presenter.saveTelegramSettings({ + botToken: 'test-bot-token', + remoteEnabled: true, + defaultAgentId: 'claude-code-acp', + defaultWorkdir: '/workspace' + }) + + expect(saved.defaultAgentId).toBe('claude-code-acp') + }) + + it('falls back to channel default when no alias-equivalent agent exists', async () => { + const configPresenter = createConfigPresenter() + const listAgents = vi + .fn() + .mockResolvedValue([{ id: 'deepchat', name: 'DeepChat', type: 'deepchat', enabled: true }]) + + const presenter = new RemoteControlPresenter({ + configPresenter: { + ...configPresenter, + listAgents + } as any, + agentSessionPresenter: {} as any, + agentRuntimePresenter: {} as any, + windowPresenter: {} as any, + tabPresenter: {} as any + }) + + const saved = await presenter.saveTelegramSettings({ + botToken: 'test-bot-token', + remoteEnabled: true, + defaultAgentId: 'claude-code-acp' + }) + + expect(saved.defaultAgentId).toBe('deepchat') + }) + it('lists builtin remote channels including discord, qqbot, and weixin-ilink', async () => { const configPresenter = createConfigPresenter() diff --git a/test/renderer/components/RemoteSettings.test.ts b/test/renderer/components/RemoteSettings.test.ts index 299795a6b..bb1b8cf77 100644 --- a/test/renderer/components/RemoteSettings.test.ts +++ b/test/renderer/components/RemoteSettings.test.ts @@ -1249,4 +1249,20 @@ describe('RemoteSettings', () => { }) ) }) + + it('renders the alias-equivalent agent label when binding holds a legacy ACP agent id', async () => { + const { wrapper } = await setup({ + settings: { + botToken: 'telegram-token', + remoteEnabled: true, + allowedUserIds: [], + defaultAgentId: 'claude-code-acp' + }, + agents: [{ id: 'claude-acp', name: 'Claude', type: 'acp', enabled: true }] + }) + + expect(wrapper.find('[data-testid="remote-default-agent-select"]').exists()).toBe(true) + expect(wrapper.text()).toContain('Claude (ACP)') + expect(wrapper.text()).not.toContain('claude-code-acp') + }) })