Skip to content

Commit 8c28bbd

Browse files
author
Yuan
committed
remerge
1 parent 0a4ed64 commit 8c28bbd

File tree

6 files changed

+589
-2
lines changed

6 files changed

+589
-2
lines changed

RELEASE_NOTE.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,22 @@
109109
- `file://` URLs
110110
- Windows drive paths (`C:\...`)
111111
- Windows UNC paths (`\\server\share\...`)
112+
113+
---
114+
115+
# Release Note (2026-02-09)
116+
117+
## 🐞 Bugfix: Plan-Mode Question Flow
118+
119+
- Fixed `tool=question` error flow getting stuck in bridge conversations.
120+
- Added question-proxy handling in bridge runtime:
121+
- captures question tool input when tool status is `error`
122+
- sends actionable question prompt back to IM chat (Feishu/Telegram)
123+
- accepts user reply by index or option text
124+
- resumes session with structured answer payload
125+
- Updated timeout behavior:
126+
- no longer auto-continues with defaults
127+
- after 15 minutes without reply, pending question is canceled with status notice
128+
- Added debug logs for real `question` tool payload inspection:
129+
- logs `status`, `input`, and `error` (with safe truncation)
130+
- logs explicit `payload-parse-failed` when normalization fails

src/handler/command.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ export type CommandContext = {
126126
chatAwaitingSaveFile: Map<string, boolean>;
127127
chatMaxFileSizeMb: Map<string, number>;
128128
chatMaxFileRetry: Map<string, number>;
129+
clearPendingQuestionForChat: (cacheKey: string) => void;
130+
clearAllPendingQuestions: () => void;
129131
ensureSession: () => Promise<string>;
130132
createNewSession: () => Promise<string | undefined>;
131133
sendCommandMessage: (content: string) => Promise<void>;
@@ -155,6 +157,8 @@ export async function handleSlashCommand(ctx: CommandContext): Promise<boolean>
155157
chatAwaitingSaveFile,
156158
chatMaxFileSizeMb,
157159
chatMaxFileRetry,
160+
clearPendingQuestionForChat,
161+
clearAllPendingQuestions,
158162
ensureSession,
159163
createNewSession,
160164
sendCommandMessage,
@@ -502,6 +506,7 @@ export async function handleSlashCommand(ctx: CommandContext): Promise<boolean>
502506
sessionToCtx.set(targetId, { chatId: ctx.chatId, senderId: ctx.senderId });
503507
chatAgent.delete(cacheKey);
504508
chatModel.delete(cacheKey);
509+
clearPendingQuestionForChat(cacheKey);
505510
await sendCommandMessage(`✅ 已切换到会话: ${targetId}`);
506511
return true;
507512
}
@@ -542,6 +547,7 @@ export async function handleSlashCommand(ctx: CommandContext): Promise<boolean>
542547
}
543548

