Skip to content

Commit b229271

Browse files
committed
fix(聊天): 修复提问工具启动竞态
- 在工具执行开始时发布 AskUserQuestion,确保挂起状态先建立 - 倒计时归零后统一关闭选项、自定义输入和提交 - 补充生命周期、网关、超时与交互回归测试
1 parent b6d6231 commit b229271

9 files changed

Lines changed: 1016 additions & 46 deletions

File tree

crates/agent-gateway/web/src/components/chat/AskUserQuestionCard.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ function formatCountdown(remainingMs: number) {
2626
/**
2727
* 倒计时提示:优先使用调用方传入的权威截止时间(GUI 读工具挂起表,WebUI 读
2828
* 网关参数上的 deadline 盖章),两端与桌面计时同源;缺失时(历史/降级数据)
29-
* 回退为挂载时刻近似。超时后 tool_result 会把卡片切到只读态。
29+
* 回退为挂载时刻近似。倒计时归零立即禁止交互,随后 tool_result 把卡片
30+
* 切到只读态。
3031
*/
3132
function useAnswerCountdown(active: boolean, deadlineAt?: number) {
3233
const [fallbackDeadline] = useState(() => Date.now() + ASK_USER_QUESTION_TIMEOUT_MS);
@@ -98,8 +99,10 @@ export function AskUserQuestionCard({
9899

99100
const isSettled = (answers?.length ?? 0) > 0;
100101
const selections = isSettled ? settledSelections : draftSelections;
101-
const canInteract = interactive && !isSettled && !cancelled && !submitting;
102-
const remainingMs = useAnswerCountdown(interactive && !isSettled && !cancelled, deadlineAt);
102+
const countdownActive = interactive && !isSettled && !cancelled;
103+
const remainingMs = useAnswerCountdown(countdownActive, deadlineAt);
104+
const countdownExpired = countdownActive && remainingMs <= 0;
105+
const canInteract = countdownActive && remainingMs > 0 && !submitting;
103106

104107
// 该题是否已作答:普通选项已选,或“其他”选中且文本非空。
105108
const isQuestionAnswered = (questionId: string) => {
@@ -267,7 +270,9 @@ export function AskUserQuestionCard({
267270
canInteract && !isSelected
268271
? "hover:border-border/70 hover:bg-foreground/[0.03] dark:hover:border-white/[0.14]"
269272
: "",
270-
!canInteract && !isSelected && (isSettled || cancelled) ? "opacity-55" : "",
273+
!canInteract && !isSelected && (isSettled || cancelled || countdownExpired)
274+
? "opacity-55"
275+
: "",
271276
canInteract ? "cursor-pointer" : "cursor-default",
272277
)}
273278
>
@@ -323,7 +328,9 @@ export function AskUserQuestionCard({
323328
canInteract && !activeCustomSelected
324329
? "hover:border-border/70 hover:bg-foreground/[0.03] dark:hover:border-white/[0.14]"
325330
: "",
326-
!canInteract && !activeCustomSelected && (isSettled || cancelled)
331+
!canInteract &&
332+
!activeCustomSelected &&
333+
(isSettled || cancelled || countdownExpired)
327334
? "opacity-55"
328335
: "",
329336
canInteract ? "cursor-pointer" : "cursor-default",
@@ -403,7 +410,7 @@ export function AskUserQuestionCard({
403410
</span>
404411
<button
405412
type="button"
406-
disabled={!allAnswered || submitting}
413+
disabled={!allAnswered || !canInteract}
407414
onClick={() => void submit()}
408415
className="shrink-0 rounded-lg bg-primary px-3 py-1.5 text-[calc(11px*var(--zone-font-scale,1))] font-medium leading-none text-primary-foreground transition-opacity hover:opacity-90 disabled:pointer-events-none disabled:opacity-40"
409416
>

crates/agent-gui/src/components/chat/AskUserQuestionCard.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ function formatCountdown(remainingMs: number) {
2626
/**
2727
* 倒计时提示:优先使用调用方传入的权威截止时间(GUI 读工具挂起表,WebUI 读
2828
* 网关参数上的 deadline 盖章),两端与桌面计时同源;缺失时(历史/降级数据)
29-
* 回退为挂载时刻近似。超时后 tool_result 会把卡片切到只读态。
29+
* 回退为挂载时刻近似。倒计时归零立即禁止交互,随后 tool_result 把卡片
30+
* 切到只读态。
3031
*/
3132
function useAnswerCountdown(active: boolean, deadlineAt?: number) {
3233
const [fallbackDeadline] = useState(() => Date.now() + ASK_USER_QUESTION_TIMEOUT_MS);
@@ -98,8 +99,10 @@ export function AskUserQuestionCard({
9899

99100
const isSettled = (answers?.length ?? 0) > 0;
100101
const selections = isSettled ? settledSelections : draftSelections;
101-
const canInteract = interactive && !isSettled && !cancelled && !submitting;
102-
const remainingMs = useAnswerCountdown(interactive && !isSettled && !cancelled, deadlineAt);
102+
const countdownActive = interactive && !isSettled && !cancelled;
103+
const remainingMs = useAnswerCountdown(countdownActive, deadlineAt);
104+
const countdownExpired = countdownActive && remainingMs <= 0;
105+
const canInteract = countdownActive && remainingMs > 0 && !submitting;
103106

104107
// 该题是否已作答:普通选项已选,或“其他”选中且文本非空。
105108
const isQuestionAnswered = (questionId: string) => {
@@ -267,7 +270,9 @@ export function AskUserQuestionCard({
267270
canInteract && !isSelected
268271
? "hover:border-border/70 hover:bg-foreground/[0.03] dark:hover:border-white/[0.14]"
269272
: "",
270-
!canInteract && !isSelected && (isSettled || cancelled) ? "opacity-55" : "",
273+
!canInteract && !isSelected && (isSettled || cancelled || countdownExpired)
274+
? "opacity-55"
275+
: "",
271276
canInteract ? "cursor-pointer" : "cursor-default",
272277
)}
273278
>
@@ -323,7 +328,9 @@ export function AskUserQuestionCard({
323328
canInteract && !activeCustomSelected
324329
? "hover:border-border/70 hover:bg-foreground/[0.03] dark:hover:border-white/[0.14]"
325330
: "",
326-
!canInteract && !activeCustomSelected && (isSettled || cancelled)
331+
!canInteract &&
332+
!activeCustomSelected &&
333+
(isSettled || cancelled || countdownExpired)
327334
? "opacity-55"
328335
: "",
329336
canInteract ? "cursor-pointer" : "cursor-default",
@@ -403,7 +410,7 @@ export function AskUserQuestionCard({
403410
</span>
404411
<button
405412
type="button"
406-
disabled={!allAnswered || submitting}
413+
disabled={!allAnswered || !canInteract}
407414
onClick={() => void submit()}
408415
className="shrink-0 rounded-lg bg-primary px-3 py-1.5 text-[calc(11px*var(--zone-font-scale,1))] font-medium leading-none text-primary-foreground transition-opacity hover:opacity-90 disabled:pointer-events-none disabled:opacity-40"
409416
>

crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -620,8 +620,9 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP
620620

621621
function queueToolCallDelta(toolCall: ToolCall, round: number) {
622622
if (!shouldShowToolEvent(toolCall)) return;
623-
// 提问卡必须等问题与选项全部生成完毕再显示:跳过流式增量,双端
624-
// (GUI 回合与网关 tool_call_delta)都只在 onToolCall 拿到完整参数后出现。
623+
// 提问卡必须等问题与选项全部生成完毕且工具真正开始执行后再显示:
624+
// 流式增量与 onToolCall 都只做内部记账,双端统一由
625+
// onToolExecutionStart 发布可交互卡片。
625626
if (toolCall.name === ASK_USER_QUESTION_TOOL_NAME) return;
626627
pendingToolCallDeltas.set(toolCallDeltaKey(round, toolCall.id), { round, toolCall });
627628
schedulePendingToolCallDeltaFlush();
@@ -741,6 +742,10 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP
741742
onToolCall: (toolCall, round) => {
742743
sawToolCallInRound = true;
743744
discardPendingToolCallDelta(toolCall, round);
745+
// isRunning 只表示工具已出现在当前轮次,不代表提问已经进入权威
746+
// pending 表。提问卡延迟到 onToolExecutionStart,避免用户在
747+
// executeToolCall 建立 pending 前抢先提交。
748+
if (toolCall.name === ASK_USER_QUESTION_TOOL_NAME) return;
744749
if (!shouldShowToolEvent(toolCall)) return;
745750
gatewayBridgeEvents.queueEvent({
746751
type: "tool_call",

crates/agent-gui/test/chat/agent-runner.test.mjs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,106 @@ test("runAssistantWithTools calls onBeforeNextTurn only for toolUse turns with t
560560
);
561561
});
562562

563+
test("runAssistantWithTools announces execution start before invoking the tool executor", async () => {
564+
const askToolCall = createToolCall("call-ask-order", "AskUserQuestion", {
565+
questions: [
566+
{
567+
id: "choice",
568+
prompt: "Choose one",
569+
options: [{ label: "First" }, { label: "Second" }],
570+
},
571+
],
572+
});
573+
const askTool = {
574+
name: "AskUserQuestion",
575+
description: "Ask the user",
576+
parameters: { type: "object", properties: {} },
577+
};
578+
const sequence = [];
579+
resetFakeStreams(createToolUseAssistant(askToolCall), createTextAssistant("done"));
580+
const { params } = createBaseParams({
581+
context: {
582+
systemPrompt: "Base system prompt",
583+
messages: [{ role: "user", content: "Start", timestamp: 1 }],
584+
tools: [askTool],
585+
},
586+
tools: [askTool],
587+
onToolCall() {
588+
sequence.push("tool_call");
589+
},
590+
onToolExecutionStart() {
591+
sequence.push("execution_start");
592+
},
593+
async executeToolCall(toolCall) {
594+
sequence.push("execute");
595+
return createToolResult(toolCall);
596+
},
597+
});
598+
599+
await runAssistantWithTools(params);
600+
601+
const executionStartIndex = sequence.indexOf("execution_start");
602+
const executeIndex = sequence.indexOf("execute");
603+
assert.ok(sequence.includes("tool_call"));
604+
assert.ok(executionStartIndex >= 0);
605+
assert.ok(executeIndex > executionStartIndex);
606+
});
607+
608+
test("AskUserQuestion is pending before the next user-event task after execution start", async () => {
609+
const askTools = loader.loadModule("src/lib/tools/askUserQuestionTools.ts");
610+
const bundle = askTools.createAskUserQuestionTools({
611+
conversationId: "conversation-runner",
612+
timeoutMs: 200,
613+
});
614+
const askTool = bundle.tools.find((tool) => tool.name === "AskUserQuestion");
615+
assert.ok(askTool);
616+
const askToolCall = createToolCall("call-ask-next-task", "AskUserQuestion", {
617+
questions: [
618+
{
619+
id: "choice",
620+
prompt: "Choose one",
621+
options: [{ label: "First", recommended: true }, { label: "Second" }],
622+
},
623+
],
624+
});
625+
resetFakeStreams(createToolUseAssistant(askToolCall), createTextAssistant("done"));
626+
627+
let resolveAnswerAttempt;
628+
const answerAttempt = new Promise((resolve) => {
629+
resolveAnswerAttempt = resolve;
630+
});
631+
const { params } = createBaseParams({
632+
context: {
633+
systemPrompt: "Base system prompt",
634+
messages: [{ role: "user", content: "Start", timestamp: 1 }],
635+
tools: [askTool],
636+
},
637+
tools: [askTool],
638+
executeToolCall: bundle.executeToolCall,
639+
onToolExecutionStart() {
640+
// DOM clicks and Gateway deliveries cannot run inside this synchronous callback;
641+
// the earliest real user event is the next task, after the runner has entered
642+
// executeToolCall and synchronously populated pendingByToolCallId.
643+
setImmediate(() => {
644+
resolveAnswerAttempt(
645+
askTools.answerAskUserQuestion("call-ask-next-task", [
646+
{ questionId: "choice", selectedLabel: "Second" },
647+
]),
648+
);
649+
});
650+
},
651+
});
652+
653+
const result = await runAssistantWithTools(params);
654+
assert.deepEqual(await answerAttempt, { ok: true });
655+
const toolResult = result.emittedMessages.find(
656+
(message) => message.role === "toolResult" && message.toolCallId === askToolCall.id,
657+
);
658+
assert.ok(toolResult);
659+
assert.equal(toolResult.details.answers[0].selectedLabel, "Second");
660+
assert.equal("timedOut" in toolResult.details, false);
661+
});
662+
563663
// Mocked turn tests (agent-turn-cancelled-history.test.mjs) replay this payload
564664
// shape by hand; the assertions here are what keep those replicas honest.
565665
test("runAssistantWithTools reports 1-based monotonic rounds and paired tool results to onBeforeNextTurn", async () => {

0 commit comments

Comments
 (0)