Skip to content

Commit 88e7c5d

Browse files
committed
Merge remote-tracking branch 'upstream/main' into chore/github-governance-upstream
2 parents 50fa57c + 82b366f commit 88e7c5d

9 files changed

Lines changed: 1104 additions & 50 deletions

File tree

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

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,23 @@ function formatCountdown(remainingMs: number) {
2626
/**
2727
* 倒计时提示:优先使用调用方传入的权威截止时间(GUI 读工具挂起表,WebUI 读
2828
* 网关参数上的 deadline 盖章),两端与桌面计时同源;缺失时(历史/降级数据)
29-
* 回退为挂载时刻近似。超时后 tool_result 会把卡片切到只读态。
29+
* 回退为挂载时刻近似。倒计时归零立即禁止交互,随后 tool_result 把卡片
30+
* 切到只读态。
31+
*
32+
* 盖章用的是桌面时钟,而倒计时读本机时钟:远端浏览器时钟偏移足够大时,
33+
* 一个仍在挂起的提问会在挂载瞬间就显示过期(或远超完整窗口)。因此仅当
34+
* 截止时间落在“挂载时刻(不含)~挂载时刻 + 完整应答窗口(含)”内才采信,
35+
* 否则视为时钟不可比、回退挂载近似,避免把可作答的卡片锁死;真正过期的
36+
* 提交仍由桌面挂起表权威拒绝。
3037
*/
3138
function useAnswerCountdown(active: boolean, deadlineAt?: number) {
32-
const [fallbackDeadline] = useState(() => Date.now() + ASK_USER_QUESTION_TIMEOUT_MS);
33-
const deadline = deadlineAt ?? fallbackDeadline;
39+
const [mountedAt] = useState(() => Date.now());
40+
const deadline =
41+
deadlineAt !== undefined &&
42+
deadlineAt > mountedAt &&
43+
deadlineAt <= mountedAt + ASK_USER_QUESTION_TIMEOUT_MS
44+
? deadlineAt
45+
: mountedAt + ASK_USER_QUESTION_TIMEOUT_MS;
3446
const [remainingMs, setRemainingMs] = useState(() => deadline - Date.now());
3547

3648
useEffect(() => {
@@ -98,8 +110,10 @@ export function AskUserQuestionCard({
98110

99111
const isSettled = (answers?.length ?? 0) > 0;
100112
const selections = isSettled ? settledSelections : draftSelections;
101-
const canInteract = interactive && !isSettled && !cancelled && !submitting;
102-
const remainingMs = useAnswerCountdown(interactive && !isSettled && !cancelled, deadlineAt);
113+
const countdownActive = interactive && !isSettled && !cancelled;
114+
const remainingMs = useAnswerCountdown(countdownActive, deadlineAt);
115+
const countdownExpired = countdownActive && remainingMs <= 0;
116+
const canInteract = countdownActive && remainingMs > 0 && !submitting;
103117

104118
// 该题是否已作答:普通选项已选,或“其他”选中且文本非空。
105119
const isQuestionAnswered = (questionId: string) => {
@@ -267,7 +281,9 @@ export function AskUserQuestionCard({
267281
canInteract && !isSelected
268282
? "hover:border-border/70 hover:bg-foreground/[0.03] dark:hover:border-white/[0.14]"
269283
: "",
270-
!canInteract && !isSelected && (isSettled || cancelled) ? "opacity-55" : "",
284+
!canInteract && !isSelected && (isSettled || cancelled || countdownExpired)
285+
? "opacity-55"
286+
: "",
271287
canInteract ? "cursor-pointer" : "cursor-default",
272288
)}
273289
>
@@ -323,7 +339,9 @@ export function AskUserQuestionCard({
323339
canInteract && !activeCustomSelected
324340
? "hover:border-border/70 hover:bg-foreground/[0.03] dark:hover:border-white/[0.14]"
325341
: "",
326-
!canInteract && !activeCustomSelected && (isSettled || cancelled)
342+
!canInteract &&
343+
!activeCustomSelected &&
344+
(isSettled || cancelled || countdownExpired)
327345
? "opacity-55"
328346
: "",
329347
canInteract ? "cursor-pointer" : "cursor-default",
@@ -403,7 +421,7 @@ export function AskUserQuestionCard({
403421
</span>
404422
<button
405423
type="button"
406-
disabled={!allAnswered || submitting}
424+
disabled={!allAnswered || !canInteract}
407425
onClick={() => void submit()}
408426
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"
409427
>

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

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,23 @@ function formatCountdown(remainingMs: number) {
2626
/**
2727
* 倒计时提示:优先使用调用方传入的权威截止时间(GUI 读工具挂起表,WebUI 读
2828
* 网关参数上的 deadline 盖章),两端与桌面计时同源;缺失时(历史/降级数据)
29-
* 回退为挂载时刻近似。超时后 tool_result 会把卡片切到只读态。
29+
* 回退为挂载时刻近似。倒计时归零立即禁止交互,随后 tool_result 把卡片
30+
* 切到只读态。
31+
*
32+
* 盖章用的是桌面时钟,而倒计时读本机时钟:远端浏览器时钟偏移足够大时,
33+
* 一个仍在挂起的提问会在挂载瞬间就显示过期(或远超完整窗口)。因此仅当
34+
* 截止时间落在“挂载时刻(不含)~挂载时刻 + 完整应答窗口(含)”内才采信,
35+
* 否则视为时钟不可比、回退挂载近似,避免把可作答的卡片锁死;真正过期的
36+
* 提交仍由桌面挂起表权威拒绝。
3037
*/
3138
function useAnswerCountdown(active: boolean, deadlineAt?: number) {
32-
const [fallbackDeadline] = useState(() => Date.now() + ASK_USER_QUESTION_TIMEOUT_MS);
33-
const deadline = deadlineAt ?? fallbackDeadline;
39+
const [mountedAt] = useState(() => Date.now());
40+
const deadline =
41+
deadlineAt !== undefined &&
42+
deadlineAt > mountedAt &&
43+
deadlineAt <= mountedAt + ASK_USER_QUESTION_TIMEOUT_MS
44+
? deadlineAt
45+
: mountedAt + ASK_USER_QUESTION_TIMEOUT_MS;
3446
const [remainingMs, setRemainingMs] = useState(() => deadline - Date.now());
3547

3648
useEffect(() => {
@@ -98,8 +110,10 @@ export function AskUserQuestionCard({
98110

99111
const isSettled = (answers?.length ?? 0) > 0;
100112
const selections = isSettled ? settledSelections : draftSelections;
101-
const canInteract = interactive && !isSettled && !cancelled && !submitting;
102-
const remainingMs = useAnswerCountdown(interactive && !isSettled && !cancelled, deadlineAt);
113+
const countdownActive = interactive && !isSettled && !cancelled;
114+
const remainingMs = useAnswerCountdown(countdownActive, deadlineAt);
115+
const countdownExpired = countdownActive && remainingMs <= 0;
116+
const canInteract = countdownActive && remainingMs > 0 && !submitting;
103117

104118
// 该题是否已作答:普通选项已选,或“其他”选中且文本非空。
105119
const isQuestionAnswered = (questionId: string) => {
@@ -267,7 +281,9 @@ export function AskUserQuestionCard({
267281
canInteract && !isSelected
268282
? "hover:border-border/70 hover:bg-foreground/[0.03] dark:hover:border-white/[0.14]"
269283
: "",
270-
!canInteract && !isSelected && (isSettled || cancelled) ? "opacity-55" : "",
284+
!canInteract && !isSelected && (isSettled || cancelled || countdownExpired)
285+
? "opacity-55"
286+
: "",
271287
canInteract ? "cursor-pointer" : "cursor-default",
272288
)}
273289
>
@@ -323,7 +339,9 @@ export function AskUserQuestionCard({
323339
canInteract && !activeCustomSelected
324340
? "hover:border-border/70 hover:bg-foreground/[0.03] dark:hover:border-white/[0.14]"
325341
: "",
326-
!canInteract && !activeCustomSelected && (isSettled || cancelled)
342+
!canInteract &&
343+
!activeCustomSelected &&
344+
(isSettled || cancelled || countdownExpired)
327345
? "opacity-55"
328346
: "",
329347
canInteract ? "cursor-pointer" : "cursor-default",
@@ -403,7 +421,7 @@ export function AskUserQuestionCard({
403421
</span>
404422
<button
405423
type="button"
406-
disabled={!allAnswered || submitting}
424+
disabled={!allAnswered || !canInteract}
407425
onClick={() => void submit()}
408426
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"
409427
>

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

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

699699
function queueToolCallDelta(toolCall: ToolCall, round: number) {
700700
if (!shouldShowToolEvent(toolCall)) return;
701-
// 提问卡必须等问题与选项全部生成完毕再显示:跳过流式增量,双端
702-
// (GUI 回合与网关 tool_call_delta)都只在 onToolCall 拿到完整参数后出现。
701+
// 提问卡必须等问题与选项全部生成完毕且工具真正开始执行后再显示:
702+
// 流式增量与 onToolCall 都只做内部记账,双端统一由
703+
// onToolExecutionStart 发布可交互卡片。
703704
if (toolCall.name === ASK_USER_QUESTION_TOOL_NAME) return;
704705
pendingToolCallDeltas.set(toolCallDeltaKey(round, toolCall.id), { round, toolCall });
705706
schedulePendingToolCallDeltaFlush();
@@ -820,6 +821,10 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP
820821
onToolCall: (toolCall, round) => {
821822
sawToolCallInRound = true;
822823
discardPendingToolCallDelta(toolCall, round);
824+
// isRunning 只表示工具已出现在当前轮次,不代表提问已经进入权威
825+
// pending 表。提问卡延迟到 onToolExecutionStart,避免用户在
826+
// executeToolCall 建立 pending 前抢先提交。
827+
if (toolCall.name === ASK_USER_QUESTION_TOOL_NAME) return;
823828
if (!shouldShowToolEvent(toolCall)) return;
824829
gatewayBridgeEvents.queueEvent({
825830
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)