From 9f03c6c74847d38b744847c475b1b13989b96a23 Mon Sep 17 00:00:00 2001 From: dsh-agent Date: Mon, 14 Sep 2026 19:03:08 +0800 Subject: [PATCH] =?UTF-8?q?fix(ask=5Fuser):=20dsh=200.1.5=20=E8=B5=B0=20us?= =?UTF-8?q?er-questions/request=20waterfall?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.1.5 移除了 userQuestions.provider,改为 waterfall 事件注册(与 approval/request 同形); 同时把 2026-09-12 只在编译产物上做过的修复回植到 src,避免源码/产物漂移。 --- lib/bridge.d.ts | 12 +++++---- lib/bridge.js | 72 +++++++++++++------------------------------------ src/bridge.ts | 72 ++++++++++++++----------------------------------- 3 files changed, 45 insertions(+), 111 deletions(-) diff --git a/lib/bridge.d.ts b/lib/bridge.d.ts index 9350f70..ba0f826 100644 --- a/lib/bridge.d.ts +++ b/lib/bridge.d.ts @@ -43,6 +43,8 @@ export declare class TelegramBridge { private hookTimer; private readonly pendingApprovalsTG; private disposeApprovalHook; + /** dsh 0.1.5 起 ask 走 'user-questions/request' waterfall(不再有 userQuestions.provider)。 */ + private disposeUserQuestionHook; /** sessionId → thinking indicator state (one notice per reasoning phase) */ private readonly thinkingSessions; /** callId → tool name (tool/result failure notices) */ @@ -130,12 +132,12 @@ export declare class TelegramBridge { private stopAllHeartbeats; private userQuestions; /** - * Wrap the UI provider's ask() so Telegram gets a parallel answer path. - * `Promise.race` decides; the UI path is untouched. The TG promise NEVER - * settles when there is no bound chat — race would kill the UI's window - * with that early rejection. + * dsh 0.1.5 移除了 `userQuestions.provider`:ask 现在走 'user-questions/request' + * waterfall(与 approval/request 同形),启动时注册一次即可,无需轮询 provider。 + * 让 TG 答案与 next()(UI 转发)赛跑;未绑定聊天时 TG promise 永不 settle, + * 于是 race 完全跟随 next()。 */ - private hookUserQuestions; + private onUserQuestionRequest; private registerTgAsk; private settleGuiSide; private formatAskPending; diff --git a/lib/bridge.js b/lib/bridge.js index 8feece3..d9d697d 100644 --- a/lib/bridge.js +++ b/lib/bridge.js @@ -111,6 +111,8 @@ export class TelegramBridge { hookTimer; pendingApprovalsTG = new Map(); disposeApprovalHook; + /** dsh 0.1.5 起 ask 走 'user-questions/request' waterfall(不再有 userQuestions.provider)。 */ + disposeUserQuestionHook; /** sessionId → thinking indicator state (one notice per reasoning phase) */ thinkingSessions = new Map(); /** callId → tool name (tool/result failure notices) */ @@ -151,7 +153,8 @@ export class TelegramBridge { // Restore persisted bindings (survive hot reload / restart) and install // the TG answering hooks (ask_user dual-path + approval dual-path). this.loadBindings(); - this.hookUserQuestions(); + this.disposeUserQuestionHook?.(); + this.disposeUserQuestionHook = this.ctx.on('user-questions/request', (req, next) => this.onUserQuestionRequest(req, next)); this.disposeApprovalHook?.(); this.disposeApprovalHook = this.ctx.on('approval/request', (req, next) => this.onApprovalRequest(req, next)); void this.client.setMyCommands([ @@ -210,6 +213,8 @@ export class TelegramBridge { clearTimeout(this.hookTimer); this.hookTimer = undefined; } + this.disposeUserQuestionHook?.(); + this.disposeUserQuestionHook = undefined; this.disposeApprovalHook?.(); this.disposeApprovalHook = undefined; if (this.pollPromise) { @@ -1348,64 +1353,23 @@ export class TelegramBridge { return this.ctx.userQuestions; } /** - * Wrap the UI provider's ask() so Telegram gets a parallel answer path. - * `Promise.race` decides; the UI path is untouched. The TG promise NEVER - * settles when there is no bound chat — race would kill the UI's window - * with that early rejection. + * dsh 0.1.5 移除了 `userQuestions.provider`:ask 现在走 'user-questions/request' + * waterfall(与 approval/request 同形),启动时注册一次即可,无需轮询 provider。 + * 让 TG 答案与 next()(UI 转发)赛跑;未绑定聊天时 TG promise 永不 settle, + * 于是 race 完全跟随 next()。 */ - hookUserQuestions(attempt = 0) { + onUserQuestionRequest(req, next) { + const tg = this.registerTgAsk(req); + let gui; try { - const provider = this.userQuestions()?.provider; - if (provider) { - if (provider.__tgHooked && provider.__tgRealAsk) { - // Hot reload: re-point the wrapper at THIS bridge instance. - const realAsk = provider.__tgRealAsk; - const self = this; - provider.ask = function (request) { - const tg = self.registerTgAsk(request); - let gui; - try { - gui = realAsk(request); - } - catch (err) { - tg.reject(err); - throw err; - } - void gui.then(() => self.settleGuiSide(request), () => self.settleGuiSide(request)); - return Promise.race([tg.promise, gui]); - }; - return; - } - const realAsk = provider.ask.bind(provider); - provider.__tgRealAsk = realAsk; - const self = this; - provider.ask = function (request) { - const tg = self.registerTgAsk(request); - let gui; - try { - gui = realAsk(request); - } - catch (err) { - tg.reject(err); - throw err; - } - void gui.then(() => self.settleGuiSide(request), () => self.settleGuiSide(request)); - return Promise.race([tg.promise, gui]); - }; - provider.__tgHooked = true; - this.ctx.logger.info('dsh-telegram-channel: ask_user TG answering hook installed'); - return; - } + gui = Promise.resolve().then(next); } catch (err) { - if (attempt === 0) - this.ctx.logger.warn(`dsh-telegram-channel: userQuestions hook deferred: ${this.redact(err)}`); + tg.reject(err); + throw err; } - if (attempt >= 30) { - this.ctx.logger.warn('dsh-telegram-channel: user-questions provider never appeared; TG answering disabled'); - return; - } - this.hookTimer = setTimeout(() => this.hookUserQuestions(attempt + 1), 2000); + void gui.then(() => this.settleGuiSide(req), () => this.settleGuiSide(req)); + return Promise.race([tg.promise, gui]); } registerTgAsk(request) { let resolveFn = () => { }; diff --git a/src/bridge.ts b/src/bridge.ts index ee2a4f8..29e67fa 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -252,6 +252,8 @@ export class TelegramBridge { private hookTimer: ReturnType | undefined private readonly pendingApprovalsTG = new Map() private disposeApprovalHook: (() => void) | undefined + /** dsh 0.1.5 起 ask 走 'user-questions/request' waterfall(不再有 userQuestions.provider)。 */ + private disposeUserQuestionHook: (() => void) | undefined /** sessionId → thinking indicator state (one notice per reasoning phase) */ private readonly thinkingSessions = new Map() /** callId → tool name (tool/result failure notices) */ @@ -295,7 +297,10 @@ export class TelegramBridge { // Restore persisted bindings (survive hot reload / restart) and install // the TG answering hooks (ask_user dual-path + approval dual-path). this.loadBindings() - this.hookUserQuestions() + this.disposeUserQuestionHook?.() + this.disposeUserQuestionHook = (this.ctx as unknown as { + on: (event: string, handler: (req: unknown, next: () => Promise) => Promise) => () => void + }).on('user-questions/request', (req, next) => this.onUserQuestionRequest(req, next)) this.disposeApprovalHook?.() this.disposeApprovalHook = (this.ctx as unknown as { on: (event: string, handler: (req: ApprovalRequestLike, next: () => Promise) => Promise) => () => void @@ -355,6 +360,8 @@ export class TelegramBridge { clearTimeout(this.hookTimer) this.hookTimer = undefined } + this.disposeUserQuestionHook?.() + this.disposeUserQuestionHook = undefined this.disposeApprovalHook?.() this.disposeApprovalHook = undefined if (this.pollPromise) { @@ -1525,61 +1532,22 @@ export class TelegramBridge { } /** - * Wrap the UI provider's ask() so Telegram gets a parallel answer path. - * `Promise.race` decides; the UI path is untouched. The TG promise NEVER - * settles when there is no bound chat — race would kill the UI's window - * with that early rejection. + * dsh 0.1.5 移除了 `userQuestions.provider`:ask 现在走 'user-questions/request' + * waterfall(与 approval/request 同形),启动时注册一次即可,无需轮询 provider。 + * 让 TG 答案与 next()(UI 转发)赛跑;未绑定聊天时 TG promise 永不 settle, + * 于是 race 完全跟随 next()。 */ - private hookUserQuestions(attempt = 0): void { + private onUserQuestionRequest(req: unknown, next: () => Promise): Promise { + const tg = this.registerTgAsk(req) + let gui: Promise try { - const provider = this.userQuestions()?.provider - if (provider) { - if (provider.__tgHooked && provider.__tgRealAsk) { - // Hot reload: re-point the wrapper at THIS bridge instance. - const realAsk = provider.__tgRealAsk - const self = this - provider.ask = function (request: unknown): Promise { - const tg = self.registerTgAsk(request) - let gui: Promise - try { - gui = realAsk(request) - } catch (err) { - tg.reject(err as Error) - throw err - } - void gui.then(() => self.settleGuiSide(request), () => self.settleGuiSide(request)) - return Promise.race([tg.promise, gui]) - } - return - } - const realAsk = provider.ask.bind(provider) as (request: unknown) => Promise - provider.__tgRealAsk = realAsk - const self = this - provider.ask = function (request: unknown): Promise { - const tg = self.registerTgAsk(request) - let gui: Promise - try { - gui = realAsk(request) - } catch (err) { - tg.reject(err as Error) - throw err - } - void gui.then(() => self.settleGuiSide(request), () => self.settleGuiSide(request)) - return Promise.race([tg.promise, gui]) - } - provider.__tgHooked = true - this.ctx.logger.info('dsh-telegram-channel: ask_user TG answering hook installed') - return - } + gui = Promise.resolve().then(next) } catch (err) { - if (attempt === 0) - this.ctx.logger.warn(`dsh-telegram-channel: userQuestions hook deferred: ${this.redact(err)}`) + tg.reject(err as Error) + throw err } - if (attempt >= 30) { - this.ctx.logger.warn('dsh-telegram-channel: user-questions provider never appeared; TG answering disabled') - return - } - this.hookTimer = setTimeout(() => this.hookUserQuestions(attempt + 1), 2000) + void gui.then(() => this.settleGuiSide(req), () => this.settleGuiSide(req)) + return Promise.race([tg.promise, gui]) } private registerTgAsk(request: unknown): { promise: Promise; reject: (err: Error) => void } {