feat: add reasoning effort commands

This commit is contained in:
xmanrui 2026-08-25 00:41:44 +08:00
parent 36cbbe48e7
commit 9e39333ff6
24 changed files with 1346 additions and 240 deletions

View file

@ -801,6 +801,12 @@ test('DingTalk lists models and presets without prompting and advertises fast co
assert.equal(creates, 0);
assert.equal(fixture.sessions.size, 0);
await bridge.accept(message('reasoning-dingtalk', '/reasoninglist'));
assert.match(sent.at(-1).text, /还没有会话/);
assert.equal(asks, 0);
assert.equal(creates, 0);
assert.equal(fixture.sessions.size, 0);
const presetReplyStart = sent.length;
await bridge.accept(message('presets-dingtalk', '/presetlist'));
const presetReplies = sent.slice(presetReplyStart).map((entry) => entry.text);
@ -832,10 +838,15 @@ test('DingTalk lists models and presets without prompting and advertises fast co
await bridge.accept(message('help-models-dingtalk', '/help'));
const help = sent.at(-1).text;
for (const command of ['/models', '/model', '/presetlist', '/preset', '/preset --default', '/stop', '/steer']) {
for (const command of [
'/models', '/model', '/reasoninglist', '/reasonings', '/reasoning',
'/presetlist', '/preset', '/preset --default', '/stop', '/steer',
]) {
assert.equal(help.includes(command), true, command);
}
assert.match(help, /\/model 2/);
assert.match(help, /\/model .*\[推理等级ID\]/);
assert.match(help, /示例:先发 \/models,再发 \/model 2 \[推理等级ID\]/);
assert.doesNotMatch(help, /\/model 2 high\b/);
assert.match(help, /\/preset id:<ID>/);
});

View file

@ -344,6 +344,13 @@ test('Feishu lists models and presets without prompting and advertises fast comm
assert.equal(creates, 0);
assert.equal(fixture.sessions.size, 0);
await bridge.accept(event('reasoning-feishu', '/reasoninglist'));
await bridge.waitForIdle();
assert.match(sent.at(-1), /还没有会话/);
assert.equal(asks, 0);
assert.equal(creates, 0);
assert.equal(fixture.sessions.size, 0);
const presetReplyStart = sent.length;
await bridge.accept(event('presets-feishu', '/presetlist'));
await bridge.waitForIdle();
@ -380,10 +387,13 @@ test('Feishu lists models and presets without prompting and advertises fast comm
await bridge.accept(event('help-feishu', '/help'));
await bridge.waitForIdle();
const help = sent.at(-1);
for (const command of ['/models', '/model', '/presetlist', '/preset', '/preset --default', '/stop', '/steer']) {
for (const command of [
'/models', '/model', '/reasoninglist', '/reasonings', '/reasoning',
'/presetlist', '/preset', '/preset --default', '/stop', '/steer',
]) {
assert.equal(help.includes(command), true, command);
}
assert.match(help, /\/model 2/);
assert.match(help, /\/model .*\[推理等级ID\]/);
assert.match(help, /\/preset id:<ID>/);
});

View file

@ -64,12 +64,21 @@ test('menu exposes the increased command set and keeps repair number-only', () =
assert.equal(actions.includes('repair'), false);
});
test('menu help advertises Agent Preset commands', () => {
test('menu and card help advertise Agent Preset and reasoning commands', () => {
const help = menuHelpText();
assert.match(help, /\/presetlist/);
assert.match(help, /\/preset \[序号或完整ID\]/);
assert.match(help, /\/preset id:<ID>/);
assert.match(help, /\/preset --default/);
assert.match(help, /\/reasoninglist 或 \/reasonings/);
assert.match(help, /\/reasoning \[序号、等级ID或 --default\]/);
assert.match(help, /\/model \[序号或完整模型ID\] \[推理等级ID\]/);
const card = helpCard();
assert.match(card, /\/reasoninglist/);
assert.match(card, /\/reasonings/);
assert.match(card, /\/reasoning \[序号、等级ID或 --default\]/);
assert.match(card, /\/model \[序号或完整模型ID\] \[推理等级ID\]/);
});
test('card-action probe carries only its action and opaque nonce', () => {

View file

@ -633,6 +633,12 @@ test('QQ lists models and presets without prompting and advertises fast commands
assert.equal(creates, 0);
assert.equal(fixture.sessions.size, 0);
await bridge.accept(message({ messageId: 'reasoning-qq', content: '/reasoninglist' }));
assert.match(sent.at(-1), /还没有会话/);
assert.equal(asks, 0);
assert.equal(creates, 0);
assert.equal(fixture.sessions.size, 0);
const presetReplyStart = sent.length;
await bridge.accept(message({ messageId: 'presets-qq', content: '/presetlist' }));
const presetReplies = sent.slice(presetReplyStart);
@ -664,10 +670,15 @@ test('QQ lists models and presets without prompting and advertises fast commands
await bridge.accept(message({ messageId: 'help-models-qq', content: '/help' }));
const help = sent.at(-1);
for (const command of ['/models', '/model', '/presetlist', '/preset', '/preset --default', '/stop', '/steer']) {
for (const command of [
'/models', '/model', '/reasoninglist', '/reasonings', '/reasoning',
'/presetlist', '/preset', '/preset --default', '/stop', '/steer',
]) {
assert.equal(help.includes(command), true, command);
}
assert.match(help, /\/model 2/);
assert.match(help, /\/model .*\[推理等级ID\]/);
assert.match(help, /示例:先发 \/models,再发 \/model 2 \[推理等级ID\]/);
assert.doesNotMatch(help, /\/model 2 high\b/);
assert.match(help, /\/preset id:<ID>/);
});

View file

@ -93,11 +93,81 @@ test('HarnessClient exposes and validates model and run-state RPCs', async () =>
assert.ok(calls.filter(([type]) => type === 'ensureRunning').every(([, value]) => value === options));
});
test('HarnessClient preserves reasoning metadata and forwards an optional reasoning effort', async () => {
const { calls, client, responses } = modelClient();
const reasoningCatalog = {
groups: [{
id: 'deepseek-official',
name: 'DeepSeek',
models: [{
id: 'deepseek-v4',
name: 'DeepSeek V4',
description: 'Reasoning model',
reasoning: {
efforts: [
{ id: 'off', name: 'Off' },
{ id: 'high', name: 'High', description: 'More thinking' },
],
defaultEffort: 'high',
},
}],
}],
failures: [],
};
responses.set('llm.models', reasoningCatalog);
responses.set('session.models', {
...reasoningCatalog,
current: {
provider: 'deepseek-official',
model: 'deepseek-v4',
reasoningEffort: 'high',
},
routable: true,
});
responses.set('session.selectModel', {
selected: {
provider: 'deepseek-official',
model: 'deepseek-v4',
reasoningEffort: 'off',
},
});
assert.equal((await client.listModels()).groups[0].models[0].reasoning.defaultEffort, 'high');
assert.equal((await client.getSessionModels('session-one')).current.reasoningEffort, 'high');
await client.selectSessionModel('session-one', {
provider: 'deepseek-official',
model: 'deepseek-v4',
reasoningEffort: 'off',
});
assert.deepEqual(
calls.find((entry) => entry[0] === 'rpc' && entry[1] === 'session.selectModel')?.[2],
{
sessionId: 'session-one',
provider: 'deepseek-official',
model: 'deepseek-v4',
reasoningEffort: 'off',
},
);
});
test('HarnessClient rejects malformed model and run-state responses', async () => {
const { client, responses } = modelClient();
responses.set('llm.models', { groups: null, failures: [] });
await assert.rejects(client.listModels(), /invalid response for llm\.models/);
responses.set('llm.models', {
...CATALOG,
groups: [{
...CATALOG.groups[0],
models: [{
...CATALOG.groups[0].models[0],
reasoning: { efforts: [] },
}],
}],
});
await assert.rejects(client.listModels(), /invalid response for llm\.models/);
responses.set('session.models', {
...CATALOG,
current: { provider: 'deepseek-official' },
@ -108,12 +178,35 @@ test('HarnessClient rejects malformed model and run-state responses', async () =
/invalid response for session\.models/,
);
responses.set('session.models', {
...CATALOG,
current: {
provider: 'deepseek-official',
model: 'deepseek-v4',
reasoningEffort: '',
},
routable: true,
});
await assert.rejects(
client.getSessionModels('session-one'),
/invalid response for session\.models/,
);
responses.set('session.selectModel', { selected: { provider: '', model: 'bad' } });
await assert.rejects(
client.selectSessionModel('session-one', { provider: 'p', model: 'm' }),
/invalid response for session\.selectModel/,
);
await assert.rejects(
client.selectSessionModel('session-one', {
provider: 'deepseek-official',
model: 'deepseek-v4',
reasoningEffort: '',
}),
/provider and model are required/,
);
responses.set('session.list', { items: [{ sessionId: 'session-one', running: 'yes' }] });
await assert.rejects(client.isSessionRunning('session-one'), /invalid response for session\.list/);
});

View file

@ -44,6 +44,10 @@ test('t() translates known keys and fills placeholders in English mode', () => {
}
assert.equal(t('未收录的中文'), '未收录的中文');
assert.equal(t('{value} 测试', { value: 'x' }), EN['{value} 测试'] ?? 'x 测试');
assert.equal(
t('示例:先发 /models,再发 /model 2 [推理等级ID]'),
'Example: send /models first, then /model 2 [reasoning effort ID]',
);
assert.equal(t(42), 42);
});

View file

@ -420,6 +420,12 @@ test('all four shared text channels list models and presets locally and advertis
assert.equal(creates, 0, `${name} create`);
assert.equal(fixture.sessions.size, 0, `${name} session binding`);
await bridge.accept(message(`reasoning-${name}`, '/reasoninglist'));
assert.match(sent.at(-1), /还没有会话/, `${name} reasoning command`);
assert.equal(asks, 0, `${name} reasoning ask`);
assert.equal(creates, 0, `${name} reasoning create`);
assert.equal(fixture.sessions.size, 0, `${name} reasoning session binding`);
const presetReplyStart = sent.length;
await bridge.accept(message(`presets-${name}`, '/presetlist'));
const presetReplies = sent.slice(presetReplyStart);
@ -451,10 +457,15 @@ test('all four shared text channels list models and presets locally and advertis
await bridge.accept(message(`help-${name}`, '/help'));
const help = sent.at(-1);
for (const command of ['/models', '/model', '/presetlist', '/preset', '/preset --default', '/stop', '/steer']) {
for (const command of [
'/models', '/model', '/reasoninglist', '/reasonings', '/reasoning',
'/presetlist', '/preset', '/preset --default', '/stop', '/steer',
]) {
assert.match(help, new RegExp(`\\${command}`), `${name} ${command}`);
}
assert.match(help, /\/model 2/, `${name} numbered model selection`);
assert.match(help, /\/model .*\[推理等级ID\]/, `${name} optional model reasoning effort`);
assert.match(help, /示例:先发 \/models,再发 \/model 2 \[推理等级ID\]/, `${name} model effort placeholder`);
assert.doesNotMatch(help, /\/model 2 high\b/, `${name} does not assume a reasoning effort ID`);
assert.match(help, /\/preset id:<ID>/, `${name} numeric preset ID selection`);
}
});

View file

@ -421,6 +421,11 @@ test('Enterprise WeChat lists models and presets without prompting and advertise
assert.equal(asks, 0);
assert.equal(creates, 0);
await bridge.accept(frame({ msgid: 'reasoning-wecom', text: { content: '/reasoninglist' } }));
assert.match(transport.streamed.at(-1).content, /还没有会话/);
assert.equal(asks, 0);
assert.equal(creates, 0);
const presetReplyStart = transport.streamed.length;
await bridge.accept(frame({ msgid: 'presets-wecom', text: { content: '/presetlist' } }));
const presetReplies = transport.streamed
@ -454,10 +459,15 @@ test('Enterprise WeChat lists models and presets without prompting and advertise
await bridge.accept(frame({ msgid: 'help-models-wecom', text: { content: '/help' } }));
const help = transport.streamed.at(-1).content;
for (const command of ['/models', '/model', '/presetlist', '/preset', '/preset --default', '/stop', '/steer']) {
for (const command of [
'/models', '/model', '/reasoninglist', '/reasonings', '/reasoning',
'/presetlist', '/preset', '/preset --default', '/stop', '/steer',
]) {
assert.equal(help.includes(command), true, command);
}
assert.match(help, /\/model 2/);
assert.match(help, /\/model .*\[推理等级ID\]/);
assert.match(help, /示例:先发 \/models,再发 \/model 2 \[推理等级ID\]/);
assert.doesNotMatch(help, /\/model 2 high\b/);
assert.match(help, /\/preset id:<ID>/);
});

View file

@ -738,6 +738,12 @@ test('Weixin lists models and presets without prompting and advertises fast comm
assert.equal(creates, 0);
assert.equal(fixture.sessions.size, 0);
await bridge.accept(message('reasoning-weixin', '/reasoninglist'));
assert.match(sent.at(-1).text, /还没有会话/);
assert.equal(asks, 0);
assert.equal(creates, 0);
assert.equal(fixture.sessions.size, 0);
const presetReplyStart = sent.length;
await bridge.accept(message('presets-weixin', '/presetlist'));
const presetReplies = sent.slice(presetReplyStart).map((entry) => entry.text);
@ -769,10 +775,15 @@ test('Weixin lists models and presets without prompting and advertises fast comm
await bridge.accept(message('help-models-weixin', '/help'));
const help = sent.at(-1).text;
for (const command of ['/models', '/model', '/presetlist', '/preset', '/preset --default', '/stop', '/steer']) {
for (const command of [
'/models', '/model', '/reasoninglist', '/reasonings', '/reasoning',
'/presetlist', '/preset', '/preset --default', '/stop', '/steer',
]) {
assert.equal(help.includes(command), true, command);
}
assert.match(help, /\/model 2/);
assert.match(help, /\/model .*\[推理等级ID\]/);
assert.match(help, /示例:先发 \/models,再发 \/model 2 \[推理等级ID\]/);
assert.doesNotMatch(help, /\/model 2 high\b/);
assert.match(help, /\/preset id:<ID>/);
});