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,97 @@
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import {
catalogHasModel,
defaultModelKey,
normalizeDefaultModel,
normalizeModelCatalog,
validateDefaultModel,
} from '../../../src/channels/shared/default-model.mjs';
import {
BotWorkspaceStore,
createBotWorkspaceScope,
} from '../../../src/channels/shared/bot-workspace-store.mjs';
test('normalizeDefaultModel accepts object and provider/model strings', () => {
assert.deepEqual(normalizeDefaultModel({ provider: 'deepseek', model: 'chat' }), {
provider: 'deepseek',
model: 'chat',
});
assert.deepEqual(normalizeDefaultModel('openai/gpt-4.1'), {
provider: 'openai',
model: 'gpt-4.1',
});
assert.equal(normalizeDefaultModel(''), null);
assert.equal(normalizeDefaultModel({ provider: '', model: 'x' }), null);
assert.equal(defaultModelKey({ provider: 'a', model: 'b' }), 'a/b');
});
test('validateDefaultModel rejects junk', () => {
assert.equal(validateDefaultModel(null), null);
assert.throws(() => validateDefaultModel({ provider: 'x' }), (error) => (
error.code === 'default-model-invalid'
));
});
test('model catalog helpers flatten groups', () => {
const catalog = normalizeModelCatalog({
groups: [{
id: 'deepseek',
name: 'DeepSeek',
models: [{ id: 'chat', name: 'Chat' }, { id: 'reasoner', name: 'Reasoner' }],
}],
failures: [],
});
assert.equal(catalog.items.length, 2);
assert.equal(catalogHasModel(catalog, 'deepseek/chat'), true);
assert.equal(catalogHasModel(catalog, 'deepseek/missing'), false);
});
test('BotWorkspaceStore persists and applies default model on createSession', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-im-default-model-'));
try {
const store = await new BotWorkspaceStore(join(root, 'workspaces.json'), {
defaultWorkspace: root,
}).load();
await store.ensure('bot-a');
await store.setDefaultModel('bot-a', { provider: 'deepseek', model: 'chat' });
assert.deepEqual(store.defaultModelFor('bot-a'), {
provider: 'deepseek',
model: 'chat',
});
const selected = [];
const harness = {
async createSession() { return 'session-1'; },
async selectSessionModel(sessionId, selection) {
selected.push({ sessionId, selection });
return { selected: selection };
},
};
const state = {
async clearSessions() {},
sessionFor() { return null; },
async setSession() { return true; },
};
const scoped = createBotWorkspaceScope(harness, {
botId: 'bot-a',
workspaces: store,
state,
});
const sessionId = await scoped.harness.createSession();
assert.equal(sessionId, 'session-1');
assert.deepEqual(selected, [{
sessionId: 'session-1',
selection: { provider: 'deepseek', model: 'chat' },
}]);
await store.setDefaultModel('bot-a', null);
assert.equal(store.defaultModelFor('bot-a'), null);
} finally {
await rm(root, { recursive: true, force: true });
}
});

View file

