feat(agents): 简化微信绑定并校验操作终态

This commit is contained in:
鲨鱼辣椒 committed 2026-10-13 17:07:47 +08:00
1 parent 0fa6f8cca0
commit d58584489b
11 files changed
+2419 -769

No files matched your search

@@ -0,0 +1,47 @@
# Task: Simplify WeChat Agent binding and reliable channel state
## Identity
- Task ID: 20261013-wechat-bindings-ui-f827c13a
- Mode: Feature
- Branch: codex/20261013-wechat-bindings-ui-f827c13a-wechat-bindings
- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261013-wechat-bindings-ui-f827c13a
- Base commit: 0fa6f8cca07c687ed1d9944267e87d950b1373ae
- Owner: codex
- Status: In Progress
## Scope
- Implement the approved one-WeChat/one-Agent flow: a card with one main action, a single Agent-choice dialog, pause in More, and unified WeChat unbinding that stops work, clears the Agent and logs out without deleting history.
- Automatically finish explicit user-initiated binding after confirmed QR login and authoritative enable/readback. Add typed Main-owned unbind operation paired with Yuxi.
- Fix stale mutation-result overwrites, visible confirmation failure feedback and honest uncertain state. Preserve channel-empty guidance, ownership/credentials/recovery and unrelated modules.
## Intent And Constraints
- Official check/start/status passed; task ID, feature mode, absolute worktree, branch and base match Identity. All 36 peer Scope/Intent/Promotion sections reviewed read-only; legacy placeholder scopes remain unknown without a conflicting channel dependency. Existing channel-empty hint is integrated and retained.
- Planning Gate Passed after entry/memory-index/own record, positioning, integrated state, accepted personal-cloud ADR, architecture, domain/success criteria, evidence/reflection/commitments were loaded. User explicitly supersedes independent unlink UI and extra manual enable confirmation. Shared canonical docs remain read-only.
- Yuxi peer task implements a new POST /channel-accounts/{id}/unbind; account ownership, operation receipts and final state must be authoritative. No live WeChat changes, paid model calls, credentials reads, app restart, production deployment, primary adoption/merge/push or peer cleanup.
- Plan: agree compound contract; implement typed transport and streamlined page; focused unit/real Electron-window fixture checks then type/lint/build; independent review and task-aware documentation check; save reviewable commits with paired-release limitations.
- User requested stopping planning-with-files; that workflow is no longer used. This record contains only the repository task outcome and integration evidence required by the existing project-docs workflow.
## Outcome
- Completed and independently reviewed. The client now exposes one card per owned WeChat, a single Agent-choice dialog, pause/resume in More, and compound unbinding. Main journals the original unbind request and only accepts a verified compound final response. QR completion only enables within the explicitly initiated binding flow; reconnecting a paused account preserves pause.
- Agent-list failures preserve safe management of successfully loaded owned accounts; target choice stays disabled. Separate account/read and refresh/recovery guards prevent delayed reads from overwriting a newer mutation result or its unknown-operation lock. Recovery-read failure keeps all conflicting changes blocked until refreshed. The companion backend also fences retained legacy disconnect replay and rejects stale revisions before any native stop or provider logout.
## Verification
- Final client check: `corepack pnpm exec vitest run tests/unit/channel-accounts-page.test.tsx tests/unit/cloud-agents-main.test.ts tests/unit/cloud-recovery.test.tsx tests/unit/cloud-channel-panel.test.tsx tests/unit/cloud-channel-conversations.test.tsx tests/unit/cloud-agents-page.test.tsx --maxWorkers=4`: 214 passed across six files (41 page tests). Includes stale-read/metadata races, partial compound responses, preserved original recovery intent, explicit QR completion, paused reconnect and unavailable-target safe stopping.
- Final client `corepack pnpm run typecheck`, changed-file ESLint and `corepack pnpm run build:vite`: passed. Full `corepack pnpm run lint:check`: passed with eight pre-existing warnings outside modified files. Vite reported existing Browserslist age/dynamic-import/chunk-size warnings. Product UI source SHA256: `37c6b37a3f895154a99656b3fe41e6f6fee04e71d9b33245e3238e8a04bab3ce`.
- Final isolated Electron E2E `cloud-agent-channels.spec.ts`: two scenarios passed in 6.1 seconds against the completed Vite build. Covered explicit QR completion, one-confirmation Agent replacement, pause/resume and compound unbind, including failure feedback inside the active dialog and 980×680 viewport handling. Agent and root inspected synthetic-fixture screenshots; no live WeChat or model calls were made. The first older-bundle E2E attempt is not final verification evidence.
- Fresh independent reviewer re-read the complete final client/backend diff and returned paired PASS with no remaining material findings. Independent client page/Main/recovery: 158 passed; backend service/lifecycle: 59 passed (final service SHA prefix `d6809619`).
- Companion Yuxi task `20261013-channel-unbind-yx-a43d91c8`: focused 101 passed (83 unit plus 18 actual HTTP/PostgreSQL tests with Core/WeChat/Redis substitutes), prior to the last legacy stale-revision guard. Initial full non-slow unit run: 2295 passed, 1 skipped, 2 failed because existing baseline scripts are indexed non-executable. No unrelated script-mode repair is included; final numbers are owned by the companion task.
## Follow-ups
- Paired backend and client release is required. No primary checkout changes, merge/push, live WeChat actions, app runtime switch or production deployment is included.
- Actual online Core/WeChat/Worker/Redis behavior requires release-environment validation. Tests use isolated Electron fixtures and actual local HTTP/PostgreSQL with upstream substitutes; no database migration or new configuration was introduced.
## Promotion Candidates
- After integration, the current product docs/accepted channel ADR should describe compound unbinding and automatic completion of explicitly initiated QR binding, superseding the earlier independent unlink and extra manual enable flow. Canonical memory remains unchanged in this feature task.
+1 -1
View File
@@ -20,7 +20,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智
云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。
Agents 侧栏的「渠道」用于把自己的智能体接到微信。每个微信一张卡,展示备注、绑定的智能体头像/名称和是否可用;一个账号同时只有一个目标,多个账号可以使用同一智能体。没有可连接的智能体时,渠道页和添加窗口说明需先创建、配置并点击“开始使用”,提供“去我的智能体”入口;加载失败不显示该空态,已有微信卡片仍可管理。添加流程为选择自己已开始使用的智能体、扫码连接、明确完成绑定并启用;备注可选,扫码确认后收起二维码与验证码,未确认或启用失败不显示完成。管理已有账号不会重新创建账号;更换目标保留微信登录与原启停状态,解除智能体绑定清空目标并停用但保留微信登录,暂停保留绑定/登录并停止该渠道当前任务,断开微信连接则需要重新扫码。更换、解除和断开有明确确认,历史保留。渠道配置页不展示微信对话或活动,不读取这些列表;聊天在微信中进行,既有云端历史、文件、审批、费用和账号隔离合同不变。页面保留加载失败重试、未确认操作恢复,创建后绑定失败可继续使用原账号,重试沿用原操作身份;仅添加/扫码不自动启用。实际微信身份与重复扫码识别仍由云端负责,客户端不以备注推测同一微信。渠道触发的应计费模型调用仍由智能体创建者支付,Main 持有认证与恢复;用户无需填写服务凭据。应用 API 管理独立于微信渠道。
Agents 侧栏的「渠道」按每个微信一张卡展示备注、当前智能体与使用状态;一个微信同时只绑定一个智能体,多个微信可以使用同一智能体。常用操作是“更换智能体”,在同一个选择窗口中确认;暂停自动回复和解除微信绑定放在“更多操作”。暂停停止当前任务并保留登录、绑定和历史;更换保留登录与原启停状态;解除微信绑定是完整操作,停止任务、清除目标并退出微信登录,历史保留,再使用需扫码。掉线显示重新扫码入口,暂停显示继续回复,未知或未同步状态显示正在确认,不宣称可用。添加流程为选择已开始使用的智能体、扫码、自动完成绑定;只有这次用户发起的绑定在扫码确认、目标正确、启用成功且最终状态回读可用后才显示完成,不因页面打开、旧记录或验证码提交自动启用。无可用智能体时说明创建和“开始使用”的前提,加载失败不冒充空态,已有微信仍可管理或解绑。Main 持有凭据与原操作恢复;解除需要配套 Yuxi 用户级 `/channel-accounts/{id}/unbind`,旧后端不会通过改按钮文案或组合未确认操作假装完成。变更后以最新读取状态为准,未完成操作保留原身份供显式恢复;失败在当前窗口可见,结果未知不自动重放。渠道页不展示微信聊天或活动,既有历史、费用、内容隔离与应用 API 保持原边界。实际微信身份及重复扫码识别仍由云端负责,不从备注推测。
- `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。左侧项目栏负责新建、切换和管理 Workspace,并在桌面设计模式下以 256px 宽度常驻展开;中央沿用 AI 编程的安静对话画布、自然消息流和底部悬浮输入器,AI 整理出的制作方案作为对话内的轻量可编辑稿持续更新;桌面端右侧同为 256px 的全高历史作品栏集中展示当前项目的制作记录与生成结果。任务中的已生成图片可通过“放大查看图片”按钮直接打开大图,支持适应窗口、原始尺寸及 Esc 关闭,无需先下载。紧凑窗口通过左侧抽屉访问项目列表,历史记录保留在时间线中。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。
- `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中,Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。
+6 -1
View File
@@ -60,6 +60,7 @@ function projectMutation(value: unknown): unknown {
...(source.worker && typeof source.worker === 'object' ? { worker: copy(source.worker, ['account_id', 'address']) } : {}),
...(source.wechat && typeof source.wechat === 'object' ? { wechat: copy(source.wechat, ['status', 'message']) } : {}),
...(source.disconnected === undefined ? {} : { disconnected: source.disconnected === true }),
...(source.unbound === undefined ? {} : { unbound: source.unbound === true }),
};
}
function projectWechat(value: unknown): unknown {
@@ -187,7 +188,7 @@ function projectSchedules(value: unknown): unknown { return { jobs: list(object(
function validateChannelInput(operation: string, args: ObjectRecord): void {
const channelOperations = new Set([
'createChannelAccount', 'routeChannelAccount', 'enableChannelAccount', 'pauseChannelAccount',
'disconnectChannelAccount', 'channelAccountWechatBindStart', 'channelAccountWechatBindStatus',
'disconnectChannelAccount', 'unbindChannelAccount', 'channelAccountWechatBindStart', 'channelAccountWechatBindStatus',
'channelAccountWechatBindVerification', 'channelAccountWechatBindAccount', 'channelAccountWechatUnbind',
'channelAccountOperation',
'createChannelBinding', 'switchChannelBindingAgent', 'enableChannelBinding', 'pauseChannelBinding',
@@ -211,6 +212,9 @@ function validateChannelInput(operation: string, args: ObjectRecord): void {
if (operation === 'routeChannelAccount' && args.target_agent_slug !== null
&& (typeof args.target_agent_slug !== 'string' || !/^ml-[a-f0-9]{32}$/.test(args.target_agent_slug))) throw new Error('invalid_id');
if (operation === 'routeChannelAccount' && args.target_agent_slug === null && args.enabled !== false) throw new Error('invalid_input');
if (operation === 'unbindChannelAccount' && (typeof args.channel_account_id !== 'string'
|| typeof args.operation_id !== 'string' || !Number.isSafeInteger(args.expected_revision)
|| Number(args.expected_revision) < 0)) throw new Error('invalid_input');
if (operation === 'switchChannelBindingAgent' && (typeof args.target_agent_slug !== 'string' || !/^ml-[a-f0-9]{32}$/.test(args.target_agent_slug))) throw new Error('invalid_id');
if (operation === 'wechatBindStart' && typeof args.force !== 'boolean') throw new Error('invalid_input');
if (operation === 'channelAccountWechatBindStart' && typeof args.force !== 'boolean') throw new Error('invalid_input');
@@ -294,6 +298,7 @@ const operations: Record<keyof CloudAgentOperations, OperationSpec> = {
enableChannelAccount: { method: 'POST', path: '/channel-accounts/:channel_account_id/enable', body: ['operation_id', 'expected_revision'], output: ['channel'], project: projectMutation },
pauseChannelAccount: { method: 'POST', path: '/channel-accounts/:channel_account_id/pause', body: ['operation_id', 'expected_revision'], output: ['channel'], project: projectMutation },
disconnectChannelAccount: { method: 'POST', path: '/channel-accounts/:channel_account_id/disconnect', body: ['operation_id', 'expected_revision'], output: ['channel', 'wechat', 'disconnected'], project: projectMutation },
unbindChannelAccount: { method: 'POST', path: '/channel-accounts/:channel_account_id/unbind', body: ['operation_id', 'expected_revision'], output: ['channel', 'wechat', 'disconnected', 'unbound'], project: projectMutation },
channelAccountWechatBindStart: { method: 'POST', path: '/channel-accounts/:channel_account_id/wechat-bind/start', body: ['operation_id', 'force'], output: ['session_key', 'status', 'message', 'qrcode_url'], project: projectWechat },
channelAccountWechatBindStatus: { method: 'GET', path: '/channel-accounts/:channel_account_id/wechat-bind/status', query: { session_key: 'session_key' }, output: ['session_key', 'status', 'message', 'qrcode_url'], project: projectWechat },
channelAccountWechatBindVerification: { method: 'POST', path: '/channel-accounts/:channel_account_id/wechat-bind/verification', body: ['operation_id', 'session_key', 'verify_code'], output: ['session_key', 'status', 'message', 'qrcode_url'], project: projectWechat },
+14 -2
View File
@@ -54,6 +54,7 @@ const MESSAGES: Record<string, string> = {
download_failed: '文件保存失败,请重试',
channel_not_found: '微信渠道不存在或已解绑',
channel_operation_conflict: '微信渠道正在处理其他操作,请稍后查看状态',
channel_operation_unconfirmed: '微信绑定的变更尚未确认,请查看待确认操作后重试',
channel_request_invalid: '微信渠道请求无效,请刷新后重试',
channel_rate_limited: '微信渠道操作过于频繁,请稍后重试',
channel_conversation_not_found: '微信会话不存在或已关闭',
@@ -423,9 +424,20 @@ export class CloudAgentsModule {
let plan;
try { plan = operationPlan(value); }
catch { throw new CloudAgentsError(422, 'invalid_input'); }
const invoke = async () => plan.project(await this.request(plan.path, plan.method, plan.body, plan.cloud === 'ws'));
const invoke = async () => {
const result = plan.project(await this.request(plan.path, plan.method, plan.body, plan.cloud === 'ws'));
if (plan.operation === 'unbindChannelAccount') {
const value = record(result), channel = record(value.channel);
if (value.unbound !== true || value.disconnected !== true || channel.id !== plan.input.channel_account_id
|| channel.enabled !== false || channel.target_agent_slug !== null || channel.health !== 'login_required') {
// Keep the original operation in recovery until both routing and login are confirmed removed.
throw new CloudAgentsError(503, 'channel_operation_unconfirmed');
}
}
return result;
};
const recoverable = ['submit', 'preview', 'resume', 'publish', 'createApplication', 'createKey', 'createKnowledge', 'processKnowledge', 'createSchedule', 'runSchedule', 'importKnowledgeAttachment', 'createChild',
'createChannelAccount', 'routeChannelAccount', 'enableChannelAccount', 'pauseChannelAccount', 'disconnectChannelAccount',
'createChannelAccount', 'routeChannelAccount', 'enableChannelAccount', 'pauseChannelAccount', 'disconnectChannelAccount', 'unbindChannelAccount',
'channelAccountWechatBindStart', 'channelAccountWechatBindVerification', 'channelAccountWechatUnbind',
'createChannelBinding', 'switchChannelBindingAgent', 'enableChannelBinding', 'pauseChannelBinding', 'disconnectChannelBinding',
'wechatBindStart', 'wechatBindVerification', 'wechatUnbind',
+3 -1
View File
@@ -222,10 +222,11 @@ export interface CloudUserChannelAccount {
target_agent_published_version: number | null;
provider_generation?: string;
desired_state?: string;
sync_state?: string;
last_confirmed_at?: string | null;
}
export interface CloudChannelAccountPage { items: CloudUserChannelAccount[] }
export interface CloudChannelAccountMutation { channel: CloudUserChannelAccount; target_agent?: string | null; wechat?: Record<string, unknown>; disconnected?: boolean }
export interface CloudChannelAccountMutation { channel: CloudUserChannelAccount; target_agent?: string | null; wechat?: Record<string, unknown>; disconnected?: boolean; unbound?: boolean }
export interface CloudChannelOperation {
operation_id: string;
status: 'pending' | 'completed' | 'failed' | 'unknown' | string;
@@ -424,6 +425,7 @@ export interface CloudAgentOperations {
enableChannelAccount: Op<{ channel_account_id: string; operation_id: string; expected_revision: number }, CloudChannelAccountMutation>;
pauseChannelAccount: Op<{ channel_account_id: string; operation_id: string; expected_revision: number }, CloudChannelAccountMutation>;
disconnectChannelAccount: Op<{ channel_account_id: string; operation_id: string; expected_revision: number }, CloudChannelAccountMutation>;
unbindChannelAccount: Op<{ channel_account_id: string; operation_id: string; expected_revision: number }, CloudChannelAccountMutation>;
channelAccountWechatBindStart: Op<{ channel_account_id: string; operation_id: string; force: boolean }, CloudWechatBindState>;
channelAccountWechatBindStatus: Op<{ channel_account_id: string; session_key: string }, CloudWechatBindState>;
channelAccountWechatBindVerification: Op<{ channel_account_id: string; operation_id: string; session_key: string; verify_code: string }, CloudWechatBindState>;
File diff suppressed because it is too large. Load diff
+4
View File
@@ -16,6 +16,7 @@ const labels: Record<string, string> = {
createChannelAccount: '添加微信账号', routeChannelAccount: '切换目标智能体', enableChannelAccount: '启用渠道',
pauseChannelAccount: '暂停渠道', disconnectChannelAccount: '断开微信账号', channelAccountWechatBindStart: '微信扫码连接',
channelAccountWechatUnbind: '解除微信登录',
unbindChannelAccount: '解除微信绑定',
};
const retiredChannelOperations = new Set(['channelPolicy', 'createChannelPairing', 'channelCallers', 'revokeChannelCaller']);
@@ -43,6 +44,9 @@ export function CloudRecovery({ pending, onResolved, triggerContainer }: { pendi
if (response.requires_input) {
setResult(null);
setNotice('这次微信验证还需要验证码。请进入「渠道 → 微信」,选择对应账号后继续连接验证。');
} else if (response.pending) {
setResult(null);
setNotice('这次操作还在确认中,请稍后再查看。');
} else if (response.retired) {
setResult(null);
setNotice('旧版联系人权限操作已移除,本机恢复记录已丢弃,微信账号的其他操作不受影响。');
+614 -120
View File
@@ -1,6 +1,8 @@
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
test('guides an empty channel workspace from the sidebar back to My Agents', async ({ launchElectronApp }, testInfo) => {
test('guides an empty channel workspace from the sidebar back to My Agents', async ({
launchElectronApp,
}, testInfo) => {
const app = await launchElectronApp({ skipSetup: true });
try {
const page = await getStableWindow(app);
@@ -13,19 +15,61 @@ test('guides an empty channel workspace from the sidebar back to My Agents', asy
Object.assign(globalThis, { cloudChannelEmptyFixtureCalls: calls });
const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } });
ipcMain.removeHandler('hostapi:fetch');
ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string; body?: string }) => {
const path = request.path ?? '';
if (path === '/api/auth/session/sync') return result({ success: true, session: { accessToken: 'ui-fixture', tokenType: 'Bearer', expiresAt: Date.now() + 3600000, lastActiveAt: Date.now(), canRefresh: false } });
if (path === '/api/auth/me') return result({ success: true, user: { username: 'creator', userId: 'creator', tenantId: null, deptId: null, authorities: [] }, moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true } });
if (path === '/api/works/user/agent-profile') return result({ success: true, profile: { display_name: '创作者', age: null, gender: null, avatar_url: null, share_age_with_agents: false, share_gender_with_agents: false, analysis_enabled: true, completed: true, version: 1, updated_at: '2026-10-13T00:00:00Z' } });
if (!path.startsWith('/api/cloud-agents/')) return result({ success: true });
const body = typeof request.body === 'string' ? JSON.parse(request.body) : request.body ?? {};
calls.push({ method: request.method ?? 'GET', path, operation: body.operation });
if (path.endsWith('/recovery')) return result({ recent: null, pending: [] });
if (path.endsWith('/agents') || path.endsWith('/bootstrap')) return result({ agents: [], next_cursor: null });
if (path.endsWith('/actions') && body.operation === 'channelAccounts') return result({ items: [] });
throw new Error('Unexpected empty channel fixture request ' + path);
});
ipcMain.handle(
'hostapi:fetch',
async (_event, request: { path?: string; method?: string; body?: string }) => {
const path = request.path ?? '';
if (path === '/api/auth/session/sync')
return result({
success: true,
session: {
accessToken: 'ui-fixture',
tokenType: 'Bearer',
expiresAt: Date.now() + 3600000,
lastActiveAt: Date.now(),
canRefresh: false,
},
});
if (path === '/api/auth/me')
return result({
success: true,
user: {
username: 'creator',
userId: 'creator',
tenantId: null,
deptId: null,
authorities: [],
},
moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true },
});
if (path === '/api/works/user/agent-profile')
return result({
success: true,
profile: {
display_name: '创作者',
age: null,
gender: null,
avatar_url: null,
share_age_with_agents: false,
share_gender_with_agents: false,
analysis_enabled: true,
completed: true,
version: 1,
updated_at: '2026-10-13T00:00:00Z',
},
});
if (!path.startsWith('/api/cloud-agents/')) return result({ success: true });
const body =
typeof request.body === 'string' ? JSON.parse(request.body) : (request.body ?? {});
calls.push({ method: request.method ?? 'GET', path, operation: body.operation });
if (path.endsWith('/recovery')) return result({ recent: null, pending: [] });
if (path.endsWith('/agents') || path.endsWith('/bootstrap'))
return result({ agents: [], next_cursor: null });
if (path.endsWith('/actions') && body.operation === 'channelAccounts')
return result({ items: [] });
throw new Error('Unexpected empty channel fixture request ' + path);
}
);
});
await page.goto(applicationUrl);
await page.getByTestId('ai-module-option-cloud_agents').click();
@@ -34,36 +78,70 @@ test('guides an empty channel workspace from the sidebar back to My Agents', asy
await expect(page).toHaveURL(/\/cloud-agents\/channels$/);
const hint = page.getByRole('heading', { name: '还没有可连接的智能体', exact: true });
await expect(hint).toBeVisible();
await expect(page.getByText('先创建一个智能体,配置完成后点击“开始使用”,就能连接到微信。', { exact: true })).toBeVisible();
await expect(
page.getByText('先创建一个智能体,配置完成后点击“开始使用”,就能连接到微信。', { exact: true })
).toBeVisible();
await expect(page.getByRole('button', { name: '添加第一个微信', exact: true })).toHaveCount(0);
await expect(page.getByRole('button', { name: '添加微信', exact: true })).toBeEnabled();
await page.screenshot({ path: testInfo.outputPath('wechat-no-agents-desktop.png'), fullPage: true });
await page.getByRole('button', { name: '添加微信', exact: true }).click();
const dialog = page.getByRole('dialog', { name: '添加微信', exact: true });
await expect(dialog.getByRole('heading', { name: '还没有可连接的智能体', exact: true })).toBeVisible();
await expect(
dialog.getByRole('heading', { name: '还没有可连接的智能体', exact: true })
).toBeVisible();
await expect(dialog.getByRole('button', { name: '下一步:扫码连接', exact: true })).toBeDisabled();
await dialog.getByRole('button', { name: '关闭', exact: true }).click();
await app.evaluate(({ BrowserWindow }) => { const window = BrowserWindow.getAllWindows()[0]; window.setMinimumSize(800, 600); window.setSize(980, 680); });
await app.evaluate(({ BrowserWindow }) => {
const window = BrowserWindow.getAllWindows()[0];
window.setMinimumSize(800, 600);
window.setSize(980, 680);
});
await expect(hint).toBeVisible();
await expect.poll(() => page.getByTestId('main-content').evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
await expect
.poll(() =>
page.getByTestId('main-content').evaluate((element) => element.scrollWidth - element.clientWidth)
)
.toBeLessThanOrEqual(1);
await page.screenshot({ path: testInfo.outputPath('wechat-no-agents-compact.png'), fullPage: true });
await page.getByRole('button', { name: '去我的智能体', exact: true }).click();
await expect(page).toHaveURL(/\/cloud-agents\?view=mine$/);
await expect(page.getByTestId('cloud-agents-page')).toBeVisible();
await expect(page.getByRole('heading', { name: '做一个属于你的 AI 助手吧', exact: true })).toBeVisible();
await expect(sidebar.getByRole('button', { name: '我的智能体', exact: true })).toHaveAttribute('aria-current', 'page');
const calls = await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelEmptyFixtureCalls: { method: string; operation?: string }[] }).cloudChannelEmptyFixtureCalls);
expect(calls.filter(call => call.operation).map(call => call.operation)).toEqual(['channelAccounts']);
expect(calls.every(call => call.method === 'GET' || call.operation === 'channelAccounts')).toBe(true);
await expect(
page.getByRole('heading', { name: '做一个属于你的 AI 助手吧', exact: true })
).toBeVisible();
await expect(sidebar.getByRole('button', { name: '我的智能体', exact: true })).toHaveAttribute(
'aria-current',
'page'
);
const calls = await app.evaluate(
() =>
(
globalThis as typeof globalThis & {
cloudChannelEmptyFixtureCalls: { method: string; operation?: string }[];
}
).cloudChannelEmptyFixtureCalls
);
expect(calls.filter((call) => call.operation).map((call) => call.operation)).toEqual([
'channelAccounts',
]);
expect(calls.every((call) => call.method === 'GET' || call.operation === 'channelAccounts')).toBe(
true
);
} finally {
await app.evaluate(({ BrowserWindow }) => { for (const window of BrowserWindow.getAllWindows()) window.destroy(); }).catch(() => undefined);
await app
.evaluate(({ BrowserWindow }) => {
for (const window of BrowserWindow.getAllWindows()) window.destroy();
})
.catch(() => undefined);
await closeElectronApp(app);
}
});
test('WeChat binding cards connect an account and switch its Agent through the channel workspace', async ({ launchElectronApp }, testInfo) => {
test('WeChat binding cards connect an account and switch its Agent through the channel workspace', async ({
launchElectronApp,
}, testInfo) => {
const app = await launchElectronApp({ skipSetup: true });
const agentSlug = 'ml-' + 'a'.repeat(32);
const secondAgentSlug = 'ml-' + 'b'.repeat(32);
@@ -77,69 +155,319 @@ test('WeChat binding cards connect an account and switch its Agent through the c
const calls: { operation: string; input: unknown }[] = [];
Object.assign(globalThis, { cloudChannelFixtureCalls: calls });
let verified = false;
let rejectUnbind = true;
const slug = 'ml-' + 'a'.repeat(32);
const configuration = { model: 'test-model', tools: [], knowledges: [], mcps: [], skills: [], preload_skills: [], subagents: [], tool_approval_mode: 'default', max_execution_steps: 40, max_output_tokens: 4096, max_run_seconds: 600 };
const draft = { slug, name: '我的创作助手', purpose: '整理选题与制作演示文稿', system_prompt: '使用简洁自然的中文', draft_revision: 2, updated_at: '2026-09-13T00:00:00Z', configuration, published_version: 1, enabled: true, archived: false };
const configuration = {
model: 'test-model',
tools: [],
knowledges: [],
mcps: [],
skills: [],
preload_skills: [],
subagents: [],
tool_approval_mode: 'default',
max_execution_steps: 40,
max_output_tokens: 4096,
max_run_seconds: 600,
};
const draft = {
slug,
name: '我的创作助手',
purpose: '整理选题与制作演示文稿',
system_prompt: '使用简洁自然的中文',
draft_revision: 2,
updated_at: '2026-09-13T00:00:00Z',
configuration,
published_version: 1,
enabled: true,
archived: false,
};
const secondSlug = 'ml-' + 'b'.repeat(32);
const secondDraft = { ...draft, slug: secondSlug, name: '资料整理助手', draft_revision: 1, published_version: 1 };
const channel = { id: 'wechat-a', address: 'wechat:personal', display_name: '创作微信', target_agent_address: 'yuxi:makelore:' + slug, route_revision: 3, provider_generation: 'generation-a', binding_id: 'binding-a', enabled: true, status: 'active', worker_online: true, agent_slug: slug, published_version: 1, desired_state: 'enabled', sync_state: 'ready', health: 'connected', last_confirmed_at: '2026-09-13T01:00:00Z', revision: 3, blockers: [], access_mode: 'self_only', policy_revision: 0, target_agent_slug: slug, target_agent_name: draft.name, target_agent_published_version: 1 };
const spare = { id: 'wechat-b', address: 'wechat:spare', display_name: '备用微信', target_agent_address: null, route_revision: 1, provider_generation: 'generation-b', binding_id: 'binding-b', enabled: false, status: 'paused', worker_online: true, agent_slug: null, published_version: null, desired_state: 'disabled', sync_state: 'ready', health: 'login_required', last_confirmed_at: null, revision: 1, blockers: ['target_required'], access_mode: 'self_only', policy_revision: 0, target_agent_slug: null, target_agent_name: null, target_agent_published_version: null };
const secondDraft = {
...draft,
slug: secondSlug,
name: '资料整理助手',
draft_revision: 1,
published_version: 1,
};
const channel = {
id: 'wechat-a',
address: 'wechat:personal',
display_name: '创作微信',
target_agent_address: 'yuxi:makelore:' + slug,
route_revision: 3,
provider_generation: 'generation-a',
binding_id: 'binding-a',
enabled: true,
status: 'active',
worker_online: true,
agent_slug: slug,
published_version: 1,
desired_state: 'enabled',
sync_state: 'ready',
health: 'connected',
last_confirmed_at: '2026-09-13T01:00:00Z',
revision: 3,
blockers: [],
access_mode: 'self_only',
policy_revision: 0,
target_agent_slug: slug,
target_agent_name: draft.name,
target_agent_published_version: 1,
};
const spare = {
id: 'wechat-b',
address: 'wechat:spare',
display_name: '备用微信',
target_agent_address: null,
route_revision: 1,
provider_generation: 'generation-b',
binding_id: 'binding-b',
enabled: false,
status: 'paused',
worker_online: true,
agent_slug: null,
published_version: null,
desired_state: 'disabled',
sync_state: 'ready',
health: 'login_required',
last_confirmed_at: null,
revision: 1,
blockers: ['target_required'],
access_mode: 'self_only',
policy_revision: 0,
target_agent_slug: null,
target_agent_name: null,
target_agent_published_version: null,
};
let channelAccounts = [channel, spare];
const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } });
ipcMain.removeHandler('hostapi:fetch');
ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string; body?: string }) => {
const path = request.path ?? '';
if (path === '/api/auth/session/sync') return result({ success: true, session: { accessToken: 'ui-fixture', tokenType: 'Bearer', expiresAt: Date.now() + 3600000, lastActiveAt: Date.now(), canRefresh: false } });
if (path === '/api/auth/me') return result({ success: true, user: { username: 'creator', userId: 'creator', tenantId: null, deptId: null, authorities: [] }, moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true } });
if (path === '/api/works/user/agent-profile') return result({ success: true, profile: { display_name: '创作者', age: null, gender: null, avatar_url: null, share_age_with_agents: false, share_gender_with_agents: false, analysis_enabled: true, completed: true, version: 1, updated_at: '2026-09-13T00:00:00Z' } });
if (!path.startsWith('/api/cloud-agents/')) return result({ success: true });
const body = typeof request.body === 'string' ? JSON.parse(request.body) : request.body ?? {};
if (path.endsWith('/recovery')) return result({ recent: null, pending: [] });
if (path.endsWith('/recovery/recent')) return result({ saved: true });
if (!path.endsWith('/actions')) return result(path.endsWith('/agents') || path.endsWith('/bootstrap') ? { agents: [draft, secondDraft], next_cursor: null } : path.endsWith(secondSlug) ? secondDraft : draft);
const { operation, input = {} } = body;
calls.push({ operation, input });
if (operation === 'threads') return result({ threads: [], next_offset: null });
if (operation === 'history') return result({ thread_id: 'desktop-thread', messages: [], run: null, next_offset: null });
if (operation === 'attachments' || operation === 'files') return result({ attachments: [], files: [] });
if (operation === 'catalog') return result({ models: [{ id: 'test-model', name: '创作模型' }], resources: {}, pricing: null });
if (operation === 'access') return result({ published_version: 1, enabled: true, versions: [], grants: [], applications: [], share_url: 'niancode://agents/' + slug });
if (operation === 'budget') return result({ request_limit_points: '2.50', daily_limit_points: '10.00', daily_committed_points: '0.20' });
if (operation === 'costs') return result({ items: [], next_offset: null, summary: { count: 0, settled_points: '0', pending_points: '0' } });
if (operation === 'channelAccounts') return result({ items: channelAccounts });
if (operation === 'createChannelAccount') {
const created = { ...spare, id: 'wechat-created', display_name: input.display_name, binding_id: 'binding-created' };
channelAccounts.push(created);
return result({ channel: created });
ipcMain.handle(
'hostapi:fetch',
async (_event, request: { path?: string; method?: string; body?: string }) => {
const path = request.path ?? '';
if (path === '/api/auth/session/sync')
return result({
success: true,
session: {
accessToken: 'ui-fixture',
tokenType: 'Bearer',
expiresAt: Date.now() + 3600000,
lastActiveAt: Date.now(),
canRefresh: false,
},
});
if (path === '/api/auth/me')
return result({
success: true,
user: {
username: 'creator',
userId: 'creator',
tenantId: null,
deptId: null,
authorities: [],
},
moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true },
});
if (path === '/api/works/user/agent-profile')
return result({
success: true,
profile: {
display_name: '创作者',
age: null,
gender: null,
avatar_url: null,
share_age_with_agents: false,
share_gender_with_agents: false,
analysis_enabled: true,
completed: true,
version: 1,
updated_at: '2026-09-13T00:00:00Z',
},
});
if (!path.startsWith('/api/cloud-agents/')) return result({ success: true });
const body =
typeof request.body === 'string' ? JSON.parse(request.body) : (request.body ?? {});
if (path.endsWith('/recovery')) return result({ recent: null, pending: [] });
if (path.endsWith('/recovery/recent')) return result({ saved: true });
if (!path.endsWith('/actions'))
return result(
path.endsWith('/agents') || path.endsWith('/bootstrap')
? { agents: [draft, secondDraft], next_cursor: null }
: path.endsWith(secondSlug)
? secondDraft
: draft
);
const { operation, input = {} } = body;
calls.push({ operation, input });
if (operation === 'threads') return result({ threads: [], next_offset: null });
if (operation === 'history')
return result({ thread_id: 'desktop-thread', messages: [], run: null, next_offset: null });
if (operation === 'attachments' || operation === 'files')
return result({ attachments: [], files: [] });
if (operation === 'catalog')
return result({
models: [{ id: 'test-model', name: '创作模型' }],
resources: {},
pricing: null,
});
if (operation === 'access')
return result({
published_version: 1,
enabled: true,
versions: [],
grants: [],
applications: [],
share_url: 'niancode://agents/' + slug,
});
if (operation === 'budget')
return result({
request_limit_points: '2.50',
daily_limit_points: '10.00',
daily_committed_points: '0.20',
});
if (operation === 'costs')
return result({
items: [],
next_offset: null,
summary: { count: 0, settled_points: '0', pending_points: '0' },
});
if (operation === 'channelAccounts') return result({ items: channelAccounts });
if (operation === 'createChannelAccount') {
const created = {
...spare,
id: 'wechat-created',
display_name: input.display_name,
binding_id: 'binding-created',
};
channelAccounts.push(created);
return result({ channel: created });
}
if (operation === 'routeChannelAccount') {
const stale = { ...channelAccounts.find((item) => item.id === input.channel_account_id) };
const target =
input.target_agent_slug === secondSlug
? secondDraft
: input.target_agent_slug === slug
? draft
: null;
channelAccounts = channelAccounts.map((item) =>
item.id !== input.channel_account_id
? item
: {
...item,
revision: item.revision + 1,
enabled: input.enabled,
target_agent_slug: target?.slug ?? null,
target_agent_name: target?.name ?? null,
target_agent_published_version: target?.published_version ?? null,
agent_slug: target?.slug ?? null,
published_version: target?.published_version ?? null,
target_agent_address: target ? 'yuxi:makelore:' + target.slug : null,
blockers: target ? [] : ['target_required'],
}
);
return result({ channel: stale, target_agent: target?.slug ?? null });
}
if (operation === 'enableChannelAccount' || operation === 'pauseChannelAccount') {
const enabled = operation === 'enableChannelAccount';
channelAccounts = channelAccounts.map((item) =>
item.id !== input.channel_account_id
? item
: {
...item,
revision: item.revision + 1,
enabled,
desired_state: enabled ? 'enabled' : 'disabled',
}
);
return result({
channel: channelAccounts.find((item) => item.id === input.channel_account_id),
});
}
if (operation === 'unbindChannelAccount') {
if (rejectUnbind) {
rejectUnbind = false;
return {
ok: true,
data: {
status: 409,
ok: false,
json: {
error: '解除暂时失败,请重试',
code: 'channel_operation_conflict',
commandOutcome: 'definitive_failure',
},
},
};
}
channelAccounts = channelAccounts.map((item) =>
item.id === input.channel_account_id
? {
...item,
health: 'login_required',
enabled: false,
desired_state: 'paused',
sync_state: 'ready',
target_agent_slug: null,
target_agent_name: null,
target_agent_address: '',
target_agent_published_version: null,
agent_slug: null,
published_version: null,
provider_generation: 'logged-out-generation',
revision: item.revision + 1,
}
: item
);
return result({
channel: channelAccounts.find((item) => item.id === input.channel_account_id),
disconnected: true,
unbound: true,
});
}
if (operation === 'channelBindings')
return result({
items: channelAccounts.filter((item) => item.target_agent_slug === input.slug),
available_accounts: [],
});
if (
operation === 'channelAccountWechatBindStart' ||
operation === 'channelAccountWechatBindStatus'
)
return result({
session_key: 'qr-session',
status: verified ? 'confirmed' : 'verification_required',
qrcode_url: 'https://login.example.test/confirm?token=fixture',
message: '请确认微信验证码',
});
if (operation === 'channelAccountWechatBindVerification') {
verified = true;
channelAccounts = channelAccounts.map((item) =>
item.id === input.channel_account_id ? { ...item, health: 'connected' } : item
);
return result({ session_key: 'qr-session', status: 'confirmed', message: '连接已确认' });
}
if (operation === 'schedules') return result({ jobs: [] });
if (operation === 'scheduleContext')
return result({
version: 1,
enabled: true,
configuration,
result_destination: '任务历史',
payer: 'creator',
});
throw new Error('Unexpected channel UI fixture operation ' + operation);
}
if (operation === 'routeChannelAccount') {
const target = input.target_agent_slug === secondSlug ? secondDraft : input.target_agent_slug === slug ? draft : null;
channelAccounts = channelAccounts.map(item => item.id !== input.channel_account_id ? item : { ...item, revision: item.revision + 1, enabled: input.enabled,
target_agent_slug: target?.slug ?? null, target_agent_name: target?.name ?? null, target_agent_published_version: target?.published_version ?? null,
agent_slug: target?.slug ?? null, published_version: target?.published_version ?? null, target_agent_address: target ? 'yuxi:makelore:' + target.slug : null,
blockers: target ? [] : ['target_required'] });
return result({ channel: channelAccounts.find(item => item.id === input.channel_account_id), target_agent: target?.slug ?? null });
}
if (operation === 'enableChannelAccount' || operation === 'pauseChannelAccount') {
const enabled = operation === 'enableChannelAccount';
channelAccounts = channelAccounts.map(item => item.id !== input.channel_account_id ? item : { ...item, revision: item.revision + 1, enabled, desired_state: enabled ? 'enabled' : 'disabled' });
return result({ channel: channelAccounts.find(item => item.id === input.channel_account_id) });
}
if (operation === 'disconnectChannelAccount') {
channelAccounts = channelAccounts.map(item => item.id === input.channel_account_id ? { ...item, health: 'login_required', enabled: false, revision: item.revision + 1 } : item);
return result({ channel: channelAccounts.find(item => item.id === input.channel_account_id), disconnected: true });
}
if (operation === 'channelBindings') return result({ items: channelAccounts.filter(item => item.target_agent_slug === input.slug), available_accounts: [] });
if (operation === 'channelAccountWechatBindStart' || operation === 'channelAccountWechatBindStatus') return result({ session_key: 'qr-session', status: verified ? 'confirmed' : 'verification_required', qrcode_url: 'https://login.example.test/confirm?token=fixture', message: '请确认微信验证码' });
if (operation === 'channelAccountWechatBindVerification') { verified = true; channelAccounts = channelAccounts.map(item => item.id === input.channel_account_id ? { ...item, health: 'connected' } : item); return result({ session_key: 'qr-session', status: 'confirmed', message: '连接已确认' }); }
if (operation === 'schedules') return result({ jobs: [] });
if (operation === 'scheduleContext') return result({ version: 1, enabled: true, configuration, result_destination: '任务历史', payer: 'creator' });
throw new Error('Unexpected channel UI fixture operation ' + operation);
});
);
});
await page.goto(applicationUrl);
await page.getByTestId('ai-module-option-cloud_agents').click();
await page.getByRole('article', { name: '我的创作助手', exact: true }).getByRole('button', { name: '和它聊聊', exact: true }).click();
await page
.getByRole('article', { name: '我的创作助手', exact: true })
.getByRole('button', { name: '和它聊聊', exact: true })
.click();
await expect(page.getByRole('region', { name: '智能体对话', exact: true })).toBeVisible();
await expect(page.getByRole('heading', { name: '我的创作助手', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '微信对话', exact: true })).toHaveCount(0);
@@ -151,7 +479,7 @@ test('WeChat binding cards connect an account and switch its Agent through the c
const legacyCard = page.getByRole('article', { name: '创作微信微信', exact: true });
const spareCard = page.getByRole('article', { name: '备用微信微信', exact: true });
await expect(legacyCard.getByText('我的创作助手', { exact: true })).toBeVisible();
await expect(spareCard.getByRole('button', { name: '继续绑定', exact: true })).toBeVisible();
await expect(spareCard.getByRole('button', { name: '绑定智能体', exact: true })).toBeVisible();
await expect(page.getByRole('dialog')).toHaveCount(0);
await expect(page.getByText('活动记录', { exact: true })).toHaveCount(0);
await expect(page.getByRole('button', { name: '微信对话', exact: true })).toHaveCount(0);
@@ -159,59 +487,225 @@ test('WeChat binding cards connect an account and switch its Agent through the c
await page.getByRole('button', { name: '添加微信', exact: true }).click();
const adding = page.getByRole('dialog', { name: '添加微信', exact: true });
await adding.getByLabel('选择智能体', { exact: true }).selectOption(agentSlug);
await adding.getByLabel('微信备注(可选)', { exact: true }).fill('新创作微信');
await adding
.getByRole('radiogroup', { name: '选择智能体' })
.getByText('我的创作助手', { exact: true })
.click();
await expect(adding.getByRole('radio', { name: '我的创作助手', exact: true })).toBeChecked();
await adding.getByRole('button', { name: '下一步:扫码连接', exact: true }).click();
await expect(adding.getByRole('img', { name: '个人微信登录二维码', exact: true })).toHaveAttribute('src', /^data:image\//);
await expect.poll(async () => app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string; input: Record<string, unknown> }[] }).cloudChannelFixtureCalls
.filter(call => call.operation === 'routeChannelAccount').some(call => call.input.channel_account_id === 'wechat-created' && call.input.target_agent_slug === 'ml-' + 'a'.repeat(32) && call.input.enabled === false))).toBe(true);
await expect(adding.getByRole('button', { name: '完成绑定并启用', exact: true })).toBeDisabled();
await expect(adding.getByRole('img', { name: '个人微信登录二维码', exact: true })).toHaveAttribute(
'src',
/^data:image\//
);
await expect
.poll(async () =>
app.evaluate(() =>
(
globalThis as typeof globalThis & {
cloudChannelFixtureCalls: { operation: string; input: Record<string, unknown> }[];
}
).cloudChannelFixtureCalls
.filter((call) => call.operation === 'routeChannelAccount')
.some(
(call) =>
call.input.channel_account_id === 'wechat-created' &&
call.input.target_agent_slug === 'ml-' + 'a'.repeat(32) &&
call.input.enabled === false
)
)
)
.toBe(true);
await expect(adding.getByRole('button', { name: '完成绑定并启用', exact: true })).toHaveCount(0);
await adding.getByLabel('微信验证码', { exact: true }).fill('123456');
await adding.getByRole('button', { name: '提交验证码', exact: true }).click();
await expect(adding.getByRole('img', { name: '个人微信登录二维码', exact: true })).toHaveCount(0);
await expect(adding.getByLabel('微信验证码', { exact: true })).toHaveCount(0);
await expect(adding.getByText('微信已连接', { exact: true })).toBeVisible();
expect(await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] }).cloudChannelFixtureCalls.some(call => call.operation === 'enableChannelAccount'))).toBe(false);
await page.screenshot({ path: testInfo.outputPath('wechat-connection-confirmed.png'), fullPage: true });
await adding.getByRole('button', { name: '完成绑定并启用', exact: true }).click();
await expect(adding.getByRole('heading', { name: '微信绑定成功', exact: true })).toBeVisible();
expect(
await app.evaluate(
() =>
(
globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] }
).cloudChannelFixtureCalls.filter((call) => call.operation === 'enableChannelAccount').length
)
).toBe(1);
await page.screenshot({
path: testInfo.outputPath('wechat-automatically-connected.png'),
fullPage: true,
});
await expect(adding.getByRole('button', { name: '完成', exact: true })).toBeVisible();
await adding.getByRole('button', { name: '完成', exact: true }).click();
await expect(page.getByRole('article', { name: '新创作微信微信', exact: true }).getByText('我的创作助手', { exact: true })).toBeVisible();
await expect(
page
.getByRole('article', { name: '微信 3微信', exact: true })
.getByText('我的创作助手', { exact: true })
).toBeVisible();
await legacyCard.getByRole('button', { name: '管理绑定', exact: true }).click();
const managing = page.getByRole('dialog', { name: '管理微信绑定', exact: true });
const qrStartsBeforeSwitch = await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] }).cloudChannelFixtureCalls.filter(call => call.operation === 'channelAccountWechatBindStart').length);
await managing.getByLabel('绑定的智能体', { exact: true }).selectOption(secondAgentSlug);
await managing.getByRole('button', { name: '保存更换', exact: true }).click();
const switching = page.getByRole('dialog', { name: '更换绑定的智能体', exact: true });
await switching.getByRole('button', { name: /确认更换/ }).click();
await expect.poll(async () => app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string; input: Record<string, unknown> }[] }).cloudChannelFixtureCalls
.filter(call => call.operation === 'routeChannelAccount').some(call => call.input.channel_account_id === 'wechat-a' && call.input.target_agent_slug === 'ml-' + 'b'.repeat(32) && call.input.enabled === true))).toBe(true);
await expect(managing.getByText('已更换绑定的智能体,微信连接保留。', { exact: true })).toBeVisible();
await expect(managing.getByLabel('绑定的智能体', { exact: true })).toHaveValue(secondAgentSlug);
expect(await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] }).cloudChannelFixtureCalls.filter(call => call.operation === 'channelAccountWechatBindStart').length)).toBe(qrStartsBeforeSwitch);
await legacyCard.getByRole('button', { name: '更换智能体', exact: true }).click();
const managing = page.getByRole('dialog', { name: '更换智能体', exact: true });
const qrStartsBeforeSwitch = await app.evaluate(
() =>
(
globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] }
).cloudChannelFixtureCalls.filter((call) => call.operation === 'channelAccountWechatBindStart')
.length
);
await managing
.getByRole('radiogroup', { name: '选择智能体' })
.getByText('资料整理助手', { exact: true })
.click();
await expect(managing.getByRole('radio', { name: '资料整理助手', exact: true })).toBeChecked();
await expect(page.getByRole('dialog')).toHaveCount(1);
await page.screenshot({ path: testInfo.outputPath('wechat-agent-choice.png'), fullPage: true });
await managing.getByRole('button', { name: '确认更换', exact: true }).click();
await expect
.poll(async () =>
app.evaluate(() =>
(
globalThis as typeof globalThis & {
cloudChannelFixtureCalls: { operation: string; input: Record<string, unknown> }[];
}
).cloudChannelFixtureCalls
.filter((call) => call.operation === 'routeChannelAccount')
.some(
(call) =>
call.input.channel_account_id === 'wechat-a' &&
call.input.target_agent_slug === 'ml-' + 'b'.repeat(32) &&
call.input.enabled === true
)
)
)
.toBe(true);
await expect(managing).toHaveCount(0);
await expect(page.getByText('已更换智能体,微信连接和历史保留。', { exact: true })).toBeVisible();
expect(
await app.evaluate(
() =>
(
globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] }
).cloudChannelFixtureCalls.filter((call) => call.operation === 'channelAccountWechatBindStart')
.length
)
).toBe(qrStartsBeforeSwitch);
await expect(page.getByText('活动记录', { exact: true })).toHaveCount(0);
await expect(page.getByRole('button', { name: '微信对话', exact: true })).toHaveCount(0);
await expect(page.getByText(/本人|受邀|邀请|使用范围|使用权限|授权调用者/)).toHaveCount(0);
await app.evaluate(({ BrowserWindow }) => { const win = BrowserWindow.getAllWindows()[0]; win.setMinimumSize(800, 600); win.setSize(980, 680); });
await expect.poll(() => page.getByTestId('main-content').evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
await page.screenshot({ path: testInfo.outputPath('wechat-manage-binding-compact.png'), fullPage: true });
await managing.getByRole('button', { name: '关闭', exact: true }).click();
await expect(managing).toHaveCount(0);
await expect(legacyCard.getByText('资料整理助手', { exact: true })).toBeVisible();
const finalCalls = await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string; input: Record<string, unknown> }[] }).cloudChannelFixtureCalls);
expect(finalCalls.some(call => ['channelActivity', 'channelConversations', 'channelConversation', 'channelConversationControl', 'channelFiles', 'downloadChannelArtifact', 'channelPolicy', 'createChannelPairing', 'channelCallers', 'channelSelfCallers', 'revokeChannelCaller', 'submit', 'preview', 'resume'].includes(call.operation))).toBe(false);
expect(finalCalls.filter(call => call.operation === 'createChannelAccount')).toHaveLength(1);
expect(finalCalls.find(call => call.operation === 'createChannelAccount')?.input).toEqual({ display_name: '新创作微信', operation_id: expect.any(String) });
expect(finalCalls.find(call => call.operation === 'channelAccountWechatBindVerification')?.input).toEqual({ channel_account_id: 'wechat-created', session_key: 'qr-session', verify_code: '123456', operation_id: expect.any(String) });
expect(finalCalls.find(call => call.operation === 'enableChannelAccount')?.input).toEqual({ channel_account_id: 'wechat-created', expected_revision: 2, operation_id: expect.any(String) });
expect(finalCalls.filter(call => call.operation === 'routeChannelAccount').map(call => call.input)).toEqual([
{ channel_account_id: 'wechat-created', target_agent_slug: agentSlug, expected_revision: 1, enabled: false, operation_id: expect.any(String) },
{ channel_account_id: 'wechat-a', target_agent_slug: secondAgentSlug, expected_revision: 3, enabled: true, operation_id: expect.any(String) },
]);
await app.evaluate(({ BrowserWindow }) => {
const win = BrowserWindow.getAllWindows()[0];
win.setMinimumSize(800, 600);
win.setSize(980, 680);
});
await expect
.poll(() =>
page.getByTestId('main-content').evaluate((element) => element.scrollWidth - element.clientWidth)
)
.toBeLessThanOrEqual(1);
await page.screenshot({
path: testInfo.outputPath('wechat-binding-cards-compact.png'),
fullPage: true,
});
await expect(legacyCard.getByText('资料整理助手', { exact: true })).toBeVisible();
const finalCalls = await app.evaluate(
() =>
(
globalThis as typeof globalThis & {
cloudChannelFixtureCalls: { operation: string; input: Record<string, unknown> }[];
}
).cloudChannelFixtureCalls
);
expect(
finalCalls.some((call) =>
[
'channelActivity',
'channelConversations',
'channelConversation',
'channelConversationControl',
'channelFiles',
'downloadChannelArtifact',
'channelPolicy',
'createChannelPairing',
'channelCallers',
'channelSelfCallers',
'revokeChannelCaller',
'submit',
'preview',
'resume',
].includes(call.operation)
)
).toBe(false);
expect(finalCalls.filter((call) => call.operation === 'createChannelAccount')).toHaveLength(1);
expect(finalCalls.find((call) => call.operation === 'createChannelAccount')?.input).toEqual({
display_name: '微信 3',
operation_id: expect.any(String),
});
expect(
finalCalls.find((call) => call.operation === 'channelAccountWechatBindVerification')?.input
).toEqual({
channel_account_id: 'wechat-created',
session_key: 'qr-session',
verify_code: '123456',
operation_id: expect.any(String),
});
expect(finalCalls.find((call) => call.operation === 'enableChannelAccount')?.input).toEqual({
channel_account_id: 'wechat-created',
expected_revision: 2,
operation_id: expect.any(String),
});
expect(
finalCalls.filter((call) => call.operation === 'routeChannelAccount').map((call) => call.input)
).toEqual([
{
channel_account_id: 'wechat-created',
target_agent_slug: agentSlug,
expected_revision: 1,
enabled: false,
operation_id: expect.any(String),
},
{
channel_account_id: 'wechat-a',
target_agent_slug: secondAgentSlug,
expected_revision: 3,
enabled: true,
operation_id: expect.any(String),
},
]);
await legacyCard.getByRole('button', { name: '更多操作', exact: true }).click();
await page.screenshot({ path: testInfo.outputPath('wechat-binding-more-compact.png'), fullPage: true });
await expect(legacyCard.getByRole('button', { name: '暂停自动回复', exact: true })).toBeVisible();
await legacyCard.getByRole('button', { name: '解除微信绑定', exact: true }).click();
const unbinding = page.getByRole('dialog', { name: '解除微信绑定', exact: true });
await unbinding.getByRole('button', { name: '确认解除微信绑定', exact: true }).click();
await expect(unbinding.getByRole('alert')).toHaveText(/解除暂时失败/);
await expect(page.getByRole('dialog')).toHaveCount(1);
await page.screenshot({
path: testInfo.outputPath('wechat-unbind-visible-failure.png'),
fullPage: true,
});
await unbinding.getByRole('button', { name: '确认解除微信绑定', exact: true }).click();
await expect(unbinding).toHaveCount(0);
await expect(legacyCard.getByText('未绑定', { exact: true })).toBeVisible();
await expect(legacyCard.getByRole('button', { name: '绑定智能体', exact: true })).toBeVisible();
await expect(page.getByText('断开微信连接', { exact: true })).toHaveCount(0);
await page.screenshot({
path: testInfo.outputPath('wechat-unbound-card-compact.png'),
fullPage: true,
});
const unbindCalls = await app.evaluate(() =>
(
globalThis as typeof globalThis & {
cloudChannelFixtureCalls: { operation: string; input: Record<string, unknown> }[];
}
).cloudChannelFixtureCalls.filter((call) => call.operation === 'unbindChannelAccount')
);
expect(unbindCalls).toHaveLength(2);
expect(unbindCalls[0].input).toEqual(unbindCalls[1].input);
} finally {
await app.evaluate(({ BrowserWindow }) => { for (const window of BrowserWindow.getAllWindows()) window.destroy(); }).catch(() => undefined);
await app
.evaluate(({ BrowserWindow }) => {
for (const window of BrowserWindow.getAllWindows()) window.destroy();
})
.catch(() => undefined);
await closeElectronApp(app);
}
});
File diff suppressed because it is too large. Load diff
+62
View File
@@ -592,6 +592,68 @@ describe('Main cloud Agents boundary', () => {
.toBe('/api/makelore/channel-operations/op-route');
});
it('limits unified WeChat unbinding to its owned account and revision contract', () => {
const plan = operationPlan({ operation: 'unbindChannelAccount', input: {
channel_account_id: 'wechat-1', operation_id: input.operation_id, expected_revision: 2,
target_agent_slug: draft.slug, access_token: 'private',
} });
expect(plan.path).toBe('/api/makelore/channel-accounts/wechat-1/unbind');
expect(plan.method).toBe('POST');
expect(plan.body).toEqual({ operation_id: input.operation_id, expected_revision: 2 });
expect(plan.project({ channel: { id: 'wechat-1', desired_state: 'paused', sync_state: 'ready', access_token: 'private' },
unbound: true, disconnected: true, wechat: { status: 'empty', token: 'private' } })).toEqual({
channel: { id: 'wechat-1', desired_state: 'paused', sync_state: 'ready' },
unbound: true, disconnected: true, wechat: { status: 'empty' },
});
for (const invalid of [
{ operation_id: input.operation_id, expected_revision: 2 },
{ channel_account_id: 'wechat-1', expected_revision: 2 },
{ channel_account_id: 'wechat-1', operation_id: input.operation_id },
{ channel_account_id: '../other', operation_id: input.operation_id, expected_revision: 2 },
]) expect(() => operationPlan({ operation: 'unbindChannelAccount', input: invalid })).toThrow();
});
it.each([
{ health: 'connected' }, { enabled: true }, { target_agent_slug: draft.slug }, { id: 'another-account' },
])('keeps unified unbinding pending when a success response contains unfinished account state %o', async unfinished => {
const channel = { id: 'wechat-1', enabled: false, target_agent_slug: null, health: 'login_required', ...unfinished };
const fetchImpl = vi.fn().mockResolvedValueOnce(session())
.mockResolvedValueOnce(json({ channel, unbound: true, disconnected: true }));
const module = moduleFor(fetchImpl);
const original = { channel_account_id: 'wechat-1', operation_id: input.operation_id, expected_revision: 2 };
await expect(module.execute({ operation: 'unbindChannelAccount', input: original })).rejects.toMatchObject({
code: 'channel_operation_unconfirmed', commandOutcome: 'unknown',
});
expect((await module.recovery()).pending).toEqual([expect.objectContaining({
operation: 'unbindChannelAccount', input: original,
})]);
});
it('requires compound completion and retries the same unbind intent after a partial success response', async () => {
const channel = { id: 'wechat-1', enabled: false, target_agent_slug: null, health: 'login_required' };
const fetchImpl = vi.fn().mockResolvedValueOnce(session())
.mockResolvedValueOnce(json({ channel, disconnected: true }))
.mockResolvedValueOnce(json({ channel, unbound: true, disconnected: true }));
const module = moduleFor(fetchImpl);
const original = { channel_account_id: 'wechat-1', operation_id: input.operation_id, expected_revision: 2 };
await expect(module.execute({ operation: 'unbindChannelAccount', input: original })).rejects.toMatchObject({
code: 'channel_operation_unconfirmed',
});
const pending = (await module.recovery()).pending[0];
expect(await module.resolvePending({ id: pending.id })).toMatchObject({
operation: 'unbindChannelAccount', input: original, result: { channel, unbound: true, disconnected: true },
});
expect((await module.recovery()).pending).toEqual([]);
expect(fetchImpl.mock.calls.slice(1).map(call => [call[0], JSON.parse(call[1].body)])).toEqual([
['https://agents.example.test/api/makelore/channel-accounts/wechat-1/unbind', {
operation_id: input.operation_id, expected_revision: 2,
}],
['https://agents.example.test/api/makelore/channel-accounts/wechat-1/unbind', {
operation_id: input.operation_id, expected_revision: 2,
}],
]);
});
it('recovers a completed WeChat verification from its receipt without replaying the code', async () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response'))
.mockResolvedValueOnce(json({ operation_id: input.operation_id, status: 'completed', result: { channel: { id: 'channel-1' } } }));
+15
View File
@@ -151,6 +151,21 @@ it('offers only explicit discard for retired operations and explains the retired
expect(api.resolvePending).toHaveBeenCalledWith(retired.id, false);
});
it('keeps a channel operation visibly pending without claiming a confirmed result', async () => {
const operation: CloudPendingOperation = { id: 'unbind:original', operation: 'unbindChannelAccount',
input: { channel_account_id: 'wechat-1', operation_id: 'original-unbind', expected_revision: 2 },
created_at: '2026-10-13T04:00:00Z' };
api.resolvePending.mockResolvedValue({ operation: operation.operation, pending: true, result: { status: 'pending' } });
render(<CloudRecovery pending={[operation]} onResolved={resolved} />);
const dialog = await openRecords();
expect(within(dialog).getByRole('article', { name: '解除微信绑定' })).toBeVisible();
fireEvent.click(within(dialog).getByRole('button', { name: '再试一次' }));
expect(await within(dialog).findByRole('status')).toHaveTextContent('这次操作还在确认中');
expect(within(dialog).queryByText(/结果已确认/)).not.toBeInTheDocument();
expect(within(dialog).getByRole('article', { name: '解除微信绑定' })).toBeVisible();
expect(resolved).toHaveBeenCalledOnce();
});
it('renders no entry or dialog when there are no pending records or retained results', () => {
render(<CloudRecovery pending={[]} onResolved={resolved} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();