544549
if (normalizedCommand === 'new') {
550+
clearPendingQuestionForChat(cacheKey);
545551
const sessionId = await createNewSession();
546552
if (sessionId) {
547553
await sendCommandMessage(`✅ 已切换到新会话: ${sessionId}`);
@@ -562,6 +568,7 @@ export async function handleSlashCommand(ctx: CommandContext): Promise<boolean>
562568
chatAwaitingSaveFile.clear();
563569
chatMaxFileSizeMb.clear();
564570
chatMaxFileRetry.clear();
571+
clearAllPendingQuestions();
565572

566573
if (globalState.__bridge_progress_msg_ids) {
567574
globalState.__bridge_progress_msg_ids.clear();

src/handler/event.flow.ts

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
EventSessionIdle,
77
OpencodeClient,
88
} from '@opencode-ai/sdk';
9+
import type { ToolPart } from '@opencode-ai/sdk';
910
import type { BridgeAdapter } from '../types';
1011
import type { AdapterMux } from './mux';
1112
import { bridgeLogger } from '../logger';
@@ -31,12 +32,20 @@ import {
3132
shouldSplitOutFinalAnswer,
3233
splitFinalAnswerFromExecution,
3334
} from './execution.flow';
35+
import {
36+
extractQuestionPayload,
37+
isQuestionToolPart,
38+
QUESTION_TIMEOUT_MS,
39+
renderQuestionPrompt,
40+
} from './question.proxy';
41+
import type { PendingQuestionState, NormalizedQuestionPayload } from './question.proxy';
3442

3543
type SessionContext = { chatId: string; senderId: string };
3644
type SelectedModel = { providerID: string; modelID: string; name?: string };
3745
type ListenerState = { isListenerStarted: boolean; shouldStopListener: boolean };
3846
type EventWithType = { type: string; properties?: unknown };
3947
type EventMessageBuffer = MessageBuffer & { __executionCarried?: boolean };
48+
const QUESTION_DEBUG_MAX_LEN = 4000;
4049

4150
export type EventFlowDeps = {
4251
listenerState: ListenerState;
@@ -53,8 +62,110 @@ export type EventFlowDeps = {
5362
chatAwaitingSaveFile: Map<string, boolean>;
5463
chatMaxFileSizeMb: Map<string, number>;
5564
chatMaxFileRetry: Map<string, number>;
65+
chatPendingQuestion: Map<string, PendingQuestionState>;
66+
pendingQuestionTimers: Map<string, NodeJS.Timeout>;
5667
};
5768

69+
function clearPendingQuestionForChat(deps: EventFlowDeps, cacheKey: string) {
70+
const timer = deps.pendingQuestionTimers.get(cacheKey);
71+
if (timer) {
72+
clearTimeout(timer);
73+
deps.pendingQuestionTimers.delete(cacheKey);
74+
}
75+
deps.chatPendingQuestion.delete(cacheKey);
76+
}
77+
78+
function clearAllPendingQuestions(deps: EventFlowDeps) {
79+
for (const timer of deps.pendingQuestionTimers.values()) {
80+
clearTimeout(timer);
81+
}
82+
deps.pendingQuestionTimers.clear();
83+
deps.chatPendingQuestion.clear();
84+
}
85+
86+
function getCacheKeyBySession(
87+
sessionId: string,
88+
deps: EventFlowDeps,
89+
): { cacheKey: string; adapterKey: string; chatId: string } | null {
90+
const ctx = deps.sessionToCtx.get(sessionId);
91+
const adapterKey = deps.sessionToAdapterKey.get(sessionId);
92+
if (!ctx || !adapterKey) return null;
93+
return {
94+
cacheKey: `${adapterKey}:${ctx.chatId}`,
95+
adapterKey,
96+
chatId: ctx.chatId,
97+
};
98+
}
99+
100+
function clipDebugText(value: string, max = QUESTION_DEBUG_MAX_LEN): string {
101+
if (value.length <= max) return value;
102+
return `${value.slice(0, max)}...<truncated>`;
103+
}
104+
105+
async function captureQuestionProxyIfNeeded(params: {
106+
part: ToolPart;
107+
sessionId: string;
108+
messageId: string;
109+
api: OpencodeClient;
110+
mux: AdapterMux;
111+
deps: EventFlowDeps;
112+
}): Promise<boolean> {
113+
const { part, sessionId, messageId, api, mux, deps } = params;
114+
if (!isQuestionToolPart(part)) return false;
115+
116+
const payloadMaybe = extractQuestionPayload(part?.state?.input);
117+
if (payloadMaybe === null) return false;
118+
const payload: NormalizedQuestionPayload = payloadMaybe;
119+
120+
const sessionCtx = getCacheKeyBySession(sessionId, deps);
121+
122+
if (!sessionCtx) return false;
123+
124+
const { cacheKey, adapterKey, chatId } = sessionCtx;
125+
const callID = part.callID || `question-${messageId}`;
126+
const existing = deps.chatPendingQuestion.get(cacheKey);
127+
if (existing && existing.callID === callID && existing.messageId === messageId) {
128+
return true;
129+
}
130+
131+
clearPendingQuestionForChat(deps, cacheKey);
132+
133+
const pending: PendingQuestionState = {
134+
key: cacheKey,
135+
adapterKey,
136+
chatId,
137+
sessionId,
138+
messageId,
139+
callID,
140+
payload,
141+
createdAt: Date.now(),
142+
dueAt: Date.now() + QUESTION_TIMEOUT_MS,
143+
};
144+
145+
deps.chatPendingQuestion.set(cacheKey, pending);
146+
147+
const adapter = mux.get(adapterKey);
148+
if (adapter) {
149+
await adapter.sendMessage(chatId, renderQuestionPrompt(pending)).catch(() => {});
150+
}
151+
152+
const timer = setTimeout(async () => {
153+
const current = deps.chatPendingQuestion.get(cacheKey);
154+
if (!current || current.callID !== callID || current.messageId !== messageId) return;
155+
156+
clearPendingQuestionForChat(deps, cacheKey);
157+
const currentAdapter = mux.get(current.adapterKey);
158+
if (currentAdapter) {
159+
await currentAdapter
160+
.sendMessage(current.chatId, '## Status\n⏰ 超时,本轮提问已取消。请重新发起问题。')
161+
.catch(() => {});
162+
}
163+
}, QUESTION_TIMEOUT_MS);
164+
165+
deps.pendingQuestionTimers.set(cacheKey, timer);
166+
return true;
167+
}
168+
58169
function isAbortedError(err: unknown): boolean {
59170
return (
60171
typeof err === 'object' &&
@@ -145,6 +256,15 @@ async function handleMessageUpdatedEvent(
145256
}
146257

147258
if (info.error) {
259+
const cache = getCacheKeyBySession(sid, deps);
260+
const pending = cache ? deps.chatPendingQuestion.get(cache.cacheKey) : undefined;
261+
262+
if (pending && pending.messageId === mid) {
263+
markStatus(deps.msgBuffers, mid, 'done', 'awaiting-user-reply');
264+
await flushMessage(adapter, ctx.chatId, mid, deps.msgBuffers, true);
265+
return;
266+
}
267+
148268
if (isAbortedError(info.error)) {
149269
markStatus(
150270
deps.msgBuffers,
@@ -181,6 +301,7 @@ async function handleMessageUpdatedEvent(
181301

182302
async function handleMessagePartUpdatedEvent(
183303
event: EventMessagePartUpdated,
304+
api: OpencodeClient,
184305
mux: AdapterMux,
185306
deps: EventFlowDeps,
186307
) {
@@ -225,6 +346,21 @@ async function handleMessagePartUpdatedEvent(
225346
buffer.selectedModel = selectedModel;
226347
}
227348
applyPartToBuffer(buffer, part, delta);
349+
350+
if (
351+
part.type === 'tool' &&
352+
(await captureQuestionProxyIfNeeded({
353+
part: part as ToolPart,
354+
sessionId,
355+
messageId,
356+
api,
357+
mux,
358+
deps,
359+
}))
360+
) {
361+
markStatus(deps.msgBuffers, messageId, 'done', 'awaiting-user-reply');
362+
}
363+
228364
bridgeLogger.debug(
229365
`[BridgeFlowDebug] part-applied sid=${sessionId} mid=${messageId} part=${part.type} textLen=${buffer.text.length} reasoningLen=${buffer.reasoning.length} tools=${buffer.tools.size} status=${buffer.status} note="${buffer.statusNote || ''}" hasPlatform=${!!buffer.platformMsgId}`,
230366
);
@@ -394,7 +530,7 @@ export async function startGlobalEventListenerWithDeps(
394530
bridgeLogger.debug(
395531
`[BridgeFlowDebug] part.updated sid=${p.sessionID} mid=${p.messageID} type=${p.type} deltaLen=${(pe.properties.delta || '').length}`,
396532
);
397-
await handleMessagePartUpdatedEvent(event as EventMessagePartUpdated, mux, deps);
533+
await handleMessagePartUpdatedEvent(event as EventMessagePartUpdated, api, mux, deps);
398534
continue;
399535
}
400536

@@ -447,4 +583,6 @@ export function stopGlobalEventListenerWithDeps(deps: EventFlowDeps) {
447583
deps.chatAwaitingSaveFile.clear();
448584
deps.chatMaxFileSizeMb.clear();
449585
deps.chatMaxFileRetry.clear();
586+
587+
clearAllPendingQuestions(deps);
450588
}

src/handler/incoming.flow.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ import { ERROR_HEADER, parseSlashCommand, globalState } from '../utils';
66
import { bridgeLogger } from '../logger';
77
import { handleSlashCommand } from './command';
88
import type { AdapterMux } from './mux';
9+
import {
10+
buildResumePrompt,
11+
parseUserReply,
12+
renderAnswerSummary,
13+
renderReplyHint,
14+
} from './question.proxy';
15+
import type { PendingQuestionState } from './question.proxy';
916

1017
type SessionContext = { chatId: string; senderId: string };
1118
type SelectedModel = { providerID: string; modelID: string; name?: string };
@@ -68,6 +75,9 @@ export type IncomingFlowDeps = {
6875
chatAwaitingSaveFile: Map<string, boolean>;
6976
chatMaxFileSizeMb: Map<string, number>;
7077
chatMaxFileRetry: Map<string, number>;
78+
chatPendingQuestion: Map<string, PendingQuestionState>;
79+
clearPendingQuestionForChat: (cacheKey: string) => void;
80+
clearAllPendingQuestions: () => void;
7181
formatUserError: (err: unknown) => string;
7282
};
7383

@@ -92,6 +102,7 @@ export const createIncomingHandlerWithDeps = (
92102
);
93103

94104
const slash = parseSlashCommand(text);
105+
const hasText = Boolean(text && text.trim());
95106
const cacheKey = `${adapterKey}:${chatId}`;
96107
const rawCommand = slash?.command?.toLowerCase();
97108
const normalizedCommand = normalizeSlashCommand(rawCommand);
@@ -203,6 +214,8 @@ export const createIncomingHandlerWithDeps = (
203214
chatAwaitingSaveFile: deps.chatAwaitingSaveFile,
204215
chatMaxFileSizeMb: deps.chatMaxFileSizeMb,
205216
chatMaxFileRetry: deps.chatMaxFileRetry,
217+
clearPendingQuestionForChat: deps.clearPendingQuestionForChat,
218+
clearAllPendingQuestions: deps.clearAllPendingQuestions,
206219
ensureSession,
207220
createNewSession,
208221
sendCommandMessage,
@@ -214,8 +227,40 @@ export const createIncomingHandlerWithDeps = (
214227
if (handled) return;
215228
}
216229

230+
const pendingQuestion = deps.chatPendingQuestion.get(cacheKey);
231+
if (pendingQuestion && !slash) {
232+
if (!hasText) {
233+
await adapter.sendMessage(chatId, renderReplyHint(pendingQuestion));
234+
return;
235+
}
236+
237+
const resolved = parseUserReply(text, pendingQuestion);
238+
if (!resolved.ok) {
239+
await adapter.sendMessage(chatId, renderReplyHint(pendingQuestion));
240+
return;
241+
}
242+
243+
deps.clearPendingQuestionForChat(cacheKey);
244+
await adapter.sendMessage(chatId, renderAnswerSummary(pendingQuestion, resolved.answers, 'user'));
245+
246+
const sessionId = await ensureSession();
247+
deps.sessionToAdapterKey.set(sessionId, adapterKey);
248+
deps.sessionToCtx.set(sessionId, { chatId, senderId });
249+
250+
const agent = deps.chatAgent.get(cacheKey);
251+
const model = deps.chatModel.get(cacheKey);
252+
await api.session.prompt({
253+
path: { id: sessionId },
254+
body: {
255+
parts: [{ type: 'text', text: buildResumePrompt(pendingQuestion, resolved.answers, 'user') }],
256+
...(agent ? { agent } : {}),
257+
...(model ? { model: { providerID: model.providerID, modelID: model.modelID } } : {}),
258+
},
259+
});
260+
return;
261+
}
262+
217263
const fileParts = (parts || []).filter(isFilePartInput);
218-
const hasText = Boolean(text && text.trim());
219264

220265
if (fileParts.length > 0) {
221266
const isSaveFileMode = deps.chatAwaitingSaveFile.get(cacheKey) === true;

0 commit comments

Comments
 (0)