@ -198,7 +198,7 @@ test('never submits the next FIFO approval before its operation is presented', a
assert.equal(sent.some(({ toolName, text }) => (
toolName === 'second-tool' && text.includes('second-tool --run')
)), true);
assert.equal(earlyTexts[0].includes('请精准回复'), true);
assert.equal(earlyTexts[0].includes('精准回复'), true);
await queue.claimReply({
key: 'direct:actor-a',
@ -237,7 +237,7 @@ test('a failed presentation cannot be followed by a blind approval', async () =>
}).process();
assert.equal(responses.length, 0);
assert.equal(sent.some((text) => text.includes('bash --run')), true);
assert.equal(sent.some((text) => text.includes('请精准回复')), true);
assert.equal(sent.some((text) => text.includes('精准回复')), true);
await queue.claimReply({
key: 'direct:actor-a',
@ -321,7 +321,7 @@ test('resolved waits for an in-flight presentation before showing the next appro
assert.match(sent[0], /first-tool --run/);
assert.equal(sent[1], '已拒绝此次操作。');
assert.match(sent[2], /second-tool --run/);
assert.match(sent[3], /请精准回复/);
assert.match(sent[3], /精准回复/);
assert.equal(secondResponses.length, 0);
await queue.claimReply({
@ -539,7 +539,7 @@ test('approval decisions stay bound to the initiating actor, route, and group me
assert.equal(responses.length, 1);
assert.equal(responses[0].value.outcome, 'allowed-once');
assert.equal(sent.filter((text) => text.includes('只有发起当前任务')).length, 2);
assert.equal(sent.some((text) => text.includes('请精准回复')), true);
assert.equal(sent.some((text) => text.includes('精准回复')), true);
});
test('a deferred approval reply stays silent when the approval resolves with its question unfinished', async () => {

View file

@ -71,6 +71,8 @@ test('WhatsApp account card uses the unified compact channel layout', () => {
assert.match(markup, /检查连接/);
assert.match(markup, /移除接入/);
assert.match(markup, /class="dim-presetSelect"/);
assert.match(markup, /默认模型/);
assert.match(markup, /Agent Preset/);
assert.match(markup, /aria-label="更多机器人设置"/);
assert.doesNotMatch(markup, /仅自己模式(默认)|指定联系人模式|开放响应模式/);
assert.match(markup, /role="status"[^>]*>测试消息已发送/);

View file

@ -455,3 +455,184 @@ test('modern adapter delegates interactions when a Session exposes no readable e
}, () => Promise.resolve(questionAnswer));
assert.deepEqual(question, questionAnswer);
});
forEachSessionApi('an approval before user/message', async (sessionApi) => {
const { events, session } = sessionFixture(sessionApi);
const eventRecord = (event) => ({ type: 'event', event });
let fixture;
let turnTask;
const append = (event) => {
events.push(event);
fixture.emit('session/event', session, event);
};
const gateway = {
async invoke(request) {
const endpoint = `${request.namespace}/${request.method}`;
if (endpoint === 'session/page') {
return { records: events.map(eventRecord), hasMore: false };
}
if (endpoint === 'session/prompt') {
turnTask = (async () => {
// Race that previously delegated to DSH Web: approval arrives while the
// IM ask is live (reconnect set) but before user/message flips `active`.
append({ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } });
append({
type: 'approval/asked', seq: 1, time: 1,
data: { id: 'approval-early', toolName: 'bash', callId: 'call-early' },
});
const outcome = await fixture.waterfall('approval/request', {
agent: { id: 'session', session },
toolName: 'bash',
callId: 'call-early',
}, () => Promise.resolve('browser-owned'));
append({
type: 'user/message', seq: 2, time: 2,
data: {
turn: 1,
source: { kind: 'user', rpcId: request.args.request.requestId },
message: { content: [] },
},
});
append({
type: 'approval/decided', seq: 3, time: 3,
data: { id: 'approval-early', outcome },
});
append({
type: 'assistant/message', seq: 4, time: 4,
data: { turn: 1, message: { content: [{ type: 'text', text: 'early-ok' }] } },
});
append({
type: 'turn/end', seq: 5, time: 5,
data: { turn: 1, reason: { kind: 'completed' } },
});
})();
return { accepted: true };
}
throw new Error(`unexpected invoke ${endpoint}`);
},
async stream(request) {
if (`${request.namespace}/${request.method}` !== 'session/follow') {
throw new Error('unexpected stream');
}
return asyncValues({
type: 'snapshot', cursor: -1, records: [], hasMore: false,
projections: { asOfSeq: -1, values: {} },
});
},
};
fixture = fakeContext(gateway);
const client = new HarnessClient({
...harnessConnection(fixture.ctx),
workspace: '/workspace',
rpcIdPrefix: 'early-approval',
logPrefix: 'early-approval',
});
const interactions = [];
const answer = await client.ask('session', 'approve early', {
timeoutMs: 5_000,
onInteraction: async (interaction) => {
interactions.push(interaction);
await interaction.respond({
ok: true,
value: {
sessionId: interaction.sessionId,
approvalId: interaction.payload.approvalId,
outcome: 'allowed-once',
},
});
},
});
await turnTask;
assert.equal(answer, 'early-ok');
assert.equal(interactions.length, 1);
assert.equal(interactions[0].kind, 'approval');
assert.equal(events.find((event) => event.type === 'approval/decided')?.data.outcome, 'allowed-once');
});
test('modern adapter still claims approval when callId shape drifts but toolName matches', async () => {
const { events, session } = sessionFixture('snapshotEvents');
const eventRecord = (event) => ({ type: 'event', event });
let fixture;
let turnTask;
const append = (event) => {
events.push(event);
fixture.emit('session/event', session, event);
};
const gateway = {
async invoke(request) {
const endpoint = `${request.namespace}/${request.method}`;
if (endpoint === 'session/page') {
return { records: events.map(eventRecord), hasMore: false };
}
if (endpoint === 'session/prompt') {
const rpcId = request.args.request.requestId;
turnTask = (async () => {
append({ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } });
append({
type: 'user/message', seq: 1, time: 1,
data: { turn: 1, source: { kind: 'user', rpcId }, message: { content: [] } },
});
append({
type: 'approval/asked', seq: 2, time: 2,
// Event omits callId while the request carries one — previously next()'d to Web.
data: { id: 'approval-drift', toolName: 'bash' },
});
const outcome = await fixture.waterfall('approval/request', {
agent: { id: 'session', session },
toolName: 'bash',
callId: 'call-drift',
}, () => Promise.resolve('browser-owned'));
append({
type: 'approval/decided', seq: 3, time: 3,
data: { id: 'approval-drift', outcome },
});
append({
type: 'assistant/message', seq: 4, time: 4,
data: { turn: 1, message: { content: [{ type: 'text', text: 'drift-ok' }] } },
});
append({
type: 'turn/end', seq: 5, time: 5,
data: { turn: 1, reason: { kind: 'completed' } },
});
})();
return { accepted: true };
}
throw new Error(`unexpected invoke ${endpoint}`);
},
async stream(request) {
if (`${request.namespace}/${request.method}` !== 'session/follow') {
throw new Error('unexpected stream');
}
return asyncValues({
type: 'snapshot', cursor: -1, records: [], hasMore: false,
projections: { asOfSeq: -1, values: {} },
});
},
};
fixture = fakeContext(gateway);
const client = new HarnessClient({
...harnessConnection(fixture.ctx),
workspace: '/workspace',
rpcIdPrefix: 'drift-approval',
logPrefix: 'drift-approval',
});
const interactions = [];
const answer = await client.ask('session', 'approve drift', {
timeoutMs: 5_000,
onInteraction: async (interaction) => {
interactions.push(interaction);
await interaction.respond({
ok: true,
value: {
sessionId: interaction.sessionId,
approvalId: interaction.payload.approvalId,
outcome: 'allowed-once',
},
});
},
});
await turnTask;
assert.equal(answer, 'drift-ok');
assert.equal(interactions.length, 1);
assert.equal(events.find((event) => event.type === 'approval/decided')?.data.outcome, 'allowed-once');
});