From ad36bcb5064ccccaa2b80aff3fc916d126385b13 Mon Sep 17 00:00:00 2001 From: zhangmo8 Date: Thu, 28 May 2026 17:25:31 +0800 Subject: [PATCH] feat(remote-control): add /agent command for all channels --- docs/features/remote-agent-switch/plan.md | 46 ++++ docs/features/remote-agent-switch/spec.md | 38 +++ docs/features/remote-agent-switch/tasks.md | 10 + .../services/discordCommandRouter.ts | 57 +++++ .../services/feishuCommandRouter.ts | 57 +++++ .../services/qqbotCommandRouter.ts | 57 +++++ .../services/remoteBindingStore.ts | 82 +++++++ .../services/remoteCommandRouter.ts | 187 +++++++++++++++ .../services/remoteConversationRunner.ts | 57 +++++ .../services/weixinIlinkCommandRouter.ts | 58 +++++ .../presenter/remoteControlPresenter/types.ts | 78 ++++++ .../feishuCommandRouter.test.ts | 103 ++++++++ .../remoteBindingStore.test.ts | 50 ++++ .../remoteCommandRouter.test.ts | 227 ++++++++++++++++++ .../remoteConversationRunner.test.ts | 120 +++++++++ 15 files changed, 1227 insertions(+) create mode 100644 docs/features/remote-agent-switch/plan.md create mode 100644 docs/features/remote-agent-switch/spec.md create mode 100644 docs/features/remote-agent-switch/tasks.md diff --git a/docs/features/remote-agent-switch/plan.md b/docs/features/remote-agent-switch/plan.md new file mode 100644 index 000000000..8c078bc66 --- /dev/null +++ b/docs/features/remote-agent-switch/plan.md @@ -0,0 +1,46 @@ +# Implementation Plan — `/agent` Remote Command + +## Touch points + +### Shared types — `src/main/presenter/remoteControlPresenter/types.ts` +- `agent` entry added to `TELEGRAM_REMOTE_COMMANDS`, `FEISHU_REMOTE_COMMANDS`, `QQBOT_REMOTE_COMMANDS`, `DISCORD_REMOTE_COMMANDS`. Weixin iLink keeps its private `COMMANDS` array. +- New types: `TelegramAgentOption`, `TelegramAgentMenuState`, `TelegramAgentMenuCallback`. +- New constants/codecs: `TELEGRAM_AGENT_MENU_TTL_MS`, `buildAgentMenuChoiceCallbackData`, `buildAgentMenuCancelCallbackData`, `parseAgentMenuCallbackData`. Callback prefix `agent`, mirrors the existing `model` prefix model. + +### Persistence — `src/main/presenter/remoteControlPresenter/services/remoteBindingStore.ts` +- New menu state: `createAgentMenuState`, `getAgentMenuState`, `clearAgentMenuState` plus `clearAgentMenuStatesForEndpoint` / `clearExpiredAgentMenuStates`. Cleared on rebind, on `clearTransientStateForEndpoint`, and on full `clearBindings()`. +- New helper: `setChannelDefaultAgentId(endpointKey, agentId)` — picks the right per-channel updater (`updateTelegramConfig` / `updateFeishuConfig` / `updateQQBotConfig` / `updateDiscordConfig` / `updateWeixinIlinkConfig`). + +### Runner — `src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts` +- `listAvailableAgents()` → `TelegramAgentOption[]`: `configPresenter.listAgents()` filtered by `enabled !== false`, projected to `{ agentId, agentName, agentType, source }`. +- `setChannelDefaultAgent(endpointKey, candidateId)` → `{ session, agent }`: + 1. Reject empty input with usage hint. + 2. Match against enabled agents by `id` first, then by `resolveAcpAgentAlias` equivalence. + 3. ACP-only guard: refuse when `getChannelDefaultWorkdir(endpointKey)` is empty (the existing private helper). + 4. `bindingStore.setChannelDefaultAgentId(endpointKey, agent.id)`. + 5. `createNewSession(endpointKey)` (already rebinds endpoint and uses `resolveDefaultAgentId`, which is the persisted channel default we just changed). + +### Telegram router — `src/main/presenter/remoteControlPresenter/services/remoteCommandRouter.ts` +- `case 'agent'` mirrors `case 'model'` shape but uses `runner.listAvailableAgents()` and `bindingStore.createAgentMenuState`. +- `handleCallbackQuery` first tries `parseAgentMenuCallbackData`. On hit: + - `cancel`: clears state, edits message to "Agent selection cancelled.". + - `choice`: calls `runner.setChannelDefaultAgent`, edits message to confirmation including new session label and provider/model. On error (e.g. ACP workdir missing) returns `callbackAnswer.showAlert: true` with the runner's message. +- Helpers added: `buildAgentMenuKeyboard`, `formatAgentMenuText`, `formatAgentButtonLabel`, `formatAgentSwitchSuccessText`, `buildExpiredAgentMenuResult`. + +### Text-channel routers +- `feishuCommandRouter.ts`, `qqbotCommandRouter.ts`, `discordCommandRouter.ts`, `weixinIlinkCommandRouter.ts`: each gets a `case 'agent'` that delegates to a new `handleAgentCommand` private method, plus a `formatAgentOverview` helper. The pattern is symmetric to `handleModelCommand` / `formatModelOverview`. + +## Decisions + +- **Switching = new session, not in-place agent change.** DeepChat sessions bind agentId at creation. Doing it any other way would require a new presenter API and break the existing default-agent invariants. The new-session reply communicates this clearly. +- **Per-channel default, not per-user.** Matches `/model` behaviour and the existing config schema. +- **ACP workdir guard is in the runner**, not the routers — single source of truth for both Telegram (button) and text channels (`/agent `). +- **Callback prefix is a new namespace `agent:`** (not reused `model:`) so the existing model menu callback parsing keeps working unchanged. + +## Test coverage + +- `remoteBindingStore.test.ts`: agent menu state lifecycle; `setChannelDefaultAgentId` for each endpoint prefix. +- `remoteConversationRunner.test.ts`: enabled-only filter, alias-tolerant match, unknown agent rejection, ACP workdir guard, successful switch + new session. +- `remoteCommandRouter.test.ts` (Telegram): `/agent` menu render; callback success path; callback ACP-failure surfaces as alert; `/help` includes `/agent`. +- `feishuCommandRouter.test.ts`: text overview without args; `/agent ` happy path. +- (qqbot/discord/weixin tests intentionally light — same code path as Feishu, covered by the runner tests.) diff --git a/docs/features/remote-agent-switch/spec.md b/docs/features/remote-agent-switch/spec.md new file mode 100644 index 000000000..57605bc82 --- /dev/null +++ b/docs/features/remote-agent-switch/spec.md @@ -0,0 +1,38 @@ +# Remote `/agent` Command + +## User Need + +Remote control users (Telegram / Feishu / QQBot / Discord / Weixin iLink) can already switch the model via `/model`, but they cannot switch agents — they're stuck on whatever default agent the desktop user configured. This is especially painful when wanting to try different ACP agents (Claude Code, Codex) from the bot. + +## Goal + +Add a `/agent` slash command on every remote channel: + +- No args: list (or button menu, on Telegram) every enabled agent so the remote user can see what's available. +- With args: `/agent ` switches the channel default agent and starts a fresh session bound to that endpoint. +- Switching to an ACP agent fails fast with an actionable message when the channel has no default workdir. + +## Acceptance Criteria + +1. `/agent` (no args) shows enabled agents from `configPresenter.listAgents()`. + - Telegram: inline keyboard menu, mirroring `/model`'s shape (one button per agent + Cancel). + - Feishu/QQBot/Discord/Weixin iLink: text overview with usage hint and ``/name/type/source. +2. `/agent ` (text channels) and a button click (Telegram) switch the channel's `defaultAgentId` and create a new bound session whose `agentId` matches. +3. Agent identifier is `Agent.id` (with `resolveAcpAgentAlias` fallback for legacy ACP aliases). +4. The reply explicitly tells the user a new session was created. +5. Switching to an ACP agent (`Agent.type === 'acp'`) when the channel has no default workdir fails with `Cannot switch to ACP agent: this channel has no default workdir set. Configure the channel default workdir in DeepChat first.` +6. Disabled agents (`enabled === false`) are not listed and cannot be switched to. +7. `/help` includes `/agent` automatically through the per-channel command registry. + +## Constraints + +- Reuse the existing presenter boundaries: routing happens in the channel-specific command routers, business logic in `RemoteConversationRunner`, persistence in `RemoteBindingStore` via `setChannelDefaultAgentId`. +- Reuse `resolveAcpAgentAlias` to keep parity with `sanitizeDefaultAgentId`. +- Reuse `createNewSession`, which already calls `resolveDefaultAgentId` and rebinds the endpoint. +- `/agent` does NOT modify an existing session's `agentId` (that's not a supported operation in DeepChat); switching always rolls a new session. + +## Non-goals + +- No mid-session agent swap. Switching ends the current session and starts a new one. +- No new agent install/registration flow. Only enabled agents already known to ConfigPresenter are switchable. +- No per-user-in-channel agent override. Switching is per-channel default, same scope as `defaultAgentId`. diff --git a/docs/features/remote-agent-switch/tasks.md b/docs/features/remote-agent-switch/tasks.md new file mode 100644 index 000000000..aeb2dfa30 --- /dev/null +++ b/docs/features/remote-agent-switch/tasks.md @@ -0,0 +1,10 @@ +# Tasks — `/agent` Remote Command + +- [x] Types: register `agent` command on each channel command list; add agent menu types, TTL, and callback codecs (`types.ts`). +- [x] BindingStore: agent menu state lifecycle helpers + `setChannelDefaultAgentId`. +- [x] Runner: `listAvailableAgents` + `setChannelDefaultAgent` (alias-tolerant match, ACP workdir guard, new-session creation). +- [x] Telegram router: `/agent` menu, agent callback dispatch, expired-menu handling, success/cancel formatting. +- [x] Text routers (Feishu / QQBot / Discord / Weixin iLink): `case 'agent'` + `handleAgentCommand` + `formatAgentOverview`. +- [x] Tests: bindingStore, runner, Telegram router (menu + callback), Feishu router (text overview + switch). +- [x] `pnpm run format`, `pnpm run lint`, `pnpm run typecheck`, `pnpm run i18n`. +- [ ] Manual e2e on at least one channel: list agents, switch (with current agent's same-id case), switch to ACP without workdir → expect failure. diff --git a/src/main/presenter/remoteControlPresenter/services/discordCommandRouter.ts b/src/main/presenter/remoteControlPresenter/services/discordCommandRouter.ts index 0fd9e6d7c..85422c6dd 100644 --- a/src/main/presenter/remoteControlPresenter/services/discordCommandRouter.ts +++ b/src/main/presenter/remoteControlPresenter/services/discordCommandRouter.ts @@ -3,6 +3,7 @@ import type { DiscordInboundMessage, DiscordRuntimeStatusSnapshot, RemotePendingInteraction, + TelegramAgentOption, TelegramModelProviderOption } from '../types' import { DISCORD_REMOTE_COMMANDS, buildDiscordBindingMeta, buildDiscordEndpointKey } from '../types' @@ -171,6 +172,9 @@ export class DiscordCommandRouter { case 'model': return await this.handleModelCommand(message, endpointKey) + case 'agent': + return await this.handleAgentCommand(message, endpointKey) + case 'status': { const runtime = this.deps.getRuntimeStatus() const status = await this.deps.runner.getStatus(endpointKey) @@ -307,6 +311,45 @@ export class DiscordCommandRouter { } } + private async handleAgentCommand( + message: DiscordInboundMessage, + endpointKey: string + ): Promise { + const session = await this.deps.runner.getCurrentSession(endpointKey) + if (!session) { + return { + replies: ['No bound session. Send a message, /new, or /use first.'] + } + } + + const agents = await this.deps.runner.listAvailableAgents() + if (agents.length === 0) { + return { + replies: ['No enabled agents are available.'] + } + } + + const rawArgs = message.command?.args?.trim() ?? '' + if (!rawArgs) { + return { + replies: [this.formatAgentOverview(session, agents)] + } + } + + const result = await this.deps.runner.setChannelDefaultAgent(endpointKey, rawArgs) + return { + replies: [ + [ + `Agent switched to ${result.agent.agentName} [${result.agent.agentId}] (${result.agent.agentType === 'acp' ? 'ACP' : 'DeepChat'}).`, + `Started a new session: ${this.formatSessionLabel(result.session)}`, + result.session.providerId + ? `Provider / Model: ${result.session.providerId} / ${result.session.modelId || 'none'}` + : 'Provider / Model: none' + ].join('\n') + ] + } + } + private async handlePendingTextResponse( endpointKey: string, text: string, @@ -426,6 +469,20 @@ export class DiscordCommandRouter { ].join('\n') } + private formatAgentOverview(session: SessionWithState, agents: TelegramAgentOption[]): string { + return [ + `Session: ${this.formatSessionLabel(session)}`, + `Current agent: ${session.agentId || 'none'}`, + 'Usage: /agent ', + '', + 'Available agents:', + ...agents.map( + (agent) => + `- ${agent.agentName} [${agent.agentId}] (${agent.agentType === 'acp' ? 'ACP' : 'DeepChat'}${agent.source ? `, ${agent.source}` : ''})` + ) + ].join('\n') + } + private formatPendingTextReplyHint(interaction: RemotePendingInteraction): string { if (interaction.type === 'permission') { return 'Reply with ALLOW or DENY.' diff --git a/src/main/presenter/remoteControlPresenter/services/feishuCommandRouter.ts b/src/main/presenter/remoteControlPresenter/services/feishuCommandRouter.ts index b7ac1d029..ce1e0499a 100644 --- a/src/main/presenter/remoteControlPresenter/services/feishuCommandRouter.ts +++ b/src/main/presenter/remoteControlPresenter/services/feishuCommandRouter.ts @@ -5,6 +5,7 @@ import type { FeishuOutboundAction, FeishuRuntimeStatusSnapshot, RemotePendingInteraction, + TelegramAgentOption, TelegramModelProviderOption } from '../types' import { FEISHU_REMOTE_COMMANDS, buildFeishuBindingMeta, buildFeishuEndpointKey } from '../types' @@ -182,6 +183,9 @@ export class FeishuCommandRouter { case 'model': return await this.handleModelCommand(message, endpointKey) + case 'agent': + return await this.handleAgentCommand(message, endpointKey) + case 'status': { const runtime = this.deps.getRuntimeStatus() const status = await this.deps.runner.getStatus(endpointKey) @@ -304,6 +308,45 @@ export class FeishuCommandRouter { } } + private async handleAgentCommand( + message: FeishuInboundMessage, + endpointKey: string + ): Promise { + const session = await this.deps.runner.getCurrentSession(endpointKey) + if (!session) { + return { + replies: ['No bound session. Send a message, /new, or /use first.'] + } + } + + const agents = await this.deps.runner.listAvailableAgents() + if (agents.length === 0) { + return { + replies: ['No enabled agents are available.'] + } + } + + const rawArgs = message.command?.args?.trim() ?? '' + if (!rawArgs) { + return { + replies: [this.formatAgentOverview(session, agents)] + } + } + + const result = await this.deps.runner.setChannelDefaultAgent(endpointKey, rawArgs) + return { + replies: [ + [ + `Agent switched to ${result.agent.agentName} [${result.agent.agentId}] (${result.agent.agentType === 'acp' ? 'ACP' : 'DeepChat'}).`, + `Started a new session: ${this.formatSessionLabel(result.session)}`, + result.session.providerId + ? `Provider / Model: ${result.session.providerId} / ${result.session.modelId || 'none'}` + : 'Provider / Model: none' + ].join('\n') + ] + } + } + private async handlePendingTextResponse( endpointKey: string, text: string, @@ -438,6 +481,20 @@ export class FeishuCommandRouter { ].join('\n') } + private formatAgentOverview(session: SessionWithState, agents: TelegramAgentOption[]): string { + return [ + `Session: ${this.formatSessionLabel(session)}`, + `Current agent: ${session.agentId || 'none'}`, + 'Usage: /agent ', + '', + 'Available agents:', + ...agents.map( + (agent) => + `- ${agent.agentName} [${agent.agentId}] (${agent.agentType === 'acp' ? 'ACP' : 'DeepChat'}${agent.source ? `, ${agent.source}` : ''})` + ) + ].join('\n') + } + private formatPendingTextReplyHint(interaction: RemotePendingInteraction): string { if (interaction.type === 'permission') { return 'Reply with ALLOW or DENY.' diff --git a/src/main/presenter/remoteControlPresenter/services/qqbotCommandRouter.ts b/src/main/presenter/remoteControlPresenter/services/qqbotCommandRouter.ts index d0e076a6f..20ff548bb 100644 --- a/src/main/presenter/remoteControlPresenter/services/qqbotCommandRouter.ts +++ b/src/main/presenter/remoteControlPresenter/services/qqbotCommandRouter.ts @@ -3,6 +3,7 @@ import type { QQBotInboundMessage, QQBotRuntimeStatusSnapshot, RemotePendingInteraction, + TelegramAgentOption, TelegramModelProviderOption } from '../types' import { QQBOT_REMOTE_COMMANDS, buildQQBotBindingMeta, buildQQBotEndpointKey } from '../types' @@ -165,6 +166,9 @@ export class QQBotCommandRouter { case 'model': return await this.handleModelCommand(message, endpointKey) + case 'agent': + return await this.handleAgentCommand(message, endpointKey) + case 'status': { const runtime = this.deps.getRuntimeStatus() const status = await this.deps.runner.getStatus(endpointKey) @@ -288,6 +292,45 @@ export class QQBotCommandRouter { } } + private async handleAgentCommand( + message: QQBotInboundMessage, + endpointKey: string + ): Promise { + const session = await this.deps.runner.getCurrentSession(endpointKey) + if (!session) { + return { + replies: ['No bound session. Send a message, /new, or /use first.'] + } + } + + const agents = await this.deps.runner.listAvailableAgents() + if (agents.length === 0) { + return { + replies: ['No enabled agents are available.'] + } + } + + const rawArgs = message.command?.args?.trim() ?? '' + if (!rawArgs) { + return { + replies: [this.formatAgentOverview(session, agents)] + } + } + + const result = await this.deps.runner.setChannelDefaultAgent(endpointKey, rawArgs) + return { + replies: [ + [ + `Agent switched to ${result.agent.agentName} [${result.agent.agentId}] (${result.agent.agentType === 'acp' ? 'ACP' : 'DeepChat'}).`, + `Started a new session: ${this.formatSessionLabel(result.session)}`, + result.session.providerId + ? `Provider / Model: ${result.session.providerId} / ${result.session.modelId || 'none'}` + : 'Provider / Model: none' + ].join('\n') + ] + } + } + private async handlePendingTextResponse( endpointKey: string, text: string, @@ -407,6 +450,20 @@ export class QQBotCommandRouter { ].join('\n') } + private formatAgentOverview(session: SessionWithState, agents: TelegramAgentOption[]): string { + return [ + `Session: ${this.formatSessionLabel(session)}`, + `Current agent: ${session.agentId || 'none'}`, + 'Usage: /agent ', + '', + 'Available agents:', + ...agents.map( + (agent) => + `- ${agent.agentName} [${agent.agentId}] (${agent.agentType === 'acp' ? 'ACP' : 'DeepChat'}${agent.source ? `, ${agent.source}` : ''})` + ) + ].join('\n') + } + private formatPendingTextReplyHint(interaction: RemotePendingInteraction): string { if (interaction.type === 'permission') { return 'Reply with ALLOW or DENY.' diff --git a/src/main/presenter/remoteControlPresenter/services/remoteBindingStore.ts b/src/main/presenter/remoteControlPresenter/services/remoteBindingStore.ts index 155e6bcb5..a21aca981 100644 --- a/src/main/presenter/remoteControlPresenter/services/remoteBindingStore.ts +++ b/src/main/presenter/remoteControlPresenter/services/remoteBindingStore.ts @@ -1,6 +1,7 @@ import type { IConfigPresenter, PairableRemoteChannel, RemoteChannel } from '@shared/presenter' import { REMOTE_CONTROL_SETTING_KEY, + TELEGRAM_AGENT_MENU_TTL_MS, TELEGRAM_INTERACTION_CALLBACK_TTL_MS, TELEGRAM_MODEL_MENU_TTL_MS, buildDiscordPairingSnapshot, @@ -22,6 +23,8 @@ import { type RemoteEndpointBinding, type RemoteEndpointBindingMeta, type RemotePendingInteraction, + type TelegramAgentMenuState, + type TelegramAgentOption, type TelegramInboundEvent, type TelegramPendingInteractionState, type TelegramModelMenuState, @@ -45,6 +48,7 @@ export class RemoteBindingStore { private readonly activeEvents = new Map() private readonly sessionSnapshots = new Map() private readonly modelMenuStates = new Map() + private readonly agentMenuStates = new Map() private readonly pendingInteractionStates = new Map() private readonly remoteDeliveryStates = new Map() @@ -226,6 +230,7 @@ export class RemoteBindingStore { })) this.activeEvents.delete(endpointKey) this.clearModelMenuStatesForEndpoint(endpointKey) + this.clearAgentMenuStatesForEndpoint(endpointKey) this.clearPendingInteractionStatesForEndpoint(endpointKey) this.clearRemoteDeliveryState(endpointKey) } @@ -344,6 +349,7 @@ export class RemoteBindingStore { if (channel === undefined) { this.modelMenuStates.clear() + this.agentMenuStates.clear() } return entries.length @@ -1015,6 +1021,64 @@ export class RemoteBindingStore { this.modelMenuStates.delete(token) } + createAgentMenuState( + endpointKey: string, + sessionId: string, + agents: TelegramAgentOption[] + ): string { + this.clearExpiredAgentMenuStates() + this.clearAgentMenuStatesForEndpoint(endpointKey) + const token = createTelegramCallbackToken() + this.agentMenuStates.set(token, { + endpointKey, + sessionId, + createdAt: Date.now(), + agents: agents.map((agent) => ({ ...agent })) + }) + return token + } + + getAgentMenuState(token: string, ttlMs: number): TelegramAgentMenuState | null { + this.clearExpiredAgentMenuStates() + const state = this.agentMenuStates.get(token) + if (!state) { + return null + } + + if (Date.now() - state.createdAt > ttlMs) { + this.agentMenuStates.delete(token) + return null + } + + return { + ...state, + agents: state.agents.map((agent) => ({ ...agent })) + } + } + + clearAgentMenuState(token: string): void { + this.agentMenuStates.delete(token) + } + + setChannelDefaultAgentId(endpointKey: string, agentId: string): void { + const channel = this.resolveChannelFromEndpointKey(endpointKey) + if (!channel) { + return + } + + if (channel === 'telegram') { + this.updateTelegramConfig((config) => ({ ...config, defaultAgentId: agentId })) + } else if (channel === 'feishu') { + this.updateFeishuConfig((config) => ({ ...config, defaultAgentId: agentId })) + } else if (channel === 'qqbot') { + this.updateQQBotConfig((config) => ({ ...config, defaultAgentId: agentId })) + } else if (channel === 'discord') { + this.updateDiscordConfig((config) => ({ ...config, defaultAgentId: agentId })) + } else if (channel === 'weixin-ilink') { + this.updateWeixinIlinkConfig((config) => ({ ...config, defaultAgentId: agentId })) + } + } + createPendingInteractionState( endpointKey: string, interaction: Pick @@ -1146,6 +1210,7 @@ export class RemoteBindingStore { this.activeEvents.delete(endpointKey) this.sessionSnapshots.delete(endpointKey) this.clearModelMenuStatesForEndpoint(endpointKey) + this.clearAgentMenuStatesForEndpoint(endpointKey) this.clearPendingInteractionStatesForEndpoint(endpointKey) this.clearRemoteDeliveryState(endpointKey) } @@ -1167,6 +1232,23 @@ export class RemoteBindingStore { } } + private clearExpiredAgentMenuStates(): void { + const now = Date.now() + for (const [token, state] of this.agentMenuStates.entries()) { + if (now - state.createdAt > TELEGRAM_AGENT_MENU_TTL_MS) { + this.agentMenuStates.delete(token) + } + } + } + + private clearAgentMenuStatesForEndpoint(endpointKey: string): void { + for (const [token, state] of this.agentMenuStates.entries()) { + if (state.endpointKey === endpointKey) { + this.agentMenuStates.delete(token) + } + } + } + private clearExpiredPendingInteractionStates(): void { const now = Date.now() for (const [token, state] of this.pendingInteractionStates.entries()) { diff --git a/src/main/presenter/remoteControlPresenter/services/remoteCommandRouter.ts b/src/main/presenter/remoteControlPresenter/services/remoteCommandRouter.ts index ce046ac7a..ea09f7c6f 100644 --- a/src/main/presenter/remoteControlPresenter/services/remoteCommandRouter.ts +++ b/src/main/presenter/remoteControlPresenter/services/remoteCommandRouter.ts @@ -1,6 +1,8 @@ import type { ToolInteractionResponse } from '@shared/types/agent-interface' import type { RemotePendingInteraction, + TelegramAgentMenuCallback, + TelegramAgentOption, TelegramCallbackAnswer, TelegramInboundCallbackQuery, TelegramInboundEvent, @@ -12,13 +14,17 @@ import type { TelegramPollerStatusSnapshot } from '../types' import { + TELEGRAM_AGENT_MENU_TTL_MS, TELEGRAM_INTERACTION_CALLBACK_TTL_MS, TELEGRAM_MODEL_MENU_TTL_MS, TELEGRAM_REMOTE_COMMANDS, + buildAgentMenuCancelCallbackData, + buildAgentMenuChoiceCallbackData, buildModelMenuBackCallbackData, buildModelMenuCancelCallbackData, buildModelMenuChoiceCallbackData, buildModelMenuProviderCallbackData, + parseAgentMenuCallbackData, parseModelMenuCallbackData, parsePendingInteractionCallbackData } from '../types' @@ -224,6 +230,35 @@ export class RemoteCommandRouter { } } + case 'agent': { + const session = await this.deps.runner.getCurrentSession(endpointKey) + if (!session) { + return { + replies: ['No bound session. Send a message, /new, or /use first.'] + } + } + + const agents = await this.deps.runner.listAvailableAgents() + if (agents.length === 0) { + return { + replies: ['No enabled agents are available.'] + } + } + + const token = this.deps.bindingStore.createAgentMenuState(endpointKey, session.id, agents) + + return { + replies: [], + outboundActions: [ + { + type: 'sendMessage', + text: this.formatAgentMenuText(session, agents), + replyMarkup: this.buildAgentMenuKeyboard(token, agents) + } + ] + } + } + case 'status': { const runtime = this.deps.getPollerStatus() const status = await this.deps.runner.getStatus(endpointKey) @@ -305,6 +340,11 @@ export class RemoteCommandRouter { } } + const agentCallback = parseAgentMenuCallbackData(event.data) + if (agentCallback) { + return await this.handleAgentMenuCallback(event, endpointKey, agentCallback) + } + const callback = parseModelMenuCallbackData(event.data) if (!callback) { return { @@ -528,6 +568,96 @@ export class RemoteCommandRouter { } } + private buildExpiredAgentMenuResult(messageId: number): RemoteCommandRouteResult { + return { + replies: [], + outboundActions: [ + { + type: 'editMessageText', + messageId, + text: 'Agent menu expired. Run /agent again.', + replyMarkup: null + } + ], + callbackAnswer: { + text: 'Agent menu expired. Run /agent again.', + showAlert: true + } + } + } + + private async handleAgentMenuCallback( + event: TelegramInboundCallbackQuery, + endpointKey: string, + callback: TelegramAgentMenuCallback + ): Promise { + const state = this.deps.bindingStore.getAgentMenuState( + callback.token, + TELEGRAM_AGENT_MENU_TTL_MS + ) + const expiredResult = this.buildExpiredAgentMenuResult(event.messageId) + if (!state || state.endpointKey !== endpointKey) { + return expiredResult + } + + const session = await this.deps.runner.getCurrentSession(endpointKey) + if (!session || session.id !== state.sessionId) { + this.deps.bindingStore.clearAgentMenuState(callback.token) + return expiredResult + } + + if (callback.action === 'cancel') { + this.deps.bindingStore.clearAgentMenuState(callback.token) + return { + replies: [], + outboundActions: [ + { + type: 'editMessageText', + messageId: event.messageId, + text: 'Agent selection cancelled.', + replyMarkup: null + } + ], + callbackAnswer: { + text: 'Cancelled.' + } + } + } + + const agentOption = state.agents[callback.agentIndex] + if (!agentOption) { + return expiredResult + } + + try { + const result = await this.deps.runner.setChannelDefaultAgent(endpointKey, agentOption.agentId) + this.deps.bindingStore.clearAgentMenuState(callback.token) + + return { + replies: [], + outboundActions: [ + { + type: 'editMessageText', + messageId: event.messageId, + text: this.formatAgentSwitchSuccessText(result.agent, result.session), + replyMarkup: null + } + ], + callbackAnswer: { + text: 'Agent switched.' + } + } + } catch (error) { + return { + replies: [], + callbackAnswer: { + text: error instanceof Error ? error.message : String(error), + showAlert: true + } + } + } + } + private async buildExpiredPendingInteractionResult( messageId: number, endpointKey: string @@ -615,6 +745,28 @@ export class RemoteCommandRouter { } } + private buildAgentMenuKeyboard( + token: string, + agents: TelegramAgentOption[] + ): TelegramInlineKeyboardMarkup { + return { + inline_keyboard: [ + ...agents.map((agent, index) => [ + { + text: this.formatAgentButtonLabel(agent), + callback_data: buildAgentMenuChoiceCallbackData(token, index) + } + ]), + [ + { + text: 'Cancel', + callback_data: buildAgentMenuCancelCallbackData(token) + } + ] + ] + } + } + private buildPendingPromptResult( endpointKey: string, interaction: RemotePendingInteraction @@ -877,6 +1029,41 @@ export class RemoteCommandRouter { ].join('\n') } + private formatAgentMenuText( + session: { title: string; id: string; agentId: string }, + agents: TelegramAgentOption[] + ): string { + const current = agents.find((agent) => agent.agentId === session.agentId) + const currentLabel = current + ? `${current.agentName} [${current.agentId}]` + : session.agentId || 'none' + return [ + `Session: ${this.formatSessionLabel(session)}`, + `Current agent: ${currentLabel}`, + 'Choose an agent (switching starts a new session):' + ].join('\n') + } + + private formatAgentButtonLabel(agent: TelegramAgentOption): string { + const typeLabel = agent.agentType === 'acp' ? 'ACP' : 'DeepChat' + return `${agent.agentName} · ${typeLabel}` + } + + private formatAgentSwitchSuccessText( + agent: TelegramAgentOption, + session: { title: string; id: string; providerId: string; modelId: string } + ): string { + const typeLabel = agent.agentType === 'acp' ? 'ACP' : 'DeepChat' + const providerLine = session.providerId + ? `Provider / Model: ${session.providerId} / ${session.modelId || 'none'}` + : `Provider / Model: none` + return [ + `Agent switched to ${agent.agentName} [${agent.agentId}] (${typeLabel}).`, + `Started a new session: ${this.formatSessionLabel(session)}`, + providerLine + ].join('\n') + } + private formatSessionLine( session: { title: string; id: string; status: string }, index: number diff --git a/src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts b/src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts index 6e0bcd645..08ded922d 100644 --- a/src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts +++ b/src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts @@ -28,8 +28,10 @@ import { type RemoteInputAttachment, type RemoteRenderableBlock, type RemotePendingInteraction, + type TelegramAgentOption, type TelegramModelProviderOption } from '../types' +import { resolveAcpAgentAlias } from '../../configPresenter/acpRegistryConstants' import { safeParseAssistantBlocks } from '../telegram/telegramOutbound' import { REMOTE_NO_RESPONSE_TEXT, @@ -587,6 +589,61 @@ export class RemoteConversationRunner { return await this.deps.agentSessionPresenter.setSessionModel(session.id, providerId, modelId) } + async listAvailableAgents(): Promise { + const agents = await this.deps.configPresenter.listAgents() + return agents + .filter((agent) => agent.enabled !== false) + .map((agent) => ({ + agentId: agent.id, + agentName: agent.name || agent.id, + agentType: agent.type, + source: agent.source + })) + } + + async setChannelDefaultAgent( + endpointKey: string, + candidateId: string + ): Promise<{ session: SessionWithState; agent: TelegramAgentOption }> { + const trimmed = candidateId.trim() + if (!trimmed) { + throw new Error('Usage: /agent ') + } + + const agents = await this.deps.configPresenter.listAgents() + const enabled = agents.filter((agent) => agent.enabled !== false) + const normalizedCandidate = resolveAcpAgentAlias(trimmed) + const matched = + enabled.find((agent) => agent.id === trimmed) ?? + enabled.find((agent) => resolveAcpAgentAlias(agent.id) === normalizedCandidate) + + if (!matched) { + throw new Error(`Agent "${trimmed}" is not available. Use /agent to view available agents.`) + } + + if (matched.type === 'acp') { + const channelDefaultWorkdir = this.getChannelDefaultWorkdir(endpointKey) + if (!channelDefaultWorkdir) { + throw new Error( + 'Cannot switch to ACP agent: this channel has no default workdir set. Configure the channel default workdir in DeepChat first.' + ) + } + } + + this.bindingStore.setChannelDefaultAgentId(endpointKey, matched.id) + const session = await this.createNewSession(endpointKey) + + return { + session, + agent: { + agentId: matched.id, + agentName: matched.name || matched.id, + agentType: matched.type, + source: matched.source + } + } + } + async sendText( endpointKey: string, text: string, diff --git a/src/main/presenter/remoteControlPresenter/services/weixinIlinkCommandRouter.ts b/src/main/presenter/remoteControlPresenter/services/weixinIlinkCommandRouter.ts index 56abff5a3..4960c349a 100644 --- a/src/main/presenter/remoteControlPresenter/services/weixinIlinkCommandRouter.ts +++ b/src/main/presenter/remoteControlPresenter/services/weixinIlinkCommandRouter.ts @@ -1,6 +1,7 @@ import type { ToolInteractionResponse, SessionWithState } from '@shared/types/agent-interface' import type { RemotePendingInteraction, + TelegramAgentOption, TelegramModelProviderOption, WeixinIlinkInboundMessage, WeixinIlinkRuntimeStatusSnapshot @@ -65,6 +66,10 @@ const COMMANDS: Array<{ command: 'model', description: 'View or switch the current model' }, + { + command: 'agent', + description: 'View or switch the current agent' + }, { command: 'status', description: 'Show runtime and session status' @@ -196,6 +201,8 @@ export class WeixinIlinkCommandRouter { } case 'model': return await this.handleModelCommand(message, endpointKey) + case 'agent': + return await this.handleAgentCommand(message, endpointKey) case 'status': { const runtime = this.deps.getRuntimeStatus() const status = await this.deps.runner.getStatus(endpointKey) @@ -315,6 +322,45 @@ export class WeixinIlinkCommandRouter { } } + private async handleAgentCommand( + message: WeixinIlinkInboundMessage, + endpointKey: string + ): Promise { + const session = await this.deps.runner.getCurrentSession(endpointKey) + if (!session) { + return { + replies: ['No bound session. Send a message, /new, or /use first.'] + } + } + + const agents = await this.deps.runner.listAvailableAgents() + if (agents.length === 0) { + return { + replies: ['No enabled agents are available.'] + } + } + + const rawArgs = message.command?.args?.trim() ?? '' + if (!rawArgs) { + return { + replies: [this.formatAgentOverview(session, agents)] + } + } + + const result = await this.deps.runner.setChannelDefaultAgent(endpointKey, rawArgs) + return { + replies: [ + [ + `Agent switched to ${result.agent.agentName} [${result.agent.agentId}] (${result.agent.agentType === 'acp' ? 'ACP' : 'DeepChat'}).`, + `Started a new session: ${this.formatSessionLabel(result.session)}`, + result.session.providerId + ? `Provider / Model: ${result.session.providerId} / ${result.session.modelId || 'none'}` + : 'Provider / Model: none' + ].join('\n') + ] + } + } + private async handlePendingTextResponse( endpointKey: string, text: string, @@ -486,4 +532,16 @@ export class WeixinIlinkCommandRouter { ]) ].join('\n') } + + private formatAgentOverview(session: SessionWithState, agents: TelegramAgentOption[]): string { + return [ + `Current agent: ${session.agentId || 'none'}`, + 'Usage: /agent ', + 'Available agents:', + ...agents.map( + (agent) => + `- ${agent.agentName} [${agent.agentId}] (${agent.agentType === 'acp' ? 'ACP' : 'DeepChat'}${agent.source ? `, ${agent.source}` : ''})` + ) + ].join('\n') + } } diff --git a/src/main/presenter/remoteControlPresenter/types.ts b/src/main/presenter/remoteControlPresenter/types.ts index 58c385d9a..6d317a015 100644 --- a/src/main/presenter/remoteControlPresenter/types.ts +++ b/src/main/presenter/remoteControlPresenter/types.ts @@ -45,6 +45,7 @@ export const TELEGRAM_STREAM_START_TIMEOUT_MS = 8_000 export const TELEGRAM_PRIVATE_THREAD_DEFAULT = 0 export const TELEGRAM_RECENT_SESSION_LIMIT = 10 export const TELEGRAM_MODEL_MENU_TTL_MS = 10 * 60 * 1000 +export const TELEGRAM_AGENT_MENU_TTL_MS = 10 * 60 * 1000 export const TELEGRAM_INTERACTION_CALLBACK_TTL_MS = 10 * 60 * 1000 export const TELEGRAM_REMOTE_DEFAULT_AGENT_ID = 'deepchat' export const FEISHU_REMOTE_DEFAULT_AGENT_ID = TELEGRAM_REMOTE_DEFAULT_AGENT_ID @@ -95,6 +96,10 @@ export const TELEGRAM_REMOTE_COMMANDS = [ command: 'model', description: 'Switch provider and model' }, + { + command: 'agent', + description: 'View or switch the current agent' + }, { command: 'status', description: 'Show runtime and session status' @@ -142,6 +147,10 @@ export const FEISHU_REMOTE_COMMANDS = [ command: 'model', description: 'View or switch the current model' }, + { + command: 'agent', + description: 'View or switch the current agent' + }, { command: 'status', description: 'Show runtime and session status' @@ -189,6 +198,10 @@ export const QQBOT_REMOTE_COMMANDS = [ command: 'model', description: 'View or switch the current model' }, + { + command: 'agent', + description: 'View or switch the current agent' + }, { command: 'status', description: 'Show runtime and session status' @@ -236,6 +249,10 @@ export const DISCORD_REMOTE_COMMANDS = [ command: 'model', description: 'View or switch the current model' }, + { + command: 'agent', + description: 'View or switch the current agent' + }, { command: 'status', description: 'Show runtime and session status' @@ -609,6 +626,31 @@ export type TelegramModelMenuCallback = token: string } +export interface TelegramAgentOption { + agentId: string + agentName: string + agentType: 'deepchat' | 'acp' + source?: 'builtin' | 'manual' | 'registry' +} + +export interface TelegramAgentMenuState { + endpointKey: string + sessionId: string + createdAt: number + agents: TelegramAgentOption[] +} + +export type TelegramAgentMenuCallback = + | { + action: 'choice' + token: string + agentIndex: number + } + | { + action: 'cancel' + token: string + } + export type TelegramPendingInteractionCallback = | { action: 'allow' | 'deny' | 'other' @@ -646,6 +688,7 @@ export type FeishuOutboundAction = } const TELEGRAM_MODEL_MENU_CALLBACK_PREFIX = 'model' +const TELEGRAM_AGENT_MENU_CALLBACK_PREFIX = 'agent' const TELEGRAM_INTERACTION_CALLBACK_PREFIX = 'pending' const TELEGRAM_ENDPOINT_KEY_REGEX = /^telegram:(-?\d+):(-?\d+)$/ const FEISHU_ENDPOINT_KEY_REGEX = /^feishu:([^:]+):([^:]+)$/ @@ -671,6 +714,41 @@ export const buildModelMenuBackCallbackData = (token: string): string => export const buildModelMenuCancelCallbackData = (token: string): string => `${TELEGRAM_MODEL_MENU_CALLBACK_PREFIX}:${token}:c` +export const buildAgentMenuChoiceCallbackData = (token: string, agentIndex: number): string => + `${TELEGRAM_AGENT_MENU_CALLBACK_PREFIX}:${token}:a:${agentIndex}` + +export const buildAgentMenuCancelCallbackData = (token: string): string => + `${TELEGRAM_AGENT_MENU_CALLBACK_PREFIX}:${token}:c` + +export const parseAgentMenuCallbackData = (data: string): TelegramAgentMenuCallback | null => { + const parts = data.trim().split(':') + if (parts[0] !== TELEGRAM_AGENT_MENU_CALLBACK_PREFIX || !parts[1]) { + return null + } + + const token = parts[1] + const action = parts[2] + if (action === 'a' && parts[3] !== undefined) { + const agentIndex = Number.parseInt(parts[3], 10) + if (Number.isInteger(agentIndex) && agentIndex >= 0) { + return { + action: 'choice', + token, + agentIndex + } + } + } + + if (action === 'c') { + return { + action: 'cancel', + token + } + } + + return null +} + export const parseModelMenuCallbackData = (data: string): TelegramModelMenuCallback | null => { const parts = data.trim().split(':') if (parts[0] !== TELEGRAM_MODEL_MENU_CALLBACK_PREFIX || !parts[1]) { diff --git a/test/main/presenter/remoteControlPresenter/feishuCommandRouter.test.ts b/test/main/presenter/remoteControlPresenter/feishuCommandRouter.test.ts index 843db56e0..61c44c93c 100644 --- a/test/main/presenter/remoteControlPresenter/feishuCommandRouter.test.ts +++ b/test/main/presenter/remoteControlPresenter/feishuCommandRouter.test.ts @@ -444,4 +444,107 @@ describe('FeishuCommandRouter', () => { }) expect(result.replies).toEqual(['Answer received: 2 please']) }) + + it('lists agents for /agent without args', async () => { + const runner = createRunner({ + getCurrentSession: vi.fn().mockResolvedValue({ + id: 'session-1', + title: 'Remote', + agentId: 'deepchat', + providerId: 'openai', + modelId: 'gpt-5' + }), + listAvailableAgents: vi.fn().mockResolvedValue([ + { + agentId: 'deepchat', + agentName: 'DeepChat', + agentType: 'deepchat', + source: 'builtin' + }, + { + agentId: 'codex', + agentName: 'Codex', + agentType: 'acp', + source: 'registry' + } + ]) + }) + const router = new FeishuCommandRouter({ + authGuard: { + ensureAuthorized: vi.fn().mockReturnValue({ ok: true, userOpenId: 'ou_123' }), + pair: vi.fn() + } as any, + runner: runner as any, + bindingStore: createBindingStore() as any, + getRuntimeStatus: vi + .fn() + .mockReturnValue({ state: 'running', lastError: null, botUser: null }) + }) + + const result = await router.handleMessage( + createMessage({ + text: '/agent', + command: { name: 'agent', args: '' } + }) + ) + + expect(result.replies[0]).toContain('Current agent: deepchat') + expect(result.replies[0]).toContain('Codex') + expect(result.replies[0]).toContain('Usage: /agent ') + }) + + it('switches the channel default agent for /agent ', async () => { + const setChannelDefaultAgent = vi.fn().mockResolvedValue({ + session: { + id: 'session-2', + title: 'New Chat', + agentId: 'codex', + providerId: 'acp', + modelId: 'codex' + }, + agent: { + agentId: 'codex', + agentName: 'Codex', + agentType: 'acp', + source: 'registry' + } + }) + const runner = createRunner({ + getCurrentSession: vi.fn().mockResolvedValue({ + id: 'session-1', + title: 'Remote', + agentId: 'deepchat', + providerId: 'openai', + modelId: 'gpt-5' + }), + listAvailableAgents: vi + .fn() + .mockResolvedValue([ + { agentId: 'codex', agentName: 'Codex', agentType: 'acp', source: 'registry' } + ]), + setChannelDefaultAgent + }) + const router = new FeishuCommandRouter({ + authGuard: { + ensureAuthorized: vi.fn().mockReturnValue({ ok: true, userOpenId: 'ou_123' }), + pair: vi.fn() + } as any, + runner: runner as any, + bindingStore: createBindingStore() as any, + getRuntimeStatus: vi + .fn() + .mockReturnValue({ state: 'running', lastError: null, botUser: null }) + }) + + const result = await router.handleMessage( + createMessage({ + text: '/agent codex', + command: { name: 'agent', args: 'codex' } + }) + ) + + expect(setChannelDefaultAgent).toHaveBeenCalledWith('feishu:oc_100:root', 'codex') + expect(result.replies[0]).toContain('Agent switched to Codex') + expect(result.replies[0]).toContain('Started a new session') + }) }) diff --git a/test/main/presenter/remoteControlPresenter/remoteBindingStore.test.ts b/test/main/presenter/remoteControlPresenter/remoteBindingStore.test.ts index 9beb09335..52040bc9d 100644 --- a/test/main/presenter/remoteControlPresenter/remoteBindingStore.test.ts +++ b/test/main/presenter/remoteControlPresenter/remoteBindingStore.test.ts @@ -490,4 +490,54 @@ describe('RemoteBindingStore', () => { expect(store.getTelegramPairingState().failedAttempts).toBe(0) }) + + it('persists agent menu state and clears it on demand', () => { + const configPresenter = createConfigPresenter() + const store = new RemoteBindingStore(configPresenter as any) + const agents = [ + { + agentId: 'deepchat', + agentName: 'DeepChat', + agentType: 'deepchat' as const, + source: 'builtin' as const + }, + { + agentId: 'codex', + agentName: 'Codex', + agentType: 'acp' as const, + source: 'registry' as const + } + ] + + const token = store.createAgentMenuState('telegram:100:0', 'session-1', agents) + const state = store.getAgentMenuState(token, 60_000) + + expect(state).not.toBeNull() + expect(state?.endpointKey).toBe('telegram:100:0') + expect(state?.agents).toHaveLength(2) + expect(state?.agents[1].agentId).toBe('codex') + + store.clearAgentMenuState(token) + expect(store.getAgentMenuState(token, 60_000)).toBeNull() + }) + + it('updates the channel default agent id by endpoint prefix', () => { + const configPresenter = createConfigPresenter() + const store = new RemoteBindingStore(configPresenter as any) + + store.setChannelDefaultAgentId('telegram:100:0', 'codex') + expect(store.getTelegramDefaultAgentId()).toBe('codex') + + store.setChannelDefaultAgentId('feishu:oc_x:root', 'codex') + expect(store.getFeishuDefaultAgentId()).toBe('codex') + + store.setChannelDefaultAgentId('qqbot:c2c:abc', 'codex') + expect(store.getQQBotDefaultAgentId()).toBe('codex') + + store.setChannelDefaultAgentId('discord:dm:abc', 'codex') + expect(store.getDiscordDefaultAgentId()).toBe('codex') + + store.setChannelDefaultAgentId('weixin-ilink:acct:user', 'codex') + expect(store.getWeixinIlinkDefaultAgentId()).toBe('codex') + }) }) diff --git a/test/main/presenter/remoteControlPresenter/remoteCommandRouter.test.ts b/test/main/presenter/remoteControlPresenter/remoteCommandRouter.test.ts index c2cd2cff8..85645078a 100644 --- a/test/main/presenter/remoteControlPresenter/remoteCommandRouter.test.ts +++ b/test/main/presenter/remoteControlPresenter/remoteCommandRouter.test.ts @@ -44,6 +44,9 @@ const createBindingStore = () => ({ createModelMenuState: vi.fn().mockReturnValue('menu-token'), getModelMenuState: vi.fn(), clearModelMenuState: vi.fn(), + createAgentMenuState: vi.fn().mockReturnValue('agent-token'), + getAgentMenuState: vi.fn(), + clearAgentMenuState: vi.fn(), createPendingInteractionState: vi.fn().mockReturnValue('pending-token'), getPendingInteractionState: vi.fn(), clearPendingInteractionState: vi.fn() @@ -871,4 +874,228 @@ describe('RemoteCommandRouter', () => { }) await (result as Exclude).deferred }) + + it('shows /agent in help output', async () => { + const router = new RemoteCommandRouter({ + authGuard: { + ensureAuthorized: vi.fn(), + pair: vi.fn() + } as any, + runner: {} as any, + bindingStore: createBindingStore() as any, + getPollerStatus: vi.fn() + }) + + const result = await router.handleMessage( + createMessage({ + text: '/help', + command: { name: 'help', args: '' } + }) + ) + + expect(result.replies[0]).toContain('/agent') + }) + + it('returns a prompt when /agent is used without a bound session', async () => { + const runner = createRunner({ + getCurrentSession: vi.fn().mockResolvedValue(null) + }) + const router = new RemoteCommandRouter({ + authGuard: { + ensureAuthorized: vi.fn().mockReturnValue({ ok: true, userId: 123 }), + pair: vi.fn() + } as any, + runner: runner as any, + bindingStore: createBindingStore() as any, + getPollerStatus: vi.fn() + }) + + const result = await router.handleMessage( + createMessage({ + text: '/agent', + command: { name: 'agent', args: '' } + }) + ) + + expect(result).toEqual({ + replies: ['No bound session. Send a message, /new, or /use first.'] + }) + }) + + it('renders an agent menu for /agent', async () => { + const runner = createRunner({ + getCurrentSession: vi.fn().mockResolvedValue({ + id: 'session-1', + title: 'Remote chat', + agentId: 'deepchat', + providerId: 'openai', + modelId: 'gpt-5' + }), + listAvailableAgents: vi.fn().mockResolvedValue([ + { + agentId: 'deepchat', + agentName: 'DeepChat', + agentType: 'deepchat', + source: 'builtin' + }, + { + agentId: 'claude-code', + agentName: 'Claude Code', + agentType: 'acp', + source: 'registry' + } + ]) + }) + const bindingStore = createBindingStore() + const router = new RemoteCommandRouter({ + authGuard: { + ensureAuthorized: vi.fn().mockReturnValue({ ok: true, userId: 123 }), + pair: vi.fn() + } as any, + runner: runner as any, + bindingStore: bindingStore as any, + getPollerStatus: vi.fn() + }) + + const result = await router.handleMessage( + createMessage({ + text: '/agent', + command: { name: 'agent', args: '' } + }) + ) + + expect(bindingStore.createAgentMenuState).toHaveBeenCalledWith( + 'telegram:100:0', + 'session-1', + expect.any(Array) + ) + expect(result.outboundActions).toEqual([ + expect.objectContaining({ + type: 'sendMessage', + text: expect.stringContaining('Choose an agent'), + replyMarkup: { + inline_keyboard: expect.arrayContaining([ + [expect.objectContaining({ text: expect.stringContaining('DeepChat') })] + ]) + } + }) + ]) + }) + + it('switches the channel default agent from a callback query', async () => { + const bindingStore = createBindingStore() + bindingStore.getAgentMenuState.mockReturnValue({ + endpointKey: 'telegram:100:0', + sessionId: 'session-1', + createdAt: Date.now(), + agents: [ + { + agentId: 'claude-code', + agentName: 'Claude Code', + agentType: 'acp', + source: 'registry' + } + ] + }) + const setChannelDefaultAgent = vi.fn().mockResolvedValue({ + session: { + id: 'session-2', + title: 'New Chat', + providerId: 'acp', + modelId: 'claude-code', + agentId: 'claude-code' + }, + agent: { + agentId: 'claude-code', + agentName: 'Claude Code', + agentType: 'acp', + source: 'registry' + } + }) + const runner = createRunner({ + getCurrentSession: vi.fn().mockResolvedValue({ + id: 'session-1', + title: 'Remote chat', + agentId: 'deepchat', + providerId: 'openai', + modelId: 'gpt-5' + }), + setChannelDefaultAgent + }) + const router = new RemoteCommandRouter({ + authGuard: { + ensureAuthorized: vi.fn().mockReturnValue({ ok: true, userId: 123 }), + pair: vi.fn() + } as any, + runner: runner as any, + bindingStore: bindingStore as any, + getPollerStatus: vi.fn() + }) + + const result = await router.handleMessage( + createCallbackQuery({ + data: 'agent:agent-token:a:0' + }) + ) + + expect(setChannelDefaultAgent).toHaveBeenCalledWith('telegram:100:0', 'claude-code') + expect(bindingStore.clearAgentMenuState).toHaveBeenCalledWith('agent-token') + expect(result.callbackAnswer).toEqual({ text: 'Agent switched.' }) + expect(result.outboundActions).toEqual([ + expect.objectContaining({ + type: 'editMessageText', + messageId: 30, + text: expect.stringContaining('Started a new session') + }) + ]) + }) + + it('surfaces agent switch errors via callback alert', async () => { + const bindingStore = createBindingStore() + bindingStore.getAgentMenuState.mockReturnValue({ + endpointKey: 'telegram:100:0', + sessionId: 'session-1', + createdAt: Date.now(), + agents: [ + { + agentId: 'claude-code', + agentName: 'Claude Code', + agentType: 'acp' + } + ] + }) + const runner = createRunner({ + getCurrentSession: vi.fn().mockResolvedValue({ + id: 'session-1', + title: 'Remote chat', + agentId: 'deepchat', + providerId: 'openai', + modelId: 'gpt-5' + }), + setChannelDefaultAgent: vi + .fn() + .mockRejectedValue( + new Error('Cannot switch to ACP agent: this channel has no default workdir set.') + ) + }) + const router = new RemoteCommandRouter({ + authGuard: { + ensureAuthorized: vi.fn().mockReturnValue({ ok: true, userId: 123 }), + pair: vi.fn() + } as any, + runner: runner as any, + bindingStore: bindingStore as any, + getPollerStatus: vi.fn() + }) + + const result = await router.handleMessage( + createCallbackQuery({ data: 'agent:agent-token:a:0' }) + ) + + expect(result.callbackAnswer).toEqual({ + text: expect.stringContaining('Cannot switch to ACP agent'), + showAlert: true + }) + expect(bindingStore.clearAgentMenuState).not.toHaveBeenCalled() + }) }) diff --git a/test/main/presenter/remoteControlPresenter/remoteConversationRunner.test.ts b/test/main/presenter/remoteControlPresenter/remoteConversationRunner.test.ts index 245a04eee..d843d5f8a 100644 --- a/test/main/presenter/remoteControlPresenter/remoteConversationRunner.test.ts +++ b/test/main/presenter/remoteControlPresenter/remoteConversationRunner.test.ts @@ -1510,4 +1510,124 @@ describe('RemoteConversationRunner', () => { 'ACP remote agent requires a channel default directory.' ) }) + + it('lists enabled agents only', async () => { + const configPresenter = createConfigPresenter({ + listAgents: vi.fn().mockResolvedValue([ + { id: 'deepchat', name: 'DeepChat', type: 'deepchat', enabled: true, source: 'builtin' }, + { id: 'codex', name: 'Codex', type: 'acp', enabled: true, source: 'registry' }, + { id: 'disabled', name: 'Disabled', type: 'deepchat', enabled: false } + ]) + }) + const runner = new RemoteConversationRunner( + { + configPresenter: configPresenter as any, + agentSessionPresenter: {} as any, + agentRuntimePresenter: {} as any, + windowPresenter: {} as any, + tabPresenter: {} as any, + resolveDefaultAgentId: vi.fn() + }, + {} as any + ) + + const agents = await runner.listAvailableAgents() + + expect(agents).toEqual([ + { agentId: 'deepchat', agentName: 'DeepChat', agentType: 'deepchat', source: 'builtin' }, + { agentId: 'codex', agentName: 'Codex', agentType: 'acp', source: 'registry' } + ]) + }) + + it('switches the channel default agent and starts a new session', async () => { + const setChannelDefaultAgentId = vi.fn() + const createDetachedSession = vi + .fn() + .mockResolvedValue(createSession({ id: 'session-new', agentId: 'codex' })) + const configPresenter = createConfigPresenter({ + listAgents: vi.fn().mockResolvedValue([ + { id: 'deepchat', name: 'DeepChat', type: 'deepchat', enabled: true }, + { id: 'codex', name: 'Codex', type: 'deepchat', enabled: true } + ]) + }) + const runner = new RemoteConversationRunner( + { + configPresenter: configPresenter as any, + agentSessionPresenter: { + createDetachedSession + } as any, + agentRuntimePresenter: {} as any, + windowPresenter: {} as any, + tabPresenter: {} as any, + resolveDefaultAgentId: vi.fn().mockResolvedValue('codex') + }, + { + setBinding: vi.fn(), + setChannelDefaultAgentId, + getTelegramDefaultWorkdir: vi.fn().mockReturnValue('') + } as any + ) + + const result = await runner.setChannelDefaultAgent('telegram:100:0', 'codex') + + expect(setChannelDefaultAgentId).toHaveBeenCalledWith('telegram:100:0', 'codex') + expect(createDetachedSession).toHaveBeenCalled() + expect(result.session.agentId).toBe('codex') + expect(result.agent.agentId).toBe('codex') + }) + + it('rejects an unknown agent id', async () => { + const configPresenter = createConfigPresenter({ + listAgents: vi + .fn() + .mockResolvedValue([{ id: 'deepchat', name: 'DeepChat', type: 'deepchat', enabled: true }]) + }) + const runner = new RemoteConversationRunner( + { + configPresenter: configPresenter as any, + agentSessionPresenter: {} as any, + agentRuntimePresenter: {} as any, + windowPresenter: {} as any, + tabPresenter: {} as any, + resolveDefaultAgentId: vi.fn() + }, + { setChannelDefaultAgentId: vi.fn() } as any + ) + + await expect(runner.setChannelDefaultAgent('telegram:100:0', 'missing')).rejects.toThrow( + 'Agent "missing" is not available' + ) + }) + + it('rejects an ACP agent switch when the channel has no default workdir', async () => { + const configPresenter = createConfigPresenter({ + listAgents: vi + .fn() + .mockResolvedValue([ + { id: 'codex', name: 'Codex', type: 'acp', enabled: true, source: 'registry' } + ]) + }) + const setChannelDefaultAgentId = vi.fn() + const runner = new RemoteConversationRunner( + { + configPresenter: configPresenter as any, + agentSessionPresenter: { + createDetachedSession: vi.fn() + } as any, + agentRuntimePresenter: {} as any, + windowPresenter: {} as any, + tabPresenter: {} as any, + resolveDefaultAgentId: vi.fn() + }, + { + setChannelDefaultAgentId, + getTelegramDefaultWorkdir: vi.fn().mockReturnValue('') + } as any + ) + + await expect(runner.setChannelDefaultAgent('telegram:100:0', 'codex')).rejects.toThrow( + /no default workdir/ + ) + expect(setChannelDefaultAgentId).not.toHaveBeenCalled() + }) })