fix: stabilize /model reasoning-effort selection (v0.3.5)

Keep pending model outside the picker map, omit undefined reasoningEffort in selectModel payload, auto-apply single-effort models, and surface RPC errors.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
owo
2026-08-14 22:41:43 +08:00
co-authored by Cursor
parent fb5a334edb
commit 819010de1c
10 changed files with 190 additions and 29 deletions
+2
View File
@@ -19,6 +19,8 @@ export declare class TelegramBridge {
private readonly maxMessageLength; private readonly maxMessageLength;
private readonly bindings; private readonly bindings;
private readonly pickers; private readonly pickers;
/** chatId → model awaiting reasoning-effort pick (kept outside picker so list refreshes won't drop it). */
private readonly pendingModels;
private polling; private polling;
private offset; private offset;
private pollPromise; private pollPromise;
+32 -9
View File
@@ -12,7 +12,8 @@ const WS_CB = 'ws:';
const SID_CB = 'sid:'; const SID_CB = 'sid:';
const BACK_WS_CB = 'wb'; const BACK_WS_CB = 'wb';
const MODEL_CB = 'mdl:'; const MODEL_CB = 'mdl:';
const EFFORT_CB = 'me:'; /** Use eff: (not me:) — short prefix, no collision with other callbacks. */
const EFFORT_CB = 'eff:';
const BACK_MODEL_CB = 'mb'; const BACK_MODEL_CB = 'mb';
const MAX_BUTTONS = 40; const MAX_BUTTONS = 40;
function lastContextKeyboard() { function lastContextKeyboard() {
@@ -37,6 +38,8 @@ export class TelegramBridge {
maxMessageLength; maxMessageLength;
bindings = new Map(); bindings = new Map();
pickers = new Map(); pickers = new Map();
/** chatId → model awaiting reasoning-effort pick (kept outside picker so list refreshes won't drop it). */
pendingModels = new Map();
polling = false; polling = false;
offset; offset;
pollPromise; pollPromise;
@@ -89,6 +92,7 @@ export class TelegramBridge {
// Never dispose host agents — only clear remote bindings. // Never dispose host agents — only clear remote bindings.
this.bindings.clear(); this.bindings.clear();
this.pickers.clear(); this.pickers.clear();
this.pendingModels.clear();
if (this.pollPromise) { if (this.pollPromise) {
await this.pollPromise.catch(() => { }); await this.pollPromise.catch(() => { });
this.pollPromise = undefined; this.pollPromise = undefined;
@@ -196,21 +200,33 @@ export class TelegramBridge {
await this.client.sendMessage(chatId, MSG.PICKER_STALE); await this.client.sendMessage(chatId, MSG.PICKER_STALE);
return; return;
} }
if (option.efforts && option.efforts.length > 0) { const efforts = (option.efforts ?? []).filter((e) => e.id);
if (efforts.length === 1) {
// Single effort (often only "off") — apply immediately, no second tap.
await this.applyModel(chatId, cq.id, { ...option, efforts }, efforts[0].id);
return;
}
if (efforts.length > 1) {
const pending = { ...option, efforts };
this.pendingModels.set(String(chatId), pending);
if (picker) if (picker)
picker.pendingModel = option; picker.pendingModel = pending;
await this.client.answerCallbackQuery(cq.id); await this.client.answerCallbackQuery(cq.id);
await this.sendEffortPicker(chatId, option); await this.sendEffortPicker(chatId, pending);
return; return;
} }
await this.applyModel(chatId, cq.id, option); await this.applyModel(chatId, cq.id, option);
return; return;
} }
if (data.startsWith(EFFORT_CB)) { if (data.startsWith(EFFORT_CB) || data.startsWith('me:')) {
const index = Number(data.slice(EFFORT_CB.length)); // Accept legacy "me:" callbacks from older bot messages.
const pending = picker?.pendingModel; const rawIndex = data.startsWith(EFFORT_CB)
? data.slice(EFFORT_CB.length)
: data.slice('me:'.length);
const index = Number(rawIndex);
const pending = this.pendingModels.get(String(chatId)) ?? picker?.pendingModel;
const effort = pending?.efforts?.[index]; const effort = pending?.efforts?.[index];
if (!pending || !effort) { if (!pending || !effort?.id) {
await this.client.answerCallbackQuery(cq.id, '列表已过期'); await this.client.answerCallbackQuery(cq.id, '列表已过期');
await this.client.sendMessage(chatId, MSG.PICKER_STALE); await this.client.sendMessage(chatId, MSG.PICKER_STALE);
return; return;
@@ -421,19 +437,26 @@ export class TelegramBridge {
await this.client.sendMessage(chatId, MSG.NEED_BIND); await this.client.sendMessage(chatId, MSG.NEED_BIND);
return; return;
} }
// Ensure session is live before selectModel (same as followup path).
await this.ensureLiveAgent(binding.sessionId);
try { try {
const selected = await selectSessionModel(this.ctx, binding.sessionId, { const selected = await selectSessionModel(this.ctx, binding.sessionId, {
provider: option.provider, provider: option.provider,
model: option.model, model: option.model,
reasoningEffort, reasoningEffort,
}); });
this.pendingModels.delete(String(chatId));
const picker = this.pickers.get(String(chatId));
if (picker)
picker.pendingModel = undefined;
await this.client.answerCallbackQuery(callbackId, '已切换'); await this.client.answerCallbackQuery(callbackId, '已切换');
await this.client.sendMessage(chatId, MSG.MODEL_SET(formatModel(selected))); await this.client.sendMessage(chatId, MSG.MODEL_SET(formatModel(selected)));
} }
catch (err) { catch (err) {
this.ctx.logger.warn(`dsh-telegram-channel: selectModel failed: ${this.redact(err)}`); this.ctx.logger.warn(`dsh-telegram-channel: selectModel failed: ${this.redact(err)}`);
const detail = err instanceof Error ? err.message : String(err);
await this.client.answerCallbackQuery(callbackId, '切换失败'); await this.client.answerCallbackQuery(callbackId, '切换失败');
await this.client.sendMessage(chatId, MSG.MODEL_FAILED); await this.client.sendMessage(chatId, MSG.MODEL_FAILED(detail));
} }
} }
liveAgents() { liveAgents() {
+1 -1
View File
@@ -20,7 +20,7 @@ export declare const MSG: {
readonly MODEL_UNROUTABLE: (current: string) => string; readonly MODEL_UNROUTABLE: (current: string) => string;
readonly MODEL_EMPTY: (current: string) => string; readonly MODEL_EMPTY: (current: string) => string;
readonly MODEL_SET: (selected: string) => string; readonly MODEL_SET: (selected: string) => string;
readonly MODEL_FAILED: "切换模型失败。请稍后重试或在 Web 中切换。"; readonly MODEL_FAILED: (detail?: string) => string;
readonly unknown: (command: string) => string; readonly unknown: (command: string) => string;
}; };
export type ParsedCommand = { export type ParsedCommand = {
+6 -1
View File
@@ -53,7 +53,12 @@ export const MSG = {
MODEL_SET(selected) { MODEL_SET(selected) {
return `已切换模型:${selected}\n下一回合生效。`; return `已切换模型:${selected}\n下一回合生效。`;
}, },
MODEL_FAILED: '切换模型失败。请稍后重试或在 Web 中切换。', MODEL_FAILED(detail) {
const tip = '切换模型失败。请稍后重试或在 Web 中切换。';
if (!detail)
return tip;
return `${tip}\n详情:${detail}`;
},
unknown(command) { unknown(command) {
return `未知命令 ${command}。发送 /help 查看可用命令。`; return `未知命令 ${command}。发送 /help 查看可用命令。`;
}, },
+7 -3
View File
@@ -53,12 +53,16 @@ export async function selectSessionModel(ctx, sessionId, selection) {
if (!api?.sessions?.selectModel) { if (!api?.sessions?.selectModel) {
throw new Error('apiProxy unavailable (use ctx.get / inject apiProxy)'); throw new Error('apiProxy unavailable (use ctx.get / inject apiProxy)');
} }
const raw = await call(api.sessions.selectModel, { // Omit undefined reasoningEffort — some validators treat explicit undefined as invalid.
const payload = {
sessionId, sessionId,
provider: selection.provider, provider: selection.provider,
model: selection.model, model: selection.model,
reasoningEffort: selection.reasoningEffort, };
}); if (selection.reasoningEffort !== undefined && selection.reasoningEffort !== '') {
payload.reasoningEffort = selection.reasoningEffort;
}
const raw = await call(api.sessions.selectModel, payload);
const value = unwrap(raw); const value = unwrap(raw);
if (!value?.selected) { if (!value?.selected) {
throw new Error(unwrapError(raw) ?? 'failed to select model'); throw new Error(unwrapError(raw) ?? 'failed to select model');
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dsh-telegram-channel", "name": "dsh-telegram-channel",
"version": "0.3.4", "version": "0.3.5",
"description": "DeepSeek Harness Telegram mobile remote: workspace→session, /last context, /model — dsh-plugin", "description": "DeepSeek Harness Telegram mobile remote: workspace→session, /last context, /model — dsh-plugin",
"keywords": [ "keywords": [
"telegram", "telegram",
+31 -9
View File
@@ -64,7 +64,8 @@ const WS_CB = 'ws:'
const SID_CB = 'sid:' const SID_CB = 'sid:'
const BACK_WS_CB = 'wb' const BACK_WS_CB = 'wb'
const MODEL_CB = 'mdl:' const MODEL_CB = 'mdl:'
const EFFORT_CB = 'me:' /** Use eff: (not me:) — short prefix, no collision with other callbacks. */
const EFFORT_CB = 'eff:'
const BACK_MODEL_CB = 'mb' const BACK_MODEL_CB = 'mb'
const MAX_BUTTONS = 40 const MAX_BUTTONS = 40
@@ -94,6 +95,8 @@ export class TelegramBridge {
private readonly bindings = new Map<string, Binding>() private readonly bindings = new Map<string, Binding>()
private readonly pickers = new Map<string, PickerState>() private readonly pickers = new Map<string, PickerState>()
/** chatId → model awaiting reasoning-effort pick (kept outside picker so list refreshes won't drop it). */
private readonly pendingModels = new Map<string, ModelOption>()
private polling = false private polling = false
private offset: number | undefined private offset: number | undefined
private pollPromise: Promise<void> | undefined private pollPromise: Promise<void> | undefined
@@ -149,6 +152,7 @@ export class TelegramBridge {
// Never dispose host agents — only clear remote bindings. // Never dispose host agents — only clear remote bindings.
this.bindings.clear() this.bindings.clear()
this.pickers.clear() this.pickers.clear()
this.pendingModels.clear()
if (this.pollPromise) { if (this.pollPromise) {
await this.pollPromise.catch(() => {}) await this.pollPromise.catch(() => {})
this.pollPromise = undefined this.pollPromise = undefined
@@ -261,20 +265,32 @@ export class TelegramBridge {
await this.client.sendMessage(chatId, MSG.PICKER_STALE) await this.client.sendMessage(chatId, MSG.PICKER_STALE)
return return
} }
if (option.efforts && option.efforts.length > 0) { const efforts = (option.efforts ?? []).filter((e) => e.id)
if (picker) picker.pendingModel = option if (efforts.length === 1) {
// Single effort (often only "off") — apply immediately, no second tap.
await this.applyModel(chatId, cq.id, { ...option, efforts }, efforts[0]!.id)
return
}
if (efforts.length > 1) {
const pending = { ...option, efforts }
this.pendingModels.set(String(chatId), pending)
if (picker) picker.pendingModel = pending
await this.client.answerCallbackQuery(cq.id) await this.client.answerCallbackQuery(cq.id)
await this.sendEffortPicker(chatId, option) await this.sendEffortPicker(chatId, pending)
return return
} }
await this.applyModel(chatId, cq.id, option) await this.applyModel(chatId, cq.id, option)
return return
} }
if (data.startsWith(EFFORT_CB)) { if (data.startsWith(EFFORT_CB) || data.startsWith('me:')) {
const index = Number(data.slice(EFFORT_CB.length)) // Accept legacy "me:" callbacks from older bot messages.
const pending = picker?.pendingModel const rawIndex = data.startsWith(EFFORT_CB)
? data.slice(EFFORT_CB.length)
: data.slice('me:'.length)
const index = Number(rawIndex)
const pending = this.pendingModels.get(String(chatId)) ?? picker?.pendingModel
const effort = pending?.efforts?.[index] const effort = pending?.efforts?.[index]
if (!pending || !effort) { if (!pending || !effort?.id) {
await this.client.answerCallbackQuery(cq.id, '列表已过期') await this.client.answerCallbackQuery(cq.id, '列表已过期')
await this.client.sendMessage(chatId, MSG.PICKER_STALE) await this.client.sendMessage(chatId, MSG.PICKER_STALE)
return return
@@ -503,18 +519,24 @@ export class TelegramBridge {
await this.client.sendMessage(chatId, MSG.NEED_BIND) await this.client.sendMessage(chatId, MSG.NEED_BIND)
return return
} }
// Ensure session is live before selectModel (same as followup path).
await this.ensureLiveAgent(binding.sessionId)
try { try {
const selected = await selectSessionModel(this.ctx, binding.sessionId, { const selected = await selectSessionModel(this.ctx, binding.sessionId, {
provider: option.provider, provider: option.provider,
model: option.model, model: option.model,
reasoningEffort, reasoningEffort,
}) })
this.pendingModels.delete(String(chatId))
const picker = this.pickers.get(String(chatId))
if (picker) picker.pendingModel = undefined
await this.client.answerCallbackQuery(callbackId, '已切换') await this.client.answerCallbackQuery(callbackId, '已切换')
await this.client.sendMessage(chatId, MSG.MODEL_SET(formatModel(selected))) await this.client.sendMessage(chatId, MSG.MODEL_SET(formatModel(selected)))
} catch (err) { } catch (err) {
this.ctx.logger.warn(`dsh-telegram-channel: selectModel failed: ${this.redact(err)}`) this.ctx.logger.warn(`dsh-telegram-channel: selectModel failed: ${this.redact(err)}`)
const detail = err instanceof Error ? err.message : String(err)
await this.client.answerCallbackQuery(callbackId, '切换失败') await this.client.answerCallbackQuery(callbackId, '切换失败')
await this.client.sendMessage(chatId, MSG.MODEL_FAILED) await this.client.sendMessage(chatId, MSG.MODEL_FAILED(detail))
} }
} }
+5 -1
View File
@@ -52,7 +52,11 @@ export const MSG = {
MODEL_SET(selected: string): string { MODEL_SET(selected: string): string {
return `已切换模型:${selected}\n下一回合生效。` return `已切换模型:${selected}\n下一回合生效。`
}, },
MODEL_FAILED: '切换模型失败。请稍后重试或在 Web 中切换。', MODEL_FAILED(detail?: string): string {
const tip = '切换模型失败。请稍后重试或在 Web 中切换。'
if (!detail) return tip
return `${tip}\n详情:${detail}`
},
unknown(command: string): string { unknown(command: string): string {
return `未知命令 ${command}。发送 /help 查看可用命令。` return `未知命令 ${command}。发送 /help 查看可用命令。`
}, },
+12 -3
View File
@@ -89,12 +89,21 @@ export async function selectSessionModel(
if (!api?.sessions?.selectModel) { if (!api?.sessions?.selectModel) {
throw new Error('apiProxy unavailable (use ctx.get / inject apiProxy)') throw new Error('apiProxy unavailable (use ctx.get / inject apiProxy)')
} }
const raw = await call(api.sessions.selectModel, { // Omit undefined reasoningEffort — some validators treat explicit undefined as invalid.
const payload: {
sessionId: string
provider: string
model: string
reasoningEffort?: string
} = {
sessionId, sessionId,
provider: selection.provider, provider: selection.provider,
model: selection.model, model: selection.model,
reasoningEffort: selection.reasoningEffort, }
}) if (selection.reasoningEffort !== undefined && selection.reasoningEffort !== '') {
payload.reasoningEffort = selection.reasoningEffort
}
const raw = await call(api.sessions.selectModel, payload)
const value = unwrap<{ selected: { provider: string; model: string; reasoningEffort?: string } }>(raw) const value = unwrap<{ selected: { provider: string; model: string; reasoningEffort?: string } }>(raw)
if (!value?.selected) { if (!value?.selected) {
throw new Error(unwrapError(raw) ?? 'failed to select model') throw new Error(unwrapError(raw) ?? 'failed to select model')
+93 -1
View File
@@ -440,7 +440,99 @@ test('/model lists and selects via apiProxy', async () => {
sessionId: 'live-mdl', sessionId: 'live-mdl',
provider: 'deepseek', provider: 'deepseek',
model: 'chat', model: 'chat',
reasoningEffort: undefined, })
assert.match(sent.at(-1)!.text, /已切换模型/)
})
test('/model effort picker applies reasoningEffort', async () => {
const sent: SentMessage[] = []
const followups: UserMessage[] = []
const agent = makeAgent('live-eff', followups)
let selected: unknown
const ctx = {
logger: { info() {}, warn() {}, error() {} },
agents: {
list: () => [agent],
roots: () => [agent],
get: () => agent,
},
apiProxy: {
sessions: {
models: async () => rpcOk({
current: { provider: 'deepseek', model: 'reasoner' },
routable: true,
groups: [{
id: 'deepseek',
name: 'DeepSeek',
models: [{
id: 'reasoner',
name: 'Reasoner',
reasoning: {
efforts: [
{ id: 'high', name: 'High' },
{ id: 'max', name: 'Max' },
],
},
}],
}],
}),
selectModel: async (req: { payload: unknown }) => {
selected = req.payload
const p = req.payload as { provider: string; model: string; reasoningEffort?: string }
return rpcOk({
selected: {
provider: p.provider,
model: p.model,
reasoningEffort: p.reasoningEffort,
},
})
},
},
},
on() { return () => {} },
}
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent),
sleep: async () => {},
})
await bridge.processUpdate({
update_id: 1,
callback_query: {
id: 'bind',
from: { id: 1 },
message: { message_id: 1, date: 0, chat: { id: 10, type: 'private' } },
data: `${BIND_CB_PREFIX}live-eff`,
},
})
await bridge.processUpdate(messageUpdate(10, 1, '/model', 2))
await bridge.processUpdate({
update_id: 3,
callback_query: {
id: 'pick-r',
from: { id: 1 },
message: { message_id: 2, date: 0, chat: { id: 10, type: 'private' } },
data: 'mdl:0',
},
})
assert.match(sent.at(-1)!.text, /reasoning effort|请选择/)
assert.equal(sent.at(-1)!.replyMarkup?.inline_keyboard?.[0]?.[0]?.callback_data, 'eff:0')
await bridge.processUpdate({
update_id: 4,
callback_query: {
id: 'pick-eff',
from: { id: 1 },
message: { message_id: 3, date: 0, chat: { id: 10, type: 'private' } },
data: 'eff:1',
},
})
assert.deepEqual(selected, {
sessionId: 'live-eff',
provider: 'deepseek',
model: 'reasoner',
reasoningEffort: 'max',
}) })
assert.match(sent.at(-1)!.text, /已切换模型/) assert.match(sent.at(-1)!.text, /已切换模型/)
}) })