Skip to content
Merged
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
28 changes: 28 additions & 0 deletions docs/issues/remote-acp-default-agent-flicker/plan.md
Original file line number Diff line number Diff line change
@@ -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<string>`); 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).
23 changes: 23 additions & 0 deletions docs/issues/remote-acp-default-agent-flicker/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Remote ACP Default Agent Flicker

## User Need
Users who pick an ACP agent in `Settings → Remote → <channel> → 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.
8 changes: 8 additions & 0 deletions docs/issues/remote-acp-default-agent-flicker/tasks.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 1 addition & 9 deletions src/main/presenter/configPresenter/acpRegistryConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
'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')
Expand Down
51 changes: 27 additions & 24 deletions src/main/presenter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down Expand Up @@ -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
})
}
}
)
35 changes: 18 additions & 17 deletions src/main/presenter/remoteControlPresenter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -1777,24 +1778,24 @@ export class RemoteControlPresenter {
channel: RemoteChannel,
candidate: string | null | undefined
): Promise<string> {
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) {
Expand Down Expand Up @@ -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<void> {
Expand Down
59 changes: 39 additions & 20 deletions src/renderer/settings/components/RemoteSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
})
}

Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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),
Expand Down
4 changes: 3 additions & 1 deletion src/renderer/src/i18n/da-DK/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion src/renderer/src/i18n/de-DE/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion src/renderer/src/i18n/en-US/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion src/renderer/src/i18n/es-ES/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion src/renderer/src/i18n/fa-IR/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2022,7 +2022,9 @@
"authorizedPrincipalsDescription": "این حساب‌های {channel} مجاز هستند و می‌توانند جفت‌سازی و کنترل نشست‌ها را انجام دهند.",
"authorizedPrincipalsEmpty": "هنوز هیچ شناسهٔ مجاز ثبت نشده است.",
"sessionBindingsTitle": "پیوندهای نشست",
"sessionBindingsDescription": "هر نقطهٔ ورودی راه‌دورِ زیر در حال حاضر به یک نشست DeepChat متصل است."
"sessionBindingsDescription": "هر نقطهٔ ورودی راه‌دورِ زیر در حال حاضر به یک نشست DeepChat متصل است.",
"acpDefaultWorkdirRequiredTitle": "شاخه پیش‌فرض الزامی است",
"acpDefaultWorkdirRequiredDescription": "عامل‌های ACP به فضای کاری محلی نیاز دارند. پیش از ذخیره، یک شاخه پیش‌فرض انتخاب کنید."
Comment thread
zhangmo8 marked this conversation as resolved.
},
"hooks": {
"title": "هوک‌های تلگرام",
Expand Down
Loading