Make WhatsApp member presets follow channel mapping by default.
Some checks failed
CI / Test, build, and verify package (push) Has been cancelled

Empty member/group Agent Preset now resolves to the bot workspace mapping (not Host global); settings label is Follow channel mapping, and /new createSession carries that mapping explicitly.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-16 23:11:33 +08:00
parent f1d85d2985
commit bcbe85fdb1
10 changed files with 182 additions and 131 deletions

View file

@ -8,6 +8,9 @@ This file records the notable changes in each dsh-im release. Its format follows
### Fixed / 修复
- WhatsApp 成员/群 Agent Preset 空值(UI 原「跟随全局」)现在明确回落到**频道工作区映射预设**,不再让人误以为跟 Host 全局默认;设置页文案改为「跟随频道映射」。`/new` 后建会话也会带上该映射,避免改 Host 全局预设后误用坏默认导致 `SESSION_CREATE`。
Empty WhatsApp member/group Agent Preset (UI formerly “Follow global”) now clearly falls back to the **channel workspace mapping preset**, not the Host catalog default; settings copy is “Follow channel mapping”. `/new` session creation also carries that mapping so a broken Host global default after operators change it does not surface as `SESSION_CREATE`.
- Host 带斜杠的错误码(如 `gateway/internal`、`workspace/invalid-path`、`agent-preset/not-found`)现在会正确映射为 `SESSION_CREATE` / `WORKSPACE_UNAVAILABLE` / `PRESET_UNAVAILABLE`;`last-message-failure.json` 额外写入 workspace、Host details 与脱敏 message,便于现场对上真实 RPC。
Host slash-branded failure codes (e.g. `gateway/internal`, `workspace/invalid-path`, `agent-preset/not-found`) now map to `SESSION_CREATE` / `WORKSPACE_UNAVAILABLE` / `PRESET_UNAVAILABLE`; `last-message-failure.json` also records workspace, Host details, and redacted messages for field triage.

View file

