diff --git a/.project-docs/30-worklog/tasks/20261013-wechat-yuxi-adapter-9a7c32e1.md b/.project-docs/30-worklog/tasks/20261013-wechat-yuxi-adapter-9a7c32e1.md
new file mode 100644
index 00000000..7c9c8a32
--- /dev/null
+++ b/.project-docs/30-worklog/tasks/20261013-wechat-yuxi-adapter-9a7c32e1.md
@@ -0,0 +1,58 @@
+# Task: 对接最新版Yuxi微信恢复状态
+
+## Identity
+
+- Task ID: 20261013-wechat-yuxi-adapter-9a7c32e1
+- Mode: Feature
+- Branch: codex/20261013-wechat-yuxi-adapter-9a7c32e1-wechat-yuxi-adapter
+- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261013-wechat-yuxi-adapter-9a7c32e1
+- Base commit: 70166d22b3c4bebd76e3f565d32b122c2f1e2dee
+- Owner: codex
+- Status: Ready for Integration
+
+## Scope
+
+- Adapt the Makelore client to Yuxi d72aecf/ab05f82 while preserving the existing one-WeChat/one-Agent flow and public request contract. Block conflicting mutations during authoritative provider logout pending; retain and explicitly recover the original operation.
+- Project current backend error codes into actionable messages and distinguish old requests that cannot safely replay from uncertain operations that can retry. Preserve original recovery evidence and all unrelated create/publish/key/knowledge behavior.
+- Prevent a known disabled target Agent from being displayed as usable using the existing Agent catalog. Update focused unit and Electron contract/interaction tests, README and this record.
+
+## Intent And Constraints
+
+- User authorizes client integration and explicitly forbids changing Yuxi. Yuxi/Core/Adapter sources, schemas, deployment, real WeChat state and model calls remain untouched. No planning-with-files workflow is used.
+- Concurrent Gate Passed: official check/start/status succeeded; task ID, feature mode, absolute worktree, branch and base match Identity. Fresh reviewer read all 53 peer records/159 Scope/Intent/Promotion sections. Existing channel/recovery tasks are ready and compatible; active navigation/knowledge/draft tasks are unrelated. One legacy child-language placeholder is unknown but has no channel dependency. The occupied dirty primary and canonical documents stay read-only.
+- Project Context Loaded / Gate Passed: startup/memory/own record, positioning, current integrated snapshot, accepted personal-cloud and user-owned channel decisions, system/module/data-flow, domain/success, community boundary and evidence/reflection/commitment indexes loaded. Main owns network/auth and immutable original operations; Yuxi owns execution and internal five-condition Core logout guard. Current source supports explicit QR activation, pause-preserving reconnect, final readback, safe unavailable-target stopping and original-ID recovery.
+- Latest Yuxi adds provider logout pending, durable route/logout intent and business schema 13. Core/Adapter compatibility, actual deployed versions, database migration and true WeChat/Worker/Redis acceptance are external release prerequisites. Ordinary-text Worker receive blocking and disabled-Agent server projection are backend limitations found in the preceding read-only assessment; this task does not repair backend code.
+- Implementation: bound page/transport/recovery changes with independent file ownership; preserve generic recovery semantics; focused unit/type/lint/build checks then isolated Electron verification and fresh independent review. Save reviewable local commits, without primary merge, push, production changes or deleting any worktree.
+
+## Outcome
+
+- Client-only integration implemented against Yuxi `d72aecfa8932da24f2c89dadcc37cf69c485799d`. The existing one-WeChat/one-Agent flow, public API, authentication ownership and immutable original operations remain intact.
+- `wechat_disconnect_pending` blocks all same-owner new management actions, including an already-open dialog and when no local recovery journal exists. Every new mutation first reads authoritative channel state; failed reads and concurrent pending observations prevent writes and operation-ID allocation. Refresh and explicit original-operation recovery remain available.
+- Full paginated Agent metadata distinguishes a known disabled target from a usable channel and excludes disabled/unpublished targets from selection. Safe pause/unbind and switching to an available Agent remain possible.
+- Main exposes actionable legacy/identity/target errors and rejects compound unbind success if the returned state remains pending/degraded/enabled, retaining the original journal. Recovery permanently blocks unsafe legacy replay; recoverable identity/target errors offer explicit readonly refresh, then separate original-record confirmation, without automatic replay or new IDs. Local reminder deletion does not cancel a server operation.
+- Independent review found a recoverable-error UI lock and a timing-sensitive test helper. Both were corrected: state refresh restores explicit original-record confirmation; the helper waits for the actual enabled control rather than clicking during startup. Final source review passed with no remaining material findings.
+- README is synchronized with the client behavior and external release prerequisites. No Yuxi/Core/Adapter source or schema changes, no primary adoption/merge/push, and no changes to the user's running app or real WeChat accounts. Isolated Electron fixture verification passed; the client result is ready for integration.
+
+## Verification
+
+- Exact package manager: `corepack pnpm` 10.33.4 in this owned worktree.
+- Final relevant unit regression: `corepack pnpm exec vitest run tests/unit/cloud-agents-main.test.ts tests/unit/channel-accounts-page.test.tsx 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=3`: 6 files / 248 tests passed. Covers owner-level pending locks, late state reads/open dialogs, failed preflight, disabled targets, strict unbind confirmation, original-ID recovery, dynamic attention refresh and no late recovery replay after unmount, with unrelated channel/conversation/Agent regression coverage.
+- `corepack pnpm typecheck` and `corepack pnpm lint:check`: exit 0. Eight warnings are in unchanged CloudChat/CloudCosts/Home/Makelore files; changed source/test scoped ESLint is clean.
+- `corepack pnpm run build:vite`: exit 0 for Renderer, Main, Preload and Utility. Existing Browserslist/chunk/import warnings remain; generated output is ignored and will not be committed.
+- Independent read-only reviewer: source PASS; separately ran Main 111 + channel page 57 + recovery 24 = 192 tests passed and `git diff --check` passed. No backend, production account, model or runtime mutations were used for review.
+- Final isolated Electron command: `/usr/local/bin/corepack pnpm exec playwright test tests/e2e/cloud-agent-channels.spec.ts --workers=1 --reporter=list`: 2/2 passed (8.7 s), after the final production build. Existing connect/switch/pause/unbind flow plus server pending with no local journal, same-owner lock, already-open dialog guard, disabled-target safe actions and legacy recovery/discard behavior passed. Fixture replaces Host API IPC and uses isolated user data; this is client acceptance, not real Yuxi/Core/WeChat acceptance.
+- Seven desktop/compact screenshots were visually reviewed by the UI agent. Root independently inspected the disabled-target actions and legacy recovery screenshots; duplicate pending-receipt text was removed, then recovery 24/24, scoped ESLint, production build and both Electron scenarios passed again. Final legacy screenshot was checked after that build; no clipping, content overlap or false success. Screenshots remain ignored under `test-results/cloud-agent-channels-WeCha-c7840-rough-the-channel-workspace/`.
+- Final independent source/spec/docs review and one-line message de-duplication review: PASS, no remaining material findings. Final source SHA256: Main `3275b5781195953c1f2eae0962d3ca55250d281ec1a187a6c49894340c09b5ce`, Channel page `b01cd4bf8c728afcfd3948bcea2572887e41416b106ebefbcc4094f30be5cc0e`, Recovery `d3c9cad5e31cef8d66a2a99f5db5750e5bf972d113fcd3e1fd5a9c94f68d97bf`; final Electron spec `839087eadd94559230e9466a35970d6338aeb6cb58f1ae99e183676f62c175f3`.
+- Project-doc ownership drift check and final Git checks passed. Local result is committed on the owned feature branch; the worktree is retained for integration.
+- Read-only Yuxi final check: HEAD remains `d72aecfa8932da24f2c89dadcc37cf69c485799d`; only the pre-existing foreign untracked assessment record is present, with no tracked source changes.
+
+## Follow-ups
+
+- Integrate the local client commit into the maintained client branch/runtime through the owning integration task. The primary checkout is occupied and has foreign changes; this task does not adopt or overwrite it.
+- Before a production rollout, verify deployed Yuxi/Core/Node Adapter compatibility and business schema 13 / knowledge schema 2, then perform real WeChat/Worker/Redis acceptance. Existing backend three-party HTTP/PG probe records do not prove the external WeChat or model execution path. This task does not deploy or migrate the backend.
+- Backend assessment limitations remain: an ordinary-text Worker handler waits for the run result before consuming later AgentBus input (including WeChat stop), and disabling an Agent can leave server channel availability stale until the backend projection is repaired. Client catalog compensation does not fix either backend mechanism. No backend change is included.
+
+## Promotion Candidates
+
+- Target: `.project-docs/30-worklog/current-state.md` and accepted personal-cloud channel architecture during serialized integration. Proposal: record client owner-level logout-pending lock, known-disabled target display, strict unbind readback and explicit original-record recovery behavior. Evidence: source diff and 248 passing focused unit tests, final build, isolated Electron 2/2 and independent review PASS. Future impact: subsequent channel changes must preserve immutable operation identity and distinguish server pending from completed logout. Semantic conflicts: no change to the accepted one-WeChat/one-Agent or server-owned identity/execution boundary. Human confirmation is not required for these facts; canonical writes remain reserved to the integration owner.
+- Target: current release-readiness evidence in canonical project memory. Proposal: latest Yuxi source has schema/compatible Core prerequisites and known Worker/projection limitations; retain those as unverified live-release conditions, not a claim of real-WeChat completion. Evidence: read-only Yuxi `d72aecf` assessment and local client-only scope. Future impact: prevent a client fixture PASS from being mistaken for deployed backend acceptance. No semantic conflict with prior backend source work; production state must be verified independently.
diff --git a/README.md b/README.md
index 04a64db9..10f5cf9d 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智
云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。
- Agents 侧栏的「渠道」按每个微信一张卡展示备注、当前智能体与使用状态;一个微信同时只绑定一个智能体,多个微信可以使用同一智能体。常用操作是“更换智能体”,在同一个选择窗口中确认;暂停自动回复和解除微信绑定放在“更多操作”。暂停停止当前任务并保留登录、绑定和历史;更换保留登录与原启停状态;解除微信绑定是完整操作,停止任务、清除目标并退出微信登录,历史保留,再使用需扫码。掉线显示重新扫码入口,暂停显示继续回复,未知或未同步状态显示正在确认,不宣称可用。添加流程为选择已开始使用的智能体、扫码、自动完成绑定;只有这次用户发起的绑定在扫码确认、目标正确、启用成功且最终状态回读可用后才显示完成,不因页面打开、旧记录或验证码提交自动启用。无可用智能体时说明创建和“开始使用”的前提,加载失败不冒充空态,已有微信仍可管理或解绑。Main 持有凭据与原操作恢复;解除需要配套 Yuxi 用户级 `/channel-accounts/{id}/unbind`,旧后端不会通过改按钮文案或组合未确认操作假装完成。变更后以最新读取状态为准,未完成操作保留原身份供显式恢复;失败在当前窗口可见,结果未知不自动重放。渠道页不展示微信聊天或活动,既有历史、费用、内容隔离与应用 API 保持原边界。实际微信身份及重复扫码识别仍由云端负责,不从备注推测。
+ Agents 侧栏的「渠道」按每个微信一张卡展示备注、当前智能体与使用状态;一个微信同时只绑定一个智能体,多个微信可以使用同一智能体。常用操作是“更换智能体”,在同一个选择窗口中确认;暂停自动回复和解除微信绑定放在“更多操作”。暂停停止当前任务并保留登录、绑定和历史;更换保留登录与原启停状态;解除微信绑定是完整操作,停止任务、清除目标并退出微信登录,历史保留,再使用需扫码。掉线显示重新扫码入口,暂停显示继续回复,未知或未同步状态显示正在确认,不宣称可用。添加流程为选择已开始使用的智能体、扫码、自动完成绑定;只有这次用户发起的绑定在扫码确认、目标正确、启用成功且最终状态回读可用后才显示完成,不因页面打开、旧记录或验证码提交自动启用。无可用智能体时说明创建和“开始使用”的前提,加载失败不冒充空态,已有微信仍可管理或解绑。Main 持有凭据与原操作恢复;解除需要配套 Yuxi 用户级 `/channel-accounts/{id}/unbind`,旧后端不会通过改按钮文案或组合未确认操作假装完成。变更后以最新读取状态为准,未完成操作保留原身份供显式恢复;失败在当前窗口可见,结果未知不自动重放。服务端显示微信退出仍在处理中时,禁止新的连接、启停和绑定变更,保留刷新与原操作恢复;管理写入前再次读取当前状态。客户端已知目标智能体停用时明确显示不可用,并禁止恢复自动回复,仍可更换目标或安全暂停、解绑。旧请求无法安全恢复、账号关系不一致或目标不可用时提示先核对,保留原记录并避免盲目重试;账号关系或目标智能体问题可显式刷新状态后再次确认原操作,刷新本身不重放操作;“不再提醒”只移除本机提醒,不取消服务端仍在处理的操作。最新 Yuxi 的退出保护条件和持久恢复由服务端负责,发布需配套 Core/Adapter 与 Yuxi business=13、knowledge=2 数据库版本。渠道页不展示微信聊天或活动,既有历史、费用、内容隔离与应用 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 密码。
diff --git a/electron/services/cloud-agents.ts b/electron/services/cloud-agents.ts
index e9e20aef..68fbfab7 100644
--- a/electron/services/cloud-agents.ts
+++ b/electron/services/cloud-agents.ts
@@ -56,6 +56,9 @@ const MESSAGES: Record = {
channel_not_found: '微信渠道不存在或已解绑',
channel_operation_conflict: '微信渠道正在处理其他操作,请稍后查看状态',
channel_operation_unconfirmed: '微信绑定的变更尚未确认,请查看待确认操作后重试',
+ channel_operation_legacy: '这次旧微信操作无法安全恢复,请联系管理员核对后再操作',
+ identity_conflict: '当前账号关系不一致,请刷新状态并核对账号后再操作',
+ target_agent_unavailable: '当前智能体暂不可用,请刷新状态并重新选择智能体',
channel_request_invalid: '微信渠道请求无效,请刷新后重试',
channel_rate_limited: '微信渠道操作过于频繁,请稍后重试',
channel_conversation_not_found: '微信会话不存在或已关闭',
@@ -430,7 +433,9 @@ export class CloudAgentsModule {
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') {
+ || channel.enabled !== false || channel.target_agent_slug !== null || channel.health !== 'login_required'
+ || (Array.isArray(channel.blockers) && channel.blockers.includes('wechat_disconnect_pending'))
+ || (channel.sync_state !== undefined && channel.sync_state !== 'ready') || channel.desired_state === 'enabled') {
// Keep the original operation in recovery until both routing and login are confirmed removed.
throw new CloudAgentsError(503, 'channel_operation_unconfirmed');
}
diff --git a/src/pages/CloudAgents/ChannelAccounts.tsx b/src/pages/CloudAgents/ChannelAccounts.tsx
index 679b0bd2..70091acf 100644
--- a/src/pages/CloudAgents/ChannelAccounts.tsx
+++ b/src/pages/CloudAgents/ChannelAccounts.tsx
@@ -69,29 +69,29 @@ const blockerText: Record = {
channel_sync_degraded: '正在确认微信的使用状态。',
};
const unsettled = (account: CloudUserChannelAccount) =>
+ account.blockers.includes('wechat_disconnect_pending') ||
Boolean(account.sync_state && account.sync_state !== 'ready') ||
account.blockers.includes('channel_sync_degraded') ||
(['paused', 'disabled'].includes(account.desired_state ?? '') && account.enabled) ||
(account.desired_state === 'enabled' && !account.enabled);
-function accountState(account: CloudUserChannelAccount, uncertain: boolean) {
+function accountState(account: CloudUserChannelAccount, uncertain: boolean, targetDisabled = false) {
if (uncertain || unsettled(account)) return { text: '正在确认', tone: 'bg-[#fff0d8] text-[#876033]' };
if (!account.target_agent_slug) return { text: '未绑定', tone: 'bg-[#f0edf8] text-[#75618e]' };
if (['login_required', 'verification_required'].includes(account.health))
return { text: '需要重新扫码', tone: 'bg-[#fff0d8] text-[#876033]' };
+ if (targetDisabled) return { text: '智能体已停用', tone: 'bg-[#fff0d8] text-[#876033]' };
if (account.health !== 'connected' || unsettled(account) || usableBlockers(account).length)
return { text: '正在确认', tone: 'bg-[#fff0d8] text-[#876033]' };
return account.enabled
? { text: '使用中', tone: 'bg-[#e7f4ec] text-[#3f7458]' }
: { text: '已暂停', tone: 'bg-[#f0edf8] text-[#75618e]' };
}
-async function loadAvailableAgents(): Promise {
+async function loadAgents(): Promise {
const items: CloudAgentDraft[] = [];
let cursor: string | null = null;
do {
const page = await cloudAgentsApi.list(cursor);
- items.push(
- ...page.agents.filter((agent) => Boolean(agent.published_version) && agent.enabled !== false)
- );
+ items.push(...page.agents);
cursor = page.next_cursor;
} while (cursor);
return items;
@@ -103,6 +103,10 @@ export function ChannelAccounts() {
const initial = useRef({ account: params.get('account') ?? '', target: params.get('target') ?? '' });
const [accounts, setAccounts] = useState([]);
const [agents, setAgents] = useState([]);
+ const agentsRef = useRef([]);
+ const availableAgents = agents.filter(
+ (agent) => Boolean(agent.published_version) && agent.enabled !== false
+ );
const [loading, setLoading] = useState(true);
const [accountsLoaded, setAccountsLoaded] = useState(false);
const [agentsLoaded, setAgentsLoaded] = useState(false);
@@ -126,11 +130,15 @@ export function ChannelAccounts() {
const intents = useRef(new Map());
const checks = useRef(new Map());
const readGeneration = useRef(0);
+ const ownerPending = useRef(false);
const refreshGeneration = useRef(0);
const [checkingIds, setCheckingIds] = useState([]);
const selected = accounts.find((account) => account.id === flow?.accountId);
const recoveryBlocked = pending.some((item) => /channelaccount/iu.test(item.operation));
- const disabled = Boolean(busy) || recoveryBlocked || !recoveryLoaded;
+ const ownerLogoutPending = accounts.some((account) =>
+ account.blockers.includes('wechat_disconnect_pending')
+ );
+ const disabled = Boolean(busy) || recoveryBlocked || !recoveryLoaded || ownerLogoutPending;
const pendingFor = (id: string) =>
pending.some(
(item) => /channelaccount/iu.test(item.operation) && item.input.channel_account_id === id
@@ -147,6 +155,9 @@ export function ChannelAccounts() {
const generation = ++readGeneration.current;
const page = await cloudAgentsApi.call('channelAccounts', {});
if (alive.current && generation === readGeneration.current) {
+ ownerPending.current = page.items.some((account) =>
+ account.blockers.includes('wechat_disconnect_pending')
+ );
setAccounts(page.items);
setAccountsLoaded(true);
for (const [id, expected] of checks.current)
@@ -165,12 +176,13 @@ export function ChannelAccounts() {
const generation = ++refreshGeneration.current;
const [accountResult, agentResult, recoveryResult] = await Promise.allSettled([
readAccounts(),
- loadAvailableAgents(),
+ loadAgents(),
cloudAgentsApi.recovery(),
]);
if (!alive.current || generation !== refreshGeneration.current) return;
if (accountResult.status === 'rejected') setError(errorText(accountResult.reason));
if (agentResult.status === 'fulfilled') {
+ agentsRef.current = agentResult.value;
setAgents(agentResult.value);
setAgentsLoaded(true);
setAgentsError('');
@@ -222,7 +234,7 @@ export function ChannelAccounts() {
return id;
};
const run = async (key: string, accountId: string, task: () => Promise): Promise => {
- if (busyRef.current || recoveryBlocked || !recoveryLoaded) return null;
+ if (busyRef.current || recoveryBlocked || !recoveryLoaded || ownerPending.current) return null;
busyRef.current = true;
const generation = ++refreshGeneration.current;
setBusy(key);
@@ -230,6 +242,13 @@ export function ChannelAccounts() {
setError('');
setNotice('');
try {
+ const observedAccounts = await readAccounts();
+ if (
+ ownerPending.current ||
+ observedAccounts.some((account) => account.blockers.includes('wechat_disconnect_pending'))
+ )
+ throw new Error('微信退出还在确认中,请先处理原来的操作。');
+ if (!alive.current) return null;
return await task();
} catch (failure) {
if (alive.current) {
@@ -299,6 +318,7 @@ export function ChannelAccounts() {
return fresh!;
};
const route = async (account: CloudUserChannelAccount, target: string) => {
+ if (ownerPending.current) throw new Error('微信退出还在确认中,请先处理原来的操作。');
const input = {
channel_account_id: account.id,
target_agent_slug: target,
@@ -328,6 +348,9 @@ export function ChannelAccounts() {
throw new Error('绑定的智能体已发生变化,请返回上一步确认。');
if (account.health !== 'connected' || unsettled(account) || usableBlockers(account).length)
throw new Error('微信还没有准备好,请稍后重试完成绑定。');
+ if (ownerPending.current) throw new Error('微信退出还在确认中,请先处理原来的操作。');
+ if (agentsRef.current.some((agent) => agent.slug === target && agent.enabled === false))
+ throw new Error('当前智能体已停用,请更换智能体后再使用。');
const activate = flowRef.current?.mode !== 'reconnect';
if (!account.enabled && activate) {
const input = { channel_account_id: id, expected_revision: account.revision };
@@ -381,7 +404,7 @@ export function ChannelAccounts() {
!current ||
!agentsLoaded ||
agentsError ||
- !agents.some((agent) => agent.slug === current.target) ||
+ !availableAgents.some((agent) => agent.slug === current.target) ||
disabled
)
return;
@@ -420,6 +443,13 @@ export function ChannelAccounts() {
const setEnabled = async (account: CloudUserChannelAccount) => {
setMore('');
const enabled = !account.enabled;
+ if (
+ enabled &&
+ agentsRef.current.some(
+ (agent) => agent.slug === account.target_agent_slug && agent.enabled === false
+ )
+ )
+ return;
const key = enabled ? 'enable' : 'pause';
const fresh = await run(key, account.id, async () => {
const input = { channel_account_id: account.id, expected_revision: account.revision };
@@ -472,7 +502,8 @@ export function ChannelAccounts() {
pollingId !== qrAccountId ||
!pollingSession ||
['confirmed', 'failed', 'expired'].includes(pollingStatus) ||
- recoveryBlocked
+ recoveryBlocked ||
+ ownerLogoutPending
)
return;
let stopped = false;
@@ -501,7 +532,7 @@ export function ChannelAccounts() {
};
// The flow ref identifies the current user-initiated connection without restarting a poll on unrelated renders.
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [pollingId, pollingSession, pollingStatus, qrAccountId, recoveryBlocked]);
+ }, [pollingId, pollingSession, pollingStatus, qrAccountId, recoveryBlocked, ownerLogoutPending]);
const verify = async () => {
if (!selected || !qr || !verifyCode.trim()) return;
const input = {
@@ -538,6 +569,27 @@ export function ChannelAccounts() {
{agentsError}。已有微信仍可以暂停或解除绑定。
)}
+ {ownerLogoutPending && (
+
+
有一个微信的退出操作还在确认中,暂时不能添加、扫码或修改微信绑定。
+
+ {recoveryBlocked
+ ? '请通过待处理记录确认原来的操作。'
+ : '请回到发起操作的设备确认原来的操作,再刷新这里;无需再次扫码或解除绑定。'}
+
+
+
+ )}
{notice && (
{notice}
@@ -612,7 +664,7 @@ export function ChannelAccounts() {
accountsLoaded &&
agentsLoaded &&
!agentsError &&
- !agents.length &&
+ !availableAgents.length &&
!flow &&
agentHint}
{loading ? (
@@ -630,7 +682,7 @@ export function ChannelAccounts() {
) : !accounts.length ? (
agentsLoaded &&
!agentsError &&
- agents.length > 0 && (
+ availableAgents.length > 0 && (
把你的智能体带到微信吧
@@ -655,15 +707,25 @@ export function ChannelAccounts() {
>
{accounts.map((account) => {
const agent = agents.find((item) => item.slug === account.target_agent_slug);
- const uncertain = !recoveryLoaded || checkingIds.includes(account.id) || pendingFor(account.id);
- const state = accountState(account, uncertain || busyAccount === account.id);
+ const uncertain =
+ !recoveryLoaded || checkingIds.includes(account.id) || pendingFor(account.id);
+ const targetDisabled = agents.some(
+ (agent) => agent.slug === account.target_agent_slug && agent.enabled === false
+ );
+ const state = accountState(
+ account,
+ uncertain || busyAccount === account.id,
+ targetDisabled
+ );
const needsScan =
+ !targetDisabled &&
account.target_agent_slug &&
['login_required', 'verification_required'].includes(account.health);
const blocked = disabled || uncertain;
const changeBlocked =
blocked || unsettled(account) || !agentsLoaded || Boolean(agentsError);
const canResume =
+ !targetDisabled &&
!unsettled(account) &&
account.health === 'connected' &&
Boolean(account.target_agent_slug) &&
@@ -709,6 +771,16 @@ export function ChannelAccounts() {
>
)}
+ {targetDisabled && (
+
+ 当前智能体已停用,可以更换智能体。
+
+ )}
+ {account.blockers.includes('wechat_disconnect_pending') && (
+
+ 这个微信的退出操作还在确认中。
+
+ )}
{usableBlockers(account)
.filter((item) => blockerText[item])
.map((item) => (
@@ -852,7 +924,7 @@ export function ChannelAccounts() {
>
) : flow?.step === 'choose' ? (
<>
- {!agentsError && agentsLoaded && !agents.length ? (
+ {!agentsError && agentsLoaded && !availableAgents.length ? (
agentHint
) : (
- {agents.map((agent) => (
+ {availableAgents.map((agent) => (
{retiredChannelOperations.has(item.operation)
?
- :
+ :
{attentionRecords[item.id] && attentionRecords[item.id].code !== 'channel_operation_legacy'
+ ?
+ : }
}
)}
{result &&
{result.operation === 'createAgent'
@@ -113,7 +158,9 @@ export function CloudRecovery({ pending, onResolved, triggerContainer }: { pendi
{error &&
{error}
}
{notice &&
{notice}
}
{!pending.length && !result && !error && !notice &&
记录已经处理完了。
}
- {pending.length > 0 &&
不再提醒只会关闭提醒,已经保存的内容会保留。
}
+ {pending.length > 0 &&
{pending.some(item => /channel|wechat/iu.test(item.operation))
+ ? '不再提醒只会移除本机提醒,不会取消微信服务端仍在处理的操作。'
+ : '不再提醒只会关闭提醒,已经保存的内容会保留。'}
}
diff --git a/tests/e2e/cloud-agent-channels.spec.ts b/tests/e2e/cloud-agent-channels.spec.ts
index 660fe9e2..5c97b122 100644
--- a/tests/e2e/cloud-agent-channels.spec.ts
+++ b/tests/e2e/cloud-agent-channels.spec.ts
@@ -156,6 +156,13 @@ test('WeChat binding cards connect an account and switch its Agent through the c
Object.assign(globalThis, { cloudChannelFixtureCalls: calls });
let verified = false;
let rejectUnbind = true;
+ const controls = {
+ serverPending: false,
+ pendingOnNextRead: false,
+ disabledTarget: false,
+ legacyPending: false,
+ };
+ Object.assign(globalThis, { cloudChannelFixtureControls: controls });
const slug = 'ml-' + 'a'.repeat(32);
const configuration = {
model: 'test-model',
@@ -289,12 +296,47 @@ test('WeChat binding cards connect an account and switch its Agent through the c
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'))
+ return result({
+ recent: null,
+ pending: controls.legacyPending
+ ? [
+ {
+ id: 'fixture-legacy-unbind',
+ operation: 'unbindChannelAccount',
+ input: {
+ channel_account_id: 'wechat-b',
+ operation_id: 'legacy-original-id',
+ expected_revision: 1,
+ },
+ created_at: '2026-10-13T00:00:00Z',
+ },
+ ]
+ : [],
+ });
+ if (path.endsWith('/recovery/pending')) {
+ calls.push({ operation: 'resolvePending', input: body });
+ if (body.discard) {
+ controls.legacyPending = false;
+ return result({ discarded: true });
+ }
+ return result({
+ operation: 'unbindChannelAccount',
+ pending: true,
+ result: { error_code: 'channel_operation_legacy' },
+ });
+ }
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 }
+ ? {
+ agents: [
+ controls.disabledTarget ? { ...draft, enabled: false } : draft,
+ secondDraft,
+ ],
+ next_cursor: null,
+ }
: path.endsWith(secondSlug)
? secondDraft
: draft
@@ -333,7 +375,31 @@ test('WeChat binding cards connect an account and switch its Agent through the c
next_offset: null,
summary: { count: 0, settled_points: '0', pending_points: '0' },
});
- if (operation === 'channelAccounts') return result({ items: channelAccounts });
+ if (operation === 'channelAccounts') {
+ if (controls.pendingOnNextRead) {
+ controls.serverPending = true;
+ controls.pendingOnNextRead = false;
+ }
+ return result({
+ items: channelAccounts.map((item) => {
+ if (controls.serverPending && item.id === 'wechat-b')
+ return {
+ ...item,
+ health: 'unknown',
+ sync_state: 'degraded',
+ blockers: ['wechat_disconnect_pending'],
+ };
+ if (controls.disabledTarget && item.id === 'wechat-created')
+ return {
+ ...item,
+ enabled: false,
+ desired_state: 'paused',
+ blockers: ['binding_paused'],
+ };
+ return item;
+ }),
+ });
+ }
if (operation === 'createChannelAccount') {
const created = {
...spare,
@@ -671,7 +737,10 @@ test('WeChat binding cards connect an account and switch its Agent through the c
},
]);
await legacyCard.getByRole('button', { name: '更多操作', exact: true }).click();
- await page.screenshot({ path: testInfo.outputPath('wechat-binding-more-compact.png'), fullPage: true });
+ 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 });
@@ -700,6 +769,148 @@ test('WeChat binding cards connect an account and switch its Agent through the c
);
expect(unbindCalls).toHaveLength(2);
expect(unbindCalls[0].input).toEqual(unbindCalls[1].input);
+
+ await app.evaluate(() => {
+ (
+ globalThis as typeof globalThis & { cloudChannelFixtureControls: { serverPending: boolean } }
+ ).cloudChannelFixtureControls.serverPending = true;
+ });
+ await page.getByRole('button', { name: '刷新微信列表', exact: true }).click();
+ await expect(spareCard.getByText('正在确认', { exact: true })).toBeVisible();
+ await expect(page.getByText(/请回到发起操作的设备确认原来的操作/)).toBeVisible();
+ await expect(page.getByRole('button', { name: '添加微信', exact: true })).toBeDisabled();
+ await expect(legacyCard.getByRole('button', { name: '绑定智能体', exact: true })).toBeDisabled();
+ const createdCard = page.getByRole('article', { name: '微信 3微信', exact: true });
+ await expect(createdCard.getByRole('button', { name: '更多操作', exact: true })).toBeDisabled();
+ await page.screenshot({
+ path: testInfo.outputPath('wechat-server-pending-owner-lock.png'),
+ fullPage: true,
+ });
+
+ await app.evaluate(() => {
+ (
+ globalThis as typeof globalThis & { cloudChannelFixtureControls: { serverPending: boolean } }
+ ).cloudChannelFixtureControls.serverPending = false;
+ });
+ await page.getByRole('button', { name: '刷新微信列表', exact: true }).click();
+ await expect(legacyCard.getByRole('button', { name: '绑定智能体', exact: true })).toBeEnabled();
+ await legacyCard.getByRole('button', { name: '绑定智能体', exact: true }).click();
+ const latePending = page.getByRole('dialog', { name: '更换智能体', exact: true });
+ await latePending
+ .getByRole('radiogroup', { name: '选择智能体' })
+ .getByText('资料整理助手', { exact: true })
+ .click();
+ const managementCallsBefore = await app.evaluate(
+ () =>
+ (
+ globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] }
+ ).cloudChannelFixtureCalls.filter((call) => call.operation !== 'channelAccounts').length
+ );
+ await app.evaluate(() => {
+ (
+ globalThis as typeof globalThis & { cloudChannelFixtureControls: { pendingOnNextRead: boolean } }
+ ).cloudChannelFixtureControls.pendingOnNextRead = true;
+ });
+ await latePending.getByRole('button', { name: '下一步:扫码连接', exact: true }).click();
+ await expect(latePending.getByRole('alert')).toHaveText(/微信退出还在确认中/);
+ await expect(
+ latePending.getByRole('button', { name: '下一步:扫码连接', exact: true })
+ ).toBeDisabled();
+ expect(
+ await app.evaluate(
+ () =>
+ (
+ globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] }
+ ).cloudChannelFixtureCalls.filter((call) => call.operation !== 'channelAccounts').length
+ )
+ ).toBe(managementCallsBefore);
+ await page.screenshot({
+ path: testInfo.outputPath('wechat-late-pending-dialog.png'),
+ fullPage: true,
+ });
+ await latePending.getByRole('button', { name: '关闭', exact: true }).click();
+
+ await app.evaluate(() => {
+ const control = (
+ globalThis as typeof globalThis & {
+ cloudChannelFixtureControls: { serverPending: boolean; disabledTarget: boolean };
+ }
+ ).cloudChannelFixtureControls;
+ control.serverPending = false;
+ control.disabledTarget = true;
+ });
+ await page.getByRole('button', { name: '刷新微信列表', exact: true }).click();
+ await expect(createdCard.getByText('智能体已停用', { exact: true })).toBeVisible();
+ await expect(createdCard.getByText('使用中', { exact: true })).toHaveCount(0);
+ await createdCard.getByRole('button', { name: '更多操作', exact: true }).click();
+ await expect(createdCard.getByRole('button', { name: '恢复自动回复', exact: true })).toBeDisabled();
+ await expect(createdCard.getByRole('button', { name: '解除微信绑定', exact: true })).toBeEnabled();
+ await createdCard
+ .getByRole('button', { name: '解除微信绑定', exact: true })
+ .scrollIntoViewIfNeeded();
+ await page.screenshot({
+ path: testInfo.outputPath('wechat-disabled-target-safe-actions.png'),
+ fullPage: true,
+ });
+
+ await app.evaluate(() => {
+ const control = (
+ globalThis as typeof globalThis & {
+ cloudChannelFixtureControls: {
+ serverPending: boolean;
+ disabledTarget: boolean;
+ legacyPending: boolean;
+ };
+ }
+ ).cloudChannelFixtureControls;
+ control.serverPending = true;
+ control.disabledTarget = false;
+ control.legacyPending = true;
+ });
+ await page.getByRole('button', { name: '刷新微信列表', exact: true }).click();
+ await page.getByRole('button', { name: '待处理记录(1)', exact: true }).click();
+ const recovering = page.getByRole('dialog', { name: '待处理记录', exact: true });
+ await recovering.getByRole('button', { name: '再试一次', exact: true }).click();
+ const recoveryRecord = recovering.getByRole('article', { name: '解除微信绑定', exact: true });
+ await expect(recoveryRecord.getByRole('status')).toHaveText(
+ '这次旧微信操作无法安全恢复,请联系管理员核对后再操作。'
+ );
+ await expect(recoveryRecord.getByRole('button', { name: '再试一次', exact: true })).toBeDisabled();
+ await expect(recoveryRecord.getByRole('button', { name: '刷新状态', exact: true })).toHaveCount(0);
+ await expect(recovering.getByText('解除微信绑定的结果已确认。', { exact: true })).toHaveCount(0);
+ await expect(recovering.getByText(/不会取消微信服务端仍在处理的操作/)).toBeVisible();
+ await page.screenshot({
+ path: testInfo.outputPath('wechat-legacy-recovery-attention.png'),
+ fullPage: true,
+ });
+ await recoveryRecord.getByRole('button', { name: '不再提醒', exact: true }).click();
+ await expect(recoveryRecord).toHaveCount(0);
+ await recovering.getByRole('button', { name: '返回列表', exact: true }).click();
+ await expect(page.getByRole('button', { name: '添加微信', exact: true })).toBeDisabled();
+ await expect(spareCard.getByText('正在确认', { exact: true })).toBeVisible();
+ await expect(page.getByText(/请回到发起操作的设备确认原来的操作/)).toBeVisible();
+ expect(
+ await app.evaluate(() =>
+ (
+ globalThis as typeof globalThis & {
+ cloudChannelFixtureCalls: { operation: string; input: Record
}[];
+ }
+ ).cloudChannelFixtureCalls
+ .filter((call) => call.operation === 'resolvePending')
+ .map((call) => call.input)
+ )
+ ).toEqual([
+ { id: 'fixture-legacy-unbind', discard: false },
+ { id: 'fixture-legacy-unbind', discard: true },
+ ]);
+ expect(
+ await app.evaluate(
+ () =>
+ (
+ globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] }
+ ).cloudChannelFixtureCalls.filter((call) => call.operation === 'unbindChannelAccount').length
+ )
+ ).toBe(2);
} finally {
await app
.evaluate(({ BrowserWindow }) => {
diff --git a/tests/unit/channel-accounts-page.test.tsx b/tests/unit/channel-accounts-page.test.tsx
index 603ba555..be918ab6 100644
--- a/tests/unit/channel-accounts-page.test.tsx
+++ b/tests/unit/channel-accounts-page.test.tsx
@@ -103,7 +103,9 @@ async function more(name = legacy.display_name) {
return current;
}
async function adding() {
- fireEvent.click(await screen.findByRole('button', { name: '添加微信', exact: true }));
+ const addButton = await screen.findByRole('button', { name: '添加微信', exact: true });
+ await waitFor(() => expect(addButton).toBeEnabled());
+ fireEvent.click(addButton);
const dialog = screen.getByRole('dialog', { name: '添加微信', exact: true });
fireEvent.click(within(dialog).getByRole('radio', { name: agentA.name }));
await act(async () =>
@@ -797,3 +799,252 @@ it('keeps an uncertain operation locked when its recovery read is also unavailab
expect(within(await card()).getByRole('button', { name: '更换智能体' })).toBeDisabled();
expect(calls('routeChannelAccount')).toHaveLength(1);
});
+
+it('blocks every new owner management write when server logout is pending without a local journal', async () => {
+ accounts = [
+ { ...legacy, health: 'connected', sync_state: 'ready', blockers: ['wechat_disconnect_pending'] },
+ {
+ ...spare,
+ target_agent_slug: agentB.slug,
+ target_agent_name: agentB.name,
+ health: 'login_required',
+ blockers: [],
+ },
+ ];
+ renderPage();
+ const first = await card();
+ const second = await card(spare.display_name);
+ expect(within(first).getByText('正在确认')).toBeVisible();
+ expect(within(first).queryByText('使用中')).not.toBeInTheDocument();
+ expect(
+ screen.getByText('请回到发起操作的设备确认原来的操作,再刷新这里;无需再次扫码或解除绑定。')
+ ).toBeVisible();
+ expect(screen.getByRole('button', { name: '添加微信', exact: true })).toBeDisabled();
+ expect(within(first).getByRole('button', { name: '更换智能体' })).toBeDisabled();
+ expect(within(first).getByRole('button', { name: '更多操作' })).toBeDisabled();
+ expect(within(second).getByRole('button', { name: '重新扫码' })).toBeDisabled();
+ expect(within(second).getByRole('button', { name: '更多操作' })).toBeDisabled();
+ expect(writes()).toEqual([]);
+ accounts[0].blockers = [];
+ fireEvent.click(screen.getByRole('button', { name: '刷新确认', exact: true }));
+ await waitFor(() => expect(within(first).getByRole('button', { name: '更换智能体' })).toBeEnabled());
+ expect(writes()).toEqual([]);
+});
+
+it.each(['switch', 'unbind'] as const)(
+ 'blocks a %s dialog opened before another account logout becomes pending',
+ async (mode) => {
+ renderPage();
+ let dialog: HTMLElement;
+ if (mode === 'switch') {
+ dialog = await chooseExisting();
+ fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name }));
+ } else {
+ fireEvent.click(within(await more()).getByRole('button', { name: '解除微信绑定' }));
+ dialog = screen.getByRole('dialog', { name: '解除微信绑定' });
+ }
+ accounts[1].blockers = ['wechat_disconnect_pending'];
+ fireEvent.click(
+ within(dialog).getByRole('button', { name: mode === 'switch' ? '确认更换' : '确认解除微信绑定' })
+ );
+ expect(await within(dialog).findByRole('alert')).toHaveTextContent('微信退出还在确认中');
+ expect(
+ within(dialog).getByRole('button', { name: mode === 'switch' ? '确认更换' : '确认解除微信绑定' })
+ ).toBeDisabled();
+ expect(within(dialog).getByText(/请回到发起操作的设备确认原来的操作/)).toBeVisible();
+ expect(writes()).toEqual([]);
+ }
+);
+
+it('does not create an account when an already-open add dialog first reads server pending', async () => {
+ renderPage();
+ const addButton = await screen.findByRole('button', { name: '添加微信', exact: true });
+ await waitFor(() => expect(addButton).toBeEnabled());
+ fireEvent.click(addButton);
+ const dialog = screen.getByRole('dialog', { name: '添加微信' });
+ fireEvent.click(within(dialog).getByRole('radio', { name: agentA.name }));
+ accounts[0].blockers = ['wechat_disconnect_pending'];
+ fireEvent.click(within(dialog).getByRole('button', { name: '下一步:扫码连接' }));
+ expect(await within(dialog).findByRole('alert')).toHaveTextContent('微信退出还在确认中');
+ expect(calls('createChannelAccount')).toHaveLength(0);
+ expect(writes()).toEqual([]);
+});
+
+it('does not start a QR on another account after the preflight sees owner logout pending', async () => {
+ accounts[1] = {
+ ...spare,
+ target_agent_slug: agentB.slug,
+ target_agent_name: agentB.name,
+ blockers: [],
+ };
+ renderPage();
+ const second = await card(spare.display_name);
+ accounts[0].blockers = ['wechat_disconnect_pending'];
+ fireEvent.click(within(second).getByRole('button', { name: '重新扫码' }));
+ const dialog = screen.getByRole('dialog', { name: '重新连接微信' });
+ expect(await within(dialog).findByRole('alert')).toHaveTextContent('微信退出还在确认中');
+ expect(calls('channelAccountWechatBindStart')).toHaveLength(0);
+});
+
+it('does not auto-enable after QR confirmation if another account logout becomes pending', async () => {
+ const base = api.call.getMockImplementation()!;
+ qrStatus = 'confirmed';
+ api.call.mockImplementation(async (op, input) => {
+ const result = await base(op, input);
+ if (op === 'channelAccountWechatBindStart') accounts[0].blockers = ['wechat_disconnect_pending'];
+ return result;
+ });
+ renderPage();
+ const dialog = await adding();
+ expect(await within(dialog).findByRole('alert')).toHaveTextContent('微信退出还在确认中');
+ expect(calls('enableChannelAccount')).toHaveLength(0);
+ expect(within(dialog).queryByRole('heading', { name: '微信绑定成功' })).not.toBeInTheDocument();
+});
+
+it('keeps original-operation recovery available while server logout is pending', async () => {
+ accounts[0].blockers = ['wechat_disconnect_pending'];
+ pending = [
+ {
+ id: 'original-logout',
+ operation: 'unbindChannelAccount',
+ input: { channel_account_id: legacy.id, operation_id: 'original-id', expected_revision: 7 },
+ created_at: '2026-10-13T00:00:00Z',
+ },
+ ];
+ api.resolvePending.mockImplementation(async (id) => {
+ expect(id).toBe('original-logout');
+ pending = [];
+ Object.assign(accounts[0], {
+ enabled: false,
+ target_agent_slug: null,
+ target_agent_name: null,
+ health: 'login_required',
+ blockers: ['target_required'],
+ });
+ return { operation: 'unbindChannelAccount', result: { unbound: true, disconnected: true } };
+ });
+ renderPage();
+ await card();
+ fireEvent.click(screen.getByRole('button', { name: '待处理记录(1)' }));
+ const dialog = screen.getByRole('dialog', { name: '待处理记录' });
+ fireEvent.click(within(dialog).getByRole('button', { name: '再试一次' }));
+ await within(dialog).findByText('解除微信绑定的结果已确认。');
+ fireEvent.click(within(dialog).getByRole('button', { name: '返回列表' }));
+ expect(within(await card()).getByText('未绑定')).toBeVisible();
+ expect(api.resolvePending).toHaveBeenCalledWith('original-logout', false);
+ expect(writes()).toEqual([]);
+});
+
+it.each(['worker_offline', 'channel_sync_degraded'])(
+ 'preserves safe stop and unbind for ordinary %s',
+ async (blocker) => {
+ accounts[0].blockers = [blocker];
+ if (blocker === 'channel_sync_degraded') accounts[0].sync_state = 'degraded';
+ renderPage();
+ const current = await more();
+ expect(within(current).getByRole('button', { name: '暂停自动回复' })).toBeEnabled();
+ expect(within(current).getByRole('button', { name: '解除微信绑定' })).toBeEnabled();
+ }
+);
+
+it('uses known disabled-target metadata without blocking replacement or safe stopping', async () => {
+ agents[0] = { ...agentA, enabled: false };
+ renderPage();
+ const current = await card();
+ expect(within(current).getByText('智能体已停用')).toBeVisible();
+ expect(within(current).queryByText('使用中')).not.toBeInTheDocument();
+ expect(within(current).getByText('当前智能体已停用,可以更换智能体。')).toBeVisible();
+ const menu = await more();
+ expect(within(menu).getByRole('button', { name: '暂停自动回复' })).toBeEnabled();
+ expect(within(menu).getByRole('button', { name: '解除微信绑定' })).toBeEnabled();
+ const dialog = await chooseExisting();
+ expect(within(dialog).queryByRole('radio', { name: agentA.name })).not.toBeInTheDocument();
+ fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name }));
+ fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' }));
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
+ expect(within(await card()).getByText('使用中')).toBeVisible();
+});
+
+it('does not resume a paused binding with a known disabled target', async () => {
+ agents[0] = { ...agentA, enabled: false };
+ accounts[0] = { ...legacy, enabled: false, blockers: ['binding_paused'] };
+ renderPage();
+ const current = await more();
+ expect(within(current).getByText('智能体已停用')).toBeVisible();
+ expect(within(current).getByRole('button', { name: '恢复自动回复' })).toBeDisabled();
+ expect(within(current).getByRole('button', { name: '解除微信绑定' })).toBeEnabled();
+ expect(calls('enableChannelAccount')).toHaveLength(0);
+});
+
+it('sends no mutation when the management preflight cannot read authoritative accounts', async () => {
+ const base = api.call.getMockImplementation()!;
+ renderPage();
+ const dialog = await chooseExisting();
+ fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name }));
+ api.call.mockImplementation(async (op, input) => {
+ if (op === 'channelAccounts') throw new Error('微信状态暂时无法读取');
+ return base(op, input);
+ });
+ fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' }));
+ expect(await within(dialog).findByRole('alert')).toHaveTextContent('微信状态暂时无法读取');
+ expect(writes()).toEqual([]);
+});
+
+it.each(['enable', 'pause'] as const)(
+ 'blocks a new %s when a different account begins server logout before preflight',
+ async (action) => {
+ if (action === 'enable') accounts[0] = { ...legacy, enabled: false, blockers: ['binding_paused'] };
+ renderPage();
+ const current = await more();
+ accounts[1].blockers = ['wechat_disconnect_pending'];
+ fireEvent.click(
+ within(current).getByRole('button', {
+ name: action === 'enable' ? '恢复自动回复' : '暂停自动回复',
+ })
+ );
+ expect(await screen.findByRole('alert')).toHaveTextContent('微信退出还在确认中');
+ expect(calls('enableChannelAccount')).toHaveLength(0);
+ expect(calls('pauseChannelAccount')).toHaveLength(0);
+ }
+);
+
+it('still sends a safe pause while an ordinary Worker-offline blocker is present', async () => {
+ accounts[0].blockers = ['worker_offline'];
+ renderPage();
+ fireEvent.click(within(await more()).getByRole('button', { name: '暂停自动回复' }));
+ expect(await screen.findByText('已暂停')).toBeVisible();
+ expect(calls('pauseChannelAccount')).toHaveLength(1);
+});
+
+it('sends no write when a superseded preflight observes pending after the newer account read fails', async () => {
+ renderPage();
+ await card();
+ const refreshButton = screen.getByRole('button', { name: '刷新微信列表' });
+ const dialog = await chooseExisting();
+ fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name }));
+ const base = api.call.getMockImplementation()!;
+ let accountReads = 0;
+ let finishPreflight: ((value: { items: CloudUserChannelAccount[] }) => void) | undefined;
+ api.call.mockImplementation(async (op, input) => {
+ if (op !== 'channelAccounts') return base(op, input);
+ accountReads += 1;
+ if (accountReads === 1)
+ return new Promise<{ items: CloudUserChannelAccount[] }>((resolve) => {
+ finishPreflight = resolve;
+ });
+ throw new Error('较新的微信状态读取失败');
+ });
+ act(() => {
+ fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' }));
+ fireEvent.click(refreshButton);
+ });
+ await waitFor(() => expect(accountReads).toBe(2));
+ expect(await within(dialog).findByRole('alert')).toHaveTextContent('较新的微信状态读取失败');
+ await act(async () =>
+ finishPreflight!({
+ items: [{ ...legacy }, { ...spare, blockers: ['wechat_disconnect_pending'] }],
+ })
+ );
+ expect(within(dialog).getByRole('alert')).toHaveTextContent('微信退出还在确认中');
+ expect(writes()).toEqual([]);
+});
diff --git a/tests/unit/cloud-agents-main.test.ts b/tests/unit/cloud-agents-main.test.ts
index ab9991ca..241069ac 100644
--- a/tests/unit/cloud-agents-main.test.ts
+++ b/tests/unit/cloud-agents-main.test.ts
@@ -596,10 +596,15 @@ describe('Main cloud Agents boundary', () => {
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',
+ expected_provider_generation: 'forged-generation', expected_binding_id: 'forged-binding',
+ expected_route_revision: 999, source_route_operation_id: 'forged-route',
+ provider_generation: 'forged-generation', provider_unbind: { status: 'completed', token: 'private' },
+ cookie: '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.input).toEqual({ channel_account_id: 'wechat-1', 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' },
@@ -654,6 +659,67 @@ describe('Main cloud Agents boundary', () => {
]);
});
+ it.each([
+ ['channel_operation_legacy', '这次旧微信操作无法安全恢复,请联系管理员核对后再操作'],
+ ['identity_conflict', '当前账号关系不一致,请刷新状态并核对账号后再操作'],
+ ['target_agent_unavailable', '当前智能体暂不可用,请刷新状态并重新选择智能体'],
+ ])('keeps the original uncertain channel request after the safe %s recovery error', async (code, message) => {
+ const channel = { id: 'wechat-1', enabled: true, target_agent_slug: draft.slug, revision: 3, health: 'connected' };
+ const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response'))
+ .mockResolvedValueOnce(json({ detail: { code, message: 'private Core owner and provider credentials' } }, 409))
+ .mockResolvedValueOnce(json({ channel }));
+ const module = moduleFor(fetchImpl);
+ const original = { channel_account_id: 'wechat-1', operation_id: input.operation_id, expected_revision: 2 };
+ await expect(module.execute({ operation: 'enableChannelAccount', input: original })).rejects.toMatchObject({
+ commandOutcome: 'unknown',
+ });
+ const pending = (await module.recovery()).pending[0];
+ expect(pending).toMatchObject({ operation: 'enableChannelAccount', input: original });
+ await expect(module.resolvePending({ id: pending.id })).rejects.toMatchObject({
+ status: 409, code, message, commandOutcome: 'unknown',
+ });
+ expect((await module.recovery()).pending).toEqual([pending]);
+ expect(await module.resolvePending({ id: pending.id })).toMatchObject({
+ operation: 'enableChannelAccount', input: original, result: { channel },
+ });
+ expect((await module.recovery()).pending).toEqual([]);
+ expect(fetchImpl.mock.calls.slice(1).map(call => [call[0], JSON.parse(call[1].body)])).toEqual(
+ Array.from({ length: 3 }, () => ['https://agents.example.test/api/makelore/channel-accounts/wechat-1/enable', {
+ operation_id: input.operation_id, expected_revision: 2,
+ }]),
+ );
+ });
+
+ it.each([
+ { blockers: ['wechat_disconnect_pending'] },
+ { sync_state: 'degraded' },
+ { sync_state: 'pending' },
+ { sync_state: 'unknown' },
+ { desired_state: 'enabled' },
+ ])('recovers the same unbind request after a flagged success still has unfinished logout state %o', async unfinished => {
+ const channel = { id: 'wechat-1', enabled: false, target_agent_slug: null, health: 'login_required',
+ desired_state: 'paused', sync_state: 'ready', blockers: [] };
+ const fetchImpl = vi.fn().mockResolvedValueOnce(session())
+ .mockResolvedValueOnce(json({ channel: { ...channel, ...unfinished }, unbound: true, 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({
+ status: 503, code: 'channel_operation_unconfirmed', commandOutcome: 'unknown',
+ });
+ const pending = (await module.recovery()).pending[0];
+ expect(pending).toMatchObject({ operation: 'unbindChannelAccount', input: original });
+ 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(
+ Array.from({ length: 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' } } }));
diff --git a/tests/unit/cloud-recovery.test.tsx b/tests/unit/cloud-recovery.test.tsx
index bc843cca..1f073156 100644
--- a/tests/unit/cloud-recovery.test.tsx
+++ b/tests/unit/cloud-recovery.test.tsx
@@ -4,7 +4,7 @@ import { CloudRecovery } from '@/pages/CloudAgents/CloudRecovery';
import { AppError } from '@/lib/error-model';
import type { CloudPendingOperation } from '../../shared/cloud-agents';
-const api = vi.hoisted(() => ({ resolvePending: vi.fn() }));
+const api = vi.hoisted(() => ({ resolvePending: vi.fn(), call: vi.fn(), list: vi.fn() }));
vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api }));
const first: CloudPendingOperation = { id: 'create:first', operation: 'createAgent', input: { operation_id: 'original-first', name: '故事搭档' }, created_at: '2026-10-13T03:54:49Z' };
const second: CloudPendingOperation = { id: 'create:second', operation: 'createAgent', input: { operation_id: 'original-second', name: '阅读伙伴' }, created_at: '2026-10-13T03:55:18Z' };
@@ -255,3 +255,158 @@ it('keeps an explicitly unknown command outcome recoverable without refreshing i
await waitFor(() => expect(api.resolvePending).toHaveBeenCalledTimes(2));
expect(api.resolvePending.mock.calls).toEqual([[first.id, false], [first.id, false]]);
});
+
+const wechatOperation: CloudPendingOperation = {
+ id: 'unbindChannelAccount::original-wechat-op',
+ operation: 'unbindChannelAccount',
+ input: { channel_account_id: 'wechat-account', operation_id: 'original-wechat-op', expected_revision: 7 },
+ created_at: '2026-10-13T04:00:00Z',
+};
+
+it.each([
+ ['channel_operation_legacy', '无法安全恢复'],
+])('keeps an original WeChat record requiring attention after %s without blind replay', async (backendCode, text) => {
+ api.resolvePending.mockRejectedValueOnce(new AppError('UNKNOWN', '这次微信操作需要核对', undefined, {
+ status: 409, backendCode, commandOutcome: 'unknown',
+ }));
+ render();
+ const dialog = await openRecords();
+ const row = within(dialog).getByRole('article', { name: '解除微信绑定' });
+ fireEvent.click(within(row).getByRole('button', { name: '再试一次' }));
+ expect(await within(row).findByRole('status')).toHaveTextContent(text);
+ expect(within(row).getByRole('button', { name: '再试一次' })).toBeDisabled();
+ expect(within(dialog).getByRole('alert')).toHaveTextContent('需要核对');
+ expect(resolved).not.toHaveBeenCalled();
+ expect(api.resolvePending.mock.calls).toEqual([[wechatOperation.id, false]]);
+ fireEvent.click(within(row).getByRole('button', { name: '再试一次' }));
+ expect(api.resolvePending).toHaveBeenCalledOnce();
+ expect(within(dialog).getByText(/不会取消微信服务端仍在处理的操作/)).toBeVisible();
+ api.resolvePending.mockResolvedValueOnce({ discarded: true });
+ fireEvent.click(within(row).getByRole('button', { name: '不再提醒' }));
+ await waitFor(() => expect(resolved).toHaveBeenCalledOnce());
+ expect(api.resolvePending.mock.calls).toEqual([[wechatOperation.id, false], [wechatOperation.id, true]]);
+});
+
+it('does not claim success or replay a pending receipt that says an old WeChat intent cannot recover', async () => {
+ const verification: CloudPendingOperation = {
+ ...wechatOperation, operation: 'channelAccountWechatBindVerification', id: 'verification:original',
+ };
+ api.resolvePending.mockResolvedValue({
+ operation: verification.operation, pending: true,
+ result: { status: 'unknown', error_code: 'channel_operation_legacy' },
+ });
+ render();
+ const dialog = await openRecords();
+ const row = within(dialog).getByRole('article', { name: '微信连接验证' });
+ fireEvent.click(within(row).getByRole('button', { name: '再试一次' }));
+ expect(await within(row).findByRole('status')).toHaveTextContent('无法安全恢复');
+ expect(within(row).getByRole('button', { name: '再试一次' })).toBeDisabled();
+ expect(within(dialog).queryByText(/结果已确认/)).not.toBeInTheDocument();
+ expect(api.resolvePending.mock.calls).toEqual([[verification.id, false]]);
+});
+
+it('keeps an unknown WeChat network result retryable using only the original record', async () => {
+ api.resolvePending.mockRejectedValue(new AppError('NETWORK', '微信操作结果暂时未知', undefined, {
+ status: 503, backendCode: 'channel_operation_unconfirmed', commandOutcome: 'unknown',
+ }));
+ render();
+ const dialog = await openRecords();
+ const row = within(dialog).getByRole('article', { name: '解除微信绑定' });
+ fireEvent.click(within(row).getByRole('button', { name: '再试一次' }));
+ await within(dialog).findByRole('alert');
+ expect(within(row).getByRole('button', { name: '再试一次' })).toBeEnabled();
+ fireEvent.click(within(row).getByRole('button', { name: '再试一次' }));
+ await waitFor(() => expect(api.resolvePending).toHaveBeenCalledTimes(2));
+ expect(api.resolvePending.mock.calls).toEqual([[wechatOperation.id, false], [wechatOperation.id, false]]);
+ expect(resolved).not.toHaveBeenCalled();
+});
+
+it('requires attention only for the rejected WeChat record while preserving another creation recovery', async () => {
+ api.resolvePending.mockRejectedValueOnce(new AppError('UNKNOWN', '旧微信请求无法恢复', undefined, {
+ status: 409, backendCode: 'channel_operation_legacy', commandOutcome: 'unknown',
+ })).mockResolvedValueOnce({ result: { slug: 'created-agent' } });
+ render();
+ const dialog = await openRecords();
+ const wechat = within(dialog).getByRole('article', { name: '解除微信绑定' });
+ fireEvent.click(within(wechat).getByRole('button', { name: '再试一次' }));
+ await within(wechat).findByRole('status');
+ const creation = within(dialog).getByRole('article', { name: '创建「故事搭档」' });
+ expect(within(creation).getByRole('button', { name: '再试一次' })).toBeEnabled();
+ fireEvent.click(within(creation).getByRole('button', { name: '再试一次' }));
+ await within(dialog).findByText(/创建成功/);
+ expect(within(wechat).getByRole('button', { name: '再试一次' })).toBeDisabled();
+ expect(api.resolvePending.mock.calls).toEqual([[wechatOperation.id, false], [first.id, false]]);
+});
+
+
+it.each(['identity_conflict', 'target_agent_unavailable'])('lets a corrected %s explicitly refresh before confirming the same original WeChat record', async backendCode => {
+ api.resolvePending.mockRejectedValueOnce(new AppError('UNKNOWN', '这次微信操作需要核对', undefined, {
+ status: 409, backendCode, commandOutcome: 'unknown',
+ })).mockResolvedValueOnce({ result: { status: 'unbound' } });
+ const accounts = deferred();
+ api.call.mockReturnValue(accounts.promise);
+ api.list.mockResolvedValue({ items: [], next_cursor: null });
+ render();
+ const dialog = await openRecords();
+ const row = within(dialog).getByRole('article', { name: '解除微信绑定' });
+ fireEvent.click(within(row).getByRole('button', { name: '再试一次' }));
+ await within(row).findByRole('status');
+ expect(api.resolvePending.mock.calls).toEqual([[wechatOperation.id, false]]);
+ expect(api.call).not.toHaveBeenCalled();
+ fireEvent.click(within(row).getByRole('button', { name: '刷新状态' }));
+ expect(api.call.mock.calls).toEqual([['channelAccounts', {}]]);
+ expect(api.list).toHaveBeenCalledWith();
+ expect(within(row).getByRole('button', { name: '正在刷新…' })).toBeDisabled();
+ expect(within(row).getByRole('button', { name: '不再提醒' })).toBeDisabled();
+ expect(within(dialog).getByRole('button', { name: '关闭记录' })).toBeDisabled();
+ expect(within(screen.getByRole('article', { name: '创建「故事搭档」' })).getByRole('button', { name: '再试一次' })).toBeDisabled();
+ await act(async () => accounts.resolve({ items: [], next_cursor: null }));
+ expect(await within(dialog).findByText(/微信状态已刷新/)).toBeVisible();
+ expect(api.resolvePending).toHaveBeenCalledOnce();
+ expect(within(dialog).queryByText(/结果已确认/)).not.toBeInTheDocument();
+ expect(within(row).getByRole('button', { name: '再试一次' })).toBeEnabled();
+ fireEvent.click(within(row).getByRole('button', { name: '再试一次' }));
+ expect(await within(dialog).findByText('解除微信绑定的结果已确认。')).toBeVisible();
+ expect(api.resolvePending.mock.calls).toEqual([[wechatOperation.id, false], [wechatOperation.id, false]]);
+});
+
+it('keeps the original record requiring attention when its explicit state refresh fails', async () => {
+ api.resolvePending.mockRejectedValueOnce(new AppError('UNKNOWN', '目标暂不可用', undefined, {
+ status: 409, backendCode: 'target_agent_unavailable', commandOutcome: 'unknown',
+ }));
+ api.call.mockRejectedValueOnce(new Error('微信状态读取失败')).mockResolvedValueOnce({ items: [] });
+ api.list.mockResolvedValue({ items: [], next_cursor: null });
+ render();
+ const dialog = await openRecords();
+ const row = within(dialog).getByRole('article', { name: '解除微信绑定' });
+ fireEvent.click(within(row).getByRole('button', { name: '再试一次' }));
+ await within(row).findByRole('status');
+ fireEvent.click(within(row).getByRole('button', { name: '刷新状态' }));
+ expect(await within(dialog).findByRole('alert')).toHaveTextContent('微信状态读取失败');
+ expect(within(row).queryByRole('button', { name: '再试一次' })).not.toBeInTheDocument();
+ expect(within(row).getByRole('button', { name: '刷新状态' })).toBeEnabled();
+ expect(resolved).not.toHaveBeenCalled();
+ expect(api.resolvePending).toHaveBeenCalledOnce();
+ fireEvent.click(within(row).getByRole('button', { name: '刷新状态' }));
+ await within(dialog).findByText(/微信状态已刷新/);
+ expect(api.resolvePending).toHaveBeenCalledOnce();
+ expect(within(row).getByRole('button', { name: '再试一次' })).toBeEnabled();
+});
+
+it('does not announce or replay a late state refresh after the recovery component unmounts', async () => {
+ api.resolvePending.mockRejectedValueOnce(new AppError('UNKNOWN', '账号关系需核对', undefined, {
+ status: 409, backendCode: 'identity_conflict', commandOutcome: 'unknown',
+ }));
+ const accounts = deferred();
+ api.call.mockReturnValue(accounts.promise);
+ api.list.mockResolvedValue({ items: [] });
+ const view = render();
+ await openRecords();
+ fireEvent.click(screen.getByRole('button', { name: '再试一次' }));
+ await screen.findByRole('button', { name: '刷新状态' });
+ fireEvent.click(screen.getByRole('button', { name: '刷新状态' }));
+ view.unmount();
+ await act(async () => accounts.resolve({ items: [] }));
+ expect(resolved).not.toHaveBeenCalled();
+ expect(api.resolvePending.mock.calls).toEqual([[wechatOperation.id, false]]);
+});