Keep IM tool approvals in chat and add WhatsApp default model.

Claim in-flight IM turns before ownership flips active so approvals no longer stick only in DSH Web, auto-recover stale session models once, and let WhatsApp bots pick a shared default model for new sessions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-15 20:02:45 +08:00
parent 2723e94541
commit 74c58bdcde
21 changed files with 1388 additions and 395 deletions

View file

@ -0,0 +1,27 @@
import { normalizeDefaultModel } from '../../../../src/channels/shared/default-model.mjs';
export const SET_DEFAULT_MODEL_ENDPOINT = 'bot.model.set';
function isRecord(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
export function validDefaultModelPayload(payload) {
if (!isRecord(payload)
|| !Object.keys(payload).every((key) => ['botId', 'defaultModel'].includes(key))
|| typeof payload.botId !== 'string'
|| !/^[A-Za-z0-9_-]{1,128}$/.test(payload.botId)) {
return false;
}
if (payload.defaultModel === null) return true;
return Boolean(normalizeDefaultModel(payload.defaultModel));
}
export function publicDefaultModelError(error) {
if (![
'default-model-invalid',
'default-model-unavailable',
'workspace-bot-not-found',
].includes(error?.code)) return null;
return { code: error.code, message: error.message };
}

View file

@ -22,6 +22,8 @@ export function publicWorkspaceError(error) {
'workspace-bot-not-found',
'agent-preset-invalid',
'agent-preset-unavailable',
'default-model-invalid',
'default-model-unavailable',
'context-enhancement-invalid',
].includes(error?.code)) return null;
return { code: error.code, message: error.message };

View file

@ -158,6 +158,7 @@ export async function createProductionController(ctx, config = {}, internals = {
workspaces,
stateFor,
agentPresetCatalog,
modelCatalog: () => harness.listModels(),
channel: 'whatsapp',
});
const supervisor = createSupervisor({

View file

@ -16,6 +16,7 @@ import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import { installConnectionRpcChannel } from '../../connection-rpc-mount.mjs';
import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
import { SET_AGENT_PRESET_ENDPOINT, validAgentPresetPayload } from '../shared/agent-preset-rpc.mjs';
import { SET_DEFAULT_MODEL_ENDPOINT, validDefaultModelPayload } from '../shared/default-model-rpc.mjs';
export const WHATSAPP_RPC_CHANNEL = '/whatsapp';
export const WHATSAPP_ENDPOINTS = Object.freeze({
@ -32,6 +33,7 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
setGroupSessionScope: SET_GROUP_SESSION_SCOPE_ENDPOINT,
setWorkspace: SET_WORKSPACE_ENDPOINT,
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setDefaultModel: SET_DEFAULT_MODEL_ENDPOINT,
setContextEnhancement: SET_CONTEXT_ENHANCEMENT_ENDPOINT,
resolveChannelPeer: 'bot.session.channel-peer',
});
@ -97,6 +99,10 @@ function payloadFailure(endpoint, payload) {
return validAgentPresetPayload(payload)
? null : '请选择 Agent Preset。';
}
if (endpoint === WHATSAPP_ENDPOINTS.setDefaultModel) {
return validDefaultModelPayload(payload)
? null : '请选择默认模型。';
}
if (endpoint === WHATSAPP_ENDPOINTS.setContextEnhancement) {
return validContextEnhancementPayload(payload)
? null : '请提交有效的上下文增强设置。';
@ -221,6 +227,12 @@ export function createWhatsappRpcHandler(controller, { encodeQr = qrDataUrl } =
await controller.updateAgentPreset(payload.botId, payload.agentPreset),
cachedEncode,
);
} else if (endpoint === WHATSAPP_ENDPOINTS.setDefaultModel) {
if (typeof controller.updateDefaultModel !== 'function') throw new Error('Default model update is unavailable');
value = await publicStatus(
await controller.updateDefaultModel(payload.botId, payload.defaultModel),
cachedEncode,
);
} else if (endpoint === WHATSAPP_ENDPOINTS.setAccessPolicy) {
if (typeof controller.updateAccessPolicy !== 'function') throw new Error('Access policy update is unavailable');
value = await controller.updateAccessPolicy(

View file

@ -473,6 +473,7 @@ class ModernHarnessApi {
const claimed = new Set([...this.#pendingApprovals.values()].map((entry) => entry.approvalId));
const decided = new Set();
let approvalId;
let fallbackId;
for (let index = owner.events.length - 1; index >= 0; index -= 1) {
const event = owner.events[index];
if (event.type === 'approval/decided') {
@ -480,11 +481,20 @@ class ModernHarnessApi {
} else if (event.type === 'approval/asked') {
const id = event.data?.id;
if (!id || decided.has(id) || claimed.has(id)) continue;
if ((request.callId ?? null) !== (event.data?.callId ?? null)) continue;
approvalId = id;
break;
// Prefer an exact callId match; keep the newest unmatched ask as a
// fallback so a callId shape drift cannot silently hand the prompt to
// DSH Web while the originating IM turn is still waiting.
if ((request.callId ?? null) === (event.data?.callId ?? null)) {
approvalId = id;
break;
}
if (fallbackId === undefined
&& (request.toolName === undefined || request.toolName === event.data?.toolName)) {
fallbackId = id;
}
}
}
approvalId ??= fallbackId;
if (approvalId === undefined) return next();
return new Promise((resolve) => {
const pending = {