@ -571,7 +571,7 @@ var React26 = __toESM(require("react"), 1);
// package.json
var package_default = {
name: "dsh-im-ops",
version: "4.9.1-ops.35",
version: "4.9.1-ops.36",
description: "Ops fork of dsh-im (all channels kept). Based on @xmanrui/dsh-im@4.9.1 - access/session policies owned here.",
keywords: [
"deepseek-harness",
@ -855,6 +855,7 @@ var EN = Object.freeze({
"\u6E20\u9053\u7528\u6237": "Channel user",
"\uFF08\u4E0D\u53EF\u7528\uFF09": " (unavailable)",
"\u7FA4 Agent Preset": "Group Agent Preset",
"\u8DDF\u968F\u9891\u9053\u6620\u5C04": "Follow channel mapping",
"\u8DDF\u968F\u5168\u5C40": "Follow global",
"\u2014": "\u2014",
"\u6765\u6E90": "Source",
@ -13036,7 +13037,7 @@ function AgentPresetSelect({
"aria-label": ariaLabel,
onChange: (event) => onChange(event.target.value || "")
},
h2("option", { value: "" }, "\u8DDF\u968F\u5168\u5C40"),
h2("option", { value: "" }, localizeText("\u8DDF\u968F\u9891\u9053\u6620\u5C04")),
...items.map((item) => h2("option", {
key: item.id,
value: item.id

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,6 @@
{
"name": "dsh-im-ops",
"version": "4.9.1-ops.35",
"version": "4.9.1-ops.36",
"description": "Ops fork of dsh-im (all channels kept). Based on @xmanrui/dsh-im@4.9.1 - access/session policies owned here.",
"keywords": [
"deepseek-harness",

View file

@ -224,7 +224,7 @@ function AgentPresetSelect({
'aria-label': ariaLabel,
onChange: (event) => onChange(event.target.value || ''),
},
h('option', { value: '' }, '跟随全局'),
h('option', { value: '' }, localizeText('跟随频道映射')),
...items.map((item) => h('option', {
key: item.id,
value: item.id,

View file

@ -14,6 +14,7 @@ const EN = Object.freeze({
'渠道用户': 'Channel user',
"(不可用)": " (unavailable)",
"群 Agent Preset": "Group Agent Preset",
"跟随频道映射": "Follow channel mapping",
"跟随全局": "Follow global",
"—": "—",
"来源": "Source",

View file

@ -310,8 +310,9 @@ export function normalizeAccessGrant(input) {
/**
* Resolve a chat-scoped Agent Preset override from the access grant.
* Returns null when the chat should follow the bot-level (global) preset.
* Priority: direct-member or group override > bot global (caller applies global).
* Returns null when the chat should follow the bot/channel mapping preset
* (workspaces.agentPresets[botId]), not the Host catalog default.
* Priority: direct-member or group override > bot mapping (caller applies).
* @param {unknown} grant
* @param {{ kind?: string, phone?: string, senderId?: string, groupJid?: string, conversationId?: string }} [context]
* @returns {string|null}
@ -332,6 +333,25 @@ export function resolveAccessAgentPreset(grant, context = {}) {
return normalizeOptionalAgentPresetId(member?.agentPreset) ?? null;
}
/**
* Member/group override, else the bot's workspace-mapped preset.
* Does not fall through to Host catalog default — that is only used when the
* bot mapping itself is empty (createSession omits agentPreset).
* @param {unknown} grant
* @param {{ kind?: string, phone?: string, senderId?: string, groupJid?: string, conversationId?: string }} [context]
* @param {string|null|undefined} botAgentPreset
* @returns {string|null}
*/
export function resolveChatAgentPreset(grant, context = {}, botAgentPreset = null) {
const fromGrant = resolveAccessAgentPreset(grant, context);
if (fromGrant) return fromGrant;
if (typeof botAgentPreset === 'string') {
const trimmed = botAgentPreset.trim();
if (trimmed) return trimmed;
}
return null;
}
/**
* Seed owner phone as the first global admin when missing.
* @param {object|null} grant

View file

@ -1,4 +1,4 @@
import { normalizeAccessPhone, phoneFromWhatsappJid, resolveAccessAgentPreset } from './access-grant.mjs';
import { normalizeAccessPhone, phoneFromWhatsappJid, resolveChatAgentPreset } from './access-grant.mjs';
/**
* Parse a durable conversation binding key into chat identity fields.
@ -118,14 +118,13 @@ export function resolveChannelPeerFromBinding(input) {
const botAgentPreset = typeof input.botAgentPreset === 'string' ? input.botAgentPreset.trim() : '';
const withPreset = (peer) => {
const fromGrant = resolveAccessAgentPreset(grant, {
const agentPreset = resolveChatAgentPreset(grant, {
kind: peer.kind === 'group' ? 'group' : 'direct',
groupJid: peer.kind === 'group' ? peer.conversationId : undefined,
conversationId: peer.conversationId,
phone: peer.phone,
senderId: peer.senderId || peer.phone,
});
const agentPreset = fromGrant || botAgentPreset || null;
}, botAgentPreset);
return Object.freeze({ ...peer, agentPreset });
};

View file

@ -7,7 +7,7 @@ import {
normalizeMessageContent,
} from '@whiskeysockets/baileys';
import { emptyAccessGrant, ensureGroupBucket, normalizeAccessPhone, phoneFromWhatsappJid, resolveAccessAgentPreset } from '../shared/access-grant.mjs';
import { emptyAccessGrant, ensureGroupBucket, normalizeAccessPhone, phoneFromWhatsappJid, resolveChatAgentPreset } from '../shared/access-grant.mjs';
import { splitMessageText } from '../shared/editable-message-stream.mjs';
import { t } from '../shared/i18n.mjs';
import { ImagePromptError } from '../shared/image-prompt.mjs';
@ -833,7 +833,8 @@ export class WhatsappRuntime {
resolveAgentPreset: (message) => {
if (!this.#workspaces || !this.#botId) return null;
const grant = this.#workspaces.accessGrantFor(this.#botId);
return resolveAccessAgentPreset(grant, {
// Member/group override, else WhatsApp workspace mapping — never Host global.
return resolveChatAgentPreset(grant, {
kind: message?.kind === 'group' ? 'group' : 'direct',
groupJid: message?.conversationId,
conversationId: message?.conversationId,
@ -842,7 +843,7 @@ export class WhatsappRuntime {
?? phoneFromWhatsappJid(message?.senderAlternateId)
?? normalizeAccessPhone(message?.senderAlternateId),
senderId: message?.senderId,
});
}, this.#workspaces.agentPresetFor(this.#botId));
},
});
const now = Date.now();

View file

@ -16,6 +16,7 @@ import {
resolveAccessPending,
upsertAccessContact,
resolveAccessAgentPreset,
resolveChatAgentPreset,
validateAccessGrant,
} from '../../../src/channels/shared/access-grant.mjs';
@ -233,7 +234,7 @@ test('quote approval helpers parse intent, pending id, and notify refs', () => {
});
test('resolveAccessAgentPreset prefers direct-member and group overrides over global', () => {
test('resolveAccessAgentPreset prefers direct-member and group overrides over channel mapping', () => {
const doc = grant({
directMembers: [
{ phone: '8618222222222', canExecuteCommands: true, agentPreset: 'ops-user' },
@ -266,6 +267,31 @@ test('resolveAccessAgentPreset prefers direct-member and group overrides over gl
}), null);
});
test('resolveChatAgentPreset falls back to bot/channel mapping, not Host global', () => {
const doc = grant({
directMembers: [
{ phone: '8618222222222', canExecuteCommands: true, agentPreset: 'ops-user' },
{ phone: '8618333333333', canExecuteCommands: true },
],
});
assert.equal(resolveChatAgentPreset(doc, {
kind: 'direct',
phone: '8618222222222',
}, 'wa-mapped'), 'ops-user');
assert.equal(resolveChatAgentPreset(doc, {
kind: 'direct',
phone: '8618333333333',
}, 'wa-mapped'), 'wa-mapped');
assert.equal(resolveChatAgentPreset(doc, {
kind: 'direct',
phone: '8618333333333',
}, null), null);
assert.equal(resolveChatAgentPreset(null, {
kind: 'direct',
phone: '8618333333333',
}, 'wa-mapped'), 'wa-mapped');
});
test('validateAccessGrant keeps optional agentPreset on members and groups', () => {
const doc = grant({
directMembers: [{ phone: '8618222222222', canExecuteCommands: false, agentPreset: 'desk' }],