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
46 changes: 46 additions & 0 deletions docs/features/remote-agent-switch/plan.md
Original file line number Diff line number Diff line change
@@ -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 <id>`).
- **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 <id>` happy path.
- (qqbot/discord/weixin tests intentionally light — same code path as Feishu, covered by the runner tests.)
38 changes: 38 additions & 0 deletions docs/features/remote-agent-switch/spec.md
Original file line number Diff line number Diff line change
@@ -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 <agent-id>` 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 `<id>`/name/type/source.
2. `/agent <id>` (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`.
10 changes: 10 additions & 0 deletions docs/features/remote-agent-switch/tasks.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
DiscordInboundMessage,
DiscordRuntimeStatusSnapshot,
RemotePendingInteraction,
TelegramAgentOption,
TelegramModelProviderOption
} from '../types'
import { DISCORD_REMOTE_COMMANDS, buildDiscordBindingMeta, buildDiscordEndpointKey } from '../types'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -307,6 +311,45 @@ export class DiscordCommandRouter {
}
}

private async handleAgentCommand(
message: DiscordInboundMessage,
endpointKey: string
): Promise<DiscordCommandRouteResult> {
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,
Expand Down Expand Up @@ -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 <id>',
'',
'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.'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
FeishuOutboundAction,
FeishuRuntimeStatusSnapshot,
RemotePendingInteraction,
TelegramAgentOption,
TelegramModelProviderOption
} from '../types'
import { FEISHU_REMOTE_COMMANDS, buildFeishuBindingMeta, buildFeishuEndpointKey } from '../types'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -304,6 +308,45 @@ export class FeishuCommandRouter {
}
}

private async handleAgentCommand(
message: FeishuInboundMessage,
endpointKey: string
): Promise<FeishuCommandRouteResult> {
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,
Expand Down Expand Up @@ -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 <id>',
'',
'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.'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
QQBotInboundMessage,
QQBotRuntimeStatusSnapshot,
RemotePendingInteraction,
TelegramAgentOption,
TelegramModelProviderOption
} from '../types'
import { QQBOT_REMOTE_COMMANDS, buildQQBotBindingMeta, buildQQBotEndpointKey } from '../types'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -288,6 +292,45 @@ export class QQBotCommandRouter {
}
}

private async handleAgentCommand(
message: QQBotInboundMessage,
endpointKey: string
): Promise<QQBotCommandRouteResult> {
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,
Expand Down Expand Up @@ -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 <id>',
'',
'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.'
Expand Down
Loading