From 3d553072b96311438c631657104d798151ed3325 Mon Sep 17 00:00:00 2001 From: FlowerRealm Date: Sun, 2 Aug 2026 01:15:33 -0700 Subject: [PATCH 1/7] feat(chat): configurable interactive answer timeout Make the answer window shared by the AskUserQuestion card and the tool approval bar configurable via settings.system.interactiveTimeoutMinutes (minutes, default 3, preserving current behavior). A very large value (e.g. 99999) approximates never timing out. The window is injected into both tools at runtime; every deadline stays a finite timestamp, so the countdown logic needs no special never-handling. Refs #354 (timeout configurability). --- .../components/chat/AskUserQuestionCard.tsx | 18 +++---- crates/agent-gateway/web/src/i18n/config.ts | 11 +++++ .../web/src/lib/chat/askUserQuestion.ts | 4 +- .../web/src/lib/settings/index.ts | 16 ++++++ .../src/pages/settings/SystemToolsSection.tsx | 49 ++++++++++++++++++- crates/agent-gui/src/App.tsx | 12 +++++ .../components/chat/AskUserQuestionCard.tsx | 18 +++---- crates/agent-gui/src/i18n/config.ts | 11 +++++ .../agent-gui/src/lib/chat/askUserQuestion.ts | 4 +- crates/agent-gui/src/lib/settings/index.ts | 16 ++++++ .../src/lib/tools/askUserQuestionTools.ts | 30 ++++++++---- .../agent-gui/src/lib/tools/toolApproval.ts | 11 ++++- .../src/pages/settings/SystemToolsSection.tsx | 49 ++++++++++++++++++- .../test/chat/ask-user-question-card.test.mjs | 7 ++- .../test/settings/normalization.test.mjs | 15 ++++++ .../tools/ask-user-question-tools.test.mjs | 27 ++++++++++ .../test/tools/tool-approval.test.mjs | 19 +++++++ docs/features/chat-runtime.md | 2 +- 18 files changed, 278 insertions(+), 41 deletions(-) diff --git a/crates/agent-gateway/web/src/components/chat/AskUserQuestionCard.tsx b/crates/agent-gateway/web/src/components/chat/AskUserQuestionCard.tsx index d7ebc2ef4..f21f99264 100644 --- a/crates/agent-gateway/web/src/components/chat/AskUserQuestionCard.tsx +++ b/crates/agent-gateway/web/src/components/chat/AskUserQuestionCard.tsx @@ -25,22 +25,18 @@ function formatCountdown(remainingMs: number) { /** * 倒计时提示:优先使用调用方传入的权威截止时间(GUI 读工具挂起表,WebUI 读 - * 网关参数上的 deadline 盖章),两端与桌面计时同源;缺失时(历史/降级数据) - * 回退为挂载时刻近似。倒计时归零立即禁止交互,随后 tool_result 把卡片 - * 切到只读态。 + * 网关参数上的 deadline 盖章),两端与桌面计时同源。超长窗口(≈永不超时)时 + * 显示很长倒计时;截止时间缺失/已过期时回退为挂载时刻的默认窗口近似,避免把 + * 可作答的卡片锁死。倒计时归零立即禁止交互,随后 tool_result 把卡片切到只读态。 * - * 盖章用的是桌面时钟,而倒计时读本机时钟:远端浏览器时钟偏移足够大时, - * 一个仍在挂起的提问会在挂载瞬间就显示过期(或远超完整窗口)。因此仅当 - * 截止时间落在“挂载时刻(不含)~挂载时刻 + 完整应答窗口(含)”内才采信, - * 否则视为时钟不可比、回退挂载近似,避免把可作答的卡片锁死;真正过期的 - * 提交仍由桌面挂起表权威拒绝。 + * 盖章用的是桌面时钟,而倒计时读本机时钟:远端浏览器时钟偏移足够大时,一个 + * 仍在挂起的提问会在挂载瞬间显示过期。仅采信“挂载时刻之后”的截止时间;真正 + * 过期的提交仍由桌面挂起表权威拒绝。 */ function useAnswerCountdown(active: boolean, deadlineAt?: number) { const [mountedAt] = useState(() => Date.now()); const deadline = - deadlineAt !== undefined && - deadlineAt > mountedAt && - deadlineAt <= mountedAt + ASK_USER_QUESTION_TIMEOUT_MS + deadlineAt !== undefined && deadlineAt > mountedAt ? deadlineAt : mountedAt + ASK_USER_QUESTION_TIMEOUT_MS; const [remainingMs, setRemainingMs] = useState(() => deadline - Date.now()); diff --git a/crates/agent-gateway/web/src/i18n/config.ts b/crates/agent-gateway/web/src/i18n/config.ts index 665ded047..5b624eda5 100644 --- a/crates/agent-gateway/web/src/i18n/config.ts +++ b/crates/agent-gateway/web/src/i18n/config.ts @@ -1244,6 +1244,11 @@ export const translations: Record> = { "settings.systemTools": "系统工具", "settings.systemToolsDesc": "查看 LiveAgent 在 Agent 模式下自动注册的内置工具,并为每个工具设置审批策略(放行 / 执行前询问 / 拒绝)。", + "settings.interactiveTimeout.title": "交互式应答超时", + "settings.interactiveTimeout.desc": "AskUserQuestion 提问卡与工具审批栏的等待窗口,二者共用。", + "settings.interactiveTimeout.unit": "分钟", + "settings.interactiveTimeout.hint": + "正数分钟 = 超时窗口;填很大的数(如 99999)≈ 永不超时。超时后按各交互既定姿态落定(提问自动选推荐项并继续、审批按拒绝)。", "settings.builtinToolCategory.fs": "文件系统", "settings.builtinToolCategory.process": "终端与进程", "settings.builtinToolCategory.intelligence": "智能与记忆", @@ -3466,6 +3471,12 @@ export const translations: Record> = { "settings.systemTools": "System Tools", "settings.systemToolsDesc": "View the built-in tools that LiveAgent registers automatically in Agent mode, and set an approval policy per tool (allow / ask before running / deny).", + "settings.interactiveTimeout.title": "Interactive answer timeout", + "settings.interactiveTimeout.desc": + "Wait window shared by the AskUserQuestion card and the tool approval bar.", + "settings.interactiveTimeout.unit": "min", + "settings.interactiveTimeout.hint": + "Positive minutes = answer window; use a very large number (e.g. 99999) for effectively no timeout. On timeout, each prompt settles its own way (questions auto-pick the recommended option and continue; approvals are denied).", "settings.builtinToolCategory.fs": "File System", "settings.builtinToolCategory.process": "Terminal & Processes", "settings.builtinToolCategory.intelligence": "Intelligence & Memory", diff --git a/crates/agent-gateway/web/src/lib/chat/askUserQuestion.ts b/crates/agent-gateway/web/src/lib/chat/askUserQuestion.ts index 38fdf437e..a5748cfa0 100644 --- a/crates/agent-gateway/web/src/lib/chat/askUserQuestion.ts +++ b/crates/agent-gateway/web/src/lib/chat/askUserQuestion.ts @@ -7,7 +7,9 @@ export const ASK_USER_QUESTION_TOOL_NAME = "AskUserQuestion"; export const ASK_USER_QUESTION_MAX_QUESTIONS = 4; export const ASK_USER_QUESTION_MIN_OPTIONS = 2; export const ASK_USER_QUESTION_MAX_OPTIONS = 6; -/** 每轮提问的应答窗口:超时后按推荐项(缺省第一项)自动落定继续执行。 */ +/** 每轮提问的应答窗口默认值(毫秒):超时后按推荐项(缺省第一项)自动落定继续执行。 + * 运行时实际窗口由设置 system.interactiveTimeoutMinutes 驱动(正数分钟;超长≈永不超时), + * 此常量仅作未注入时的兜底默认,保持历史行为与测试注入口径一致。 */ export const ASK_USER_QUESTION_TIMEOUT_MS = 3 * 60 * 1000; /** UI 合成"其他(自行输入)"应答的最大长度;超出部分截断。 */ export const ASK_USER_QUESTION_CUSTOM_MAX_LENGTH = 2000; diff --git a/crates/agent-gateway/web/src/lib/settings/index.ts b/crates/agent-gateway/web/src/lib/settings/index.ts index 7f3fbeafd..d2f3bc4a6 100644 --- a/crates/agent-gateway/web/src/lib/settings/index.ts +++ b/crates/agent-gateway/web/src/lib/settings/index.ts @@ -180,6 +180,11 @@ export type SystemProxyConfig = { /** 工具审批策略:allow 直接执行、ask 执行前请求用户批准、deny 直接拒绝。 */ export type ToolPolicy = "allow" | "ask" | "deny"; +/** 交互式应答(AskUserQuestion 提问卡 + 工具审批栏)的等待窗口,单位分钟。 + * 正数 = 超时窗口,超时后按各交互既定姿态落定(提问自动选推荐项并继续、 + * 审批按拒绝)。两处交互共用同一窗口。填很大的数(如 99999)≈ 永不超时。 */ +export const INTERACTIVE_TIMEOUT_DEFAULT_MINUTES = 3; + export type SystemSettings = { executionMode: ExecutionMode; workdir: string; @@ -188,6 +193,8 @@ export type SystemSettings = { * 回写会丢掉桌面端设置的策略。策略的裁决在桌面端 resolveToolPolicy。 */ toolPolicies?: Record; + /** 交互式应答超时(分钟):正数=窗口,超长≈永不。与桌面端对齐原样透传。 */ + interactiveTimeoutMinutes: number; workspaceProjects: WorkspaceProject[]; activeWorkspaceProjectId?: string; hiddenWorkspaceProjectPaths: string[]; @@ -1678,12 +1685,20 @@ export function normalizeSystemProxyConfig(input: unknown): SystemProxyConfig { }; } +/** 归一化交互式应答超时(分钟):正数=窗口(超长≈永不),缺省/非法/非正回 3。 */ +export function normalizeInteractiveTimeoutMinutes(input: unknown): number { + return typeof input === "number" && Number.isFinite(input) && input > 0 + ? input + : INTERACTIVE_TIMEOUT_DEFAULT_MINUTES; +} + export function normalizeSystemSettings(input: unknown): SystemSettings { const obj = (input && typeof input === "object" ? input : {}) as Record; return { executionMode: normalizeExecutionMode(obj.executionMode), workdir: normalizeWorkdir(obj.workdir), toolPolicies: normalizeToolPolicies(obj.toolPolicies), + interactiveTimeoutMinutes: normalizeInteractiveTimeoutMinutes(obj.interactiveTimeoutMinutes), workspaceProjects: normalizeWorkspaceProjects(obj.workspaceProjects), activeWorkspaceProjectId: typeof obj.activeWorkspaceProjectId === "string" && obj.activeWorkspaceProjectId.trim() @@ -2235,6 +2250,7 @@ export function getDefaultSettings(): AppSettings { missingWorkspaceProjectPaths: [], archivedWorkspaceProjectPaths: [], systemProxy: getDefaultSystemProxyConfig(), + interactiveTimeoutMinutes: INTERACTIVE_TIMEOUT_DEFAULT_MINUTES, }, customProviders, mcp: { diff --git a/crates/agent-gateway/web/src/pages/settings/SystemToolsSection.tsx b/crates/agent-gateway/web/src/pages/settings/SystemToolsSection.tsx index c81f90cdd..8599c29dd 100644 --- a/crates/agent-gateway/web/src/pages/settings/SystemToolsSection.tsx +++ b/crates/agent-gateway/web/src/pages/settings/SystemToolsSection.tsx @@ -5,9 +5,10 @@ // // 说明:MCP 工具按 server、插件工具按工具的策略已就地内联到各自 Hub 卡片旁 //(需运行时数据),不在本节;本节聚焦内置工具,补上内置工具此前不可管控的缺口。 -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { ToolPolicyToggle } from "../../components/hub/ToolPolicyToggle"; import { Wrench } from "../../components/icons"; +import { Input } from "../../components/ui/input"; import { useLocale } from "../../i18n"; import { type ToolPolicy, updateSystem } from "../../lib/settings"; import { BUILTIN_TOOL_CATALOG, BUILTIN_TOOL_CATEGORIES } from "../../lib/tools/builtinToolCatalog"; @@ -51,6 +52,22 @@ export function SystemToolsSection(props: SettingsSectionProps) { const overriddenCount = Object.keys(policies).length; + // 交互式应答超时(分钟):正数=超时窗口,超长≈永不超时。草稿 + blur 提交,避免 + // 逐字符触发设置同步;空/非法回退不提交,非正由归一化回默认 3。 + const interactiveTimeoutMinutes = settings.system.interactiveTimeoutMinutes; + const [timeoutDraft, setTimeoutDraft] = useState(null); + const timeoutInputValue = timeoutDraft ?? String(interactiveTimeoutMinutes); + const commitTimeoutDraft = () => { + if (timeoutDraft === null) return; + const parsed = Number.parseInt(timeoutDraft, 10); + if (!Number.isFinite(parsed)) { + setTimeoutDraft(null); + return; + } + setSettings((prev) => updateSystem(prev, { interactiveTimeoutMinutes: parsed })); + setTimeoutDraft(null); + }; + return (
@@ -70,6 +87,36 @@ export function SystemToolsSection(props: SettingsSectionProps) { ) : null}
+
+
+
+
{t("settings.interactiveTimeout.title")}
+

+ {t("settings.interactiveTimeout.desc")} +

+
+
+ setTimeoutDraft(event.currentTarget.value)} + onBlur={commitTimeoutDraft} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + }} + className="w-20" + /> + + {t("settings.interactiveTimeout.unit")} + +
+
+

+ {t("settings.interactiveTimeout.hint")} +

+
+
{groups.map(({ category, entries }) => (
diff --git a/crates/agent-gui/src/App.tsx b/crates/agent-gui/src/App.tsx index 36f31e4b8..25f295d43 100644 --- a/crates/agent-gui/src/App.tsx +++ b/crates/agent-gui/src/App.tsx @@ -36,6 +36,8 @@ import { } from "./lib/settings/sync"; import { applyStoredGlobalShortcuts } from "./lib/shortcuts/globalShortcuts"; import { applyFontFamilies } from "./lib/system/fontFamily"; +import { setAskUserQuestionTimeoutMs } from "./lib/tools/askUserQuestionTools"; +import { setToolApprovalTimeoutMs } from "./lib/tools/toolApproval"; import { ChatPage } from "./pages/ChatPage"; import { SettingsPage } from "./pages/SettingsPage"; import type { SectionId } from "./pages/settings/types"; @@ -380,6 +382,16 @@ export default function App() { const getMcpSettings = useCallback(() => settingsRef.current.mcp, []); const getToolPolicies = useCallback(() => settingsRef.current.system.toolPolicies, []); + // 把交互式应答超时设置(分钟)注入工具运行时窗口(毫秒):AskUserQuestion 与 + // 工具审批共用同一窗口;永不超时用很大的分钟数表达。设置变更后新挂起的提问/ + // 审批生效,已挂起的沿用旧窗口。模块级配置由工具侧 ensureAskUserQuestionDeadlineAt / + // requestToolApproval 读取,避免在 6+ 处工具预览调用点逐个传参。 + useEffect(() => { + const ms = settings.system.interactiveTimeoutMinutes * 60_000; + setAskUserQuestionTimeoutMs(ms); + setToolApprovalTimeoutMs(ms); + }, [settings.system.interactiveTimeoutMinutes]); + const reloadPersistedSettings = useCallback(async () => { await saveChainRef.current.catch(() => undefined); const { settings: loaded, defaultWorkdir } = await loadPersistedSettingsWithDefaults(); diff --git a/crates/agent-gui/src/components/chat/AskUserQuestionCard.tsx b/crates/agent-gui/src/components/chat/AskUserQuestionCard.tsx index d7ebc2ef4..f21f99264 100644 --- a/crates/agent-gui/src/components/chat/AskUserQuestionCard.tsx +++ b/crates/agent-gui/src/components/chat/AskUserQuestionCard.tsx @@ -25,22 +25,18 @@ function formatCountdown(remainingMs: number) { /** * 倒计时提示:优先使用调用方传入的权威截止时间(GUI 读工具挂起表,WebUI 读 - * 网关参数上的 deadline 盖章),两端与桌面计时同源;缺失时(历史/降级数据) - * 回退为挂载时刻近似。倒计时归零立即禁止交互,随后 tool_result 把卡片 - * 切到只读态。 + * 网关参数上的 deadline 盖章),两端与桌面计时同源。超长窗口(≈永不超时)时 + * 显示很长倒计时;截止时间缺失/已过期时回退为挂载时刻的默认窗口近似,避免把 + * 可作答的卡片锁死。倒计时归零立即禁止交互,随后 tool_result 把卡片切到只读态。 * - * 盖章用的是桌面时钟,而倒计时读本机时钟:远端浏览器时钟偏移足够大时, - * 一个仍在挂起的提问会在挂载瞬间就显示过期(或远超完整窗口)。因此仅当 - * 截止时间落在“挂载时刻(不含)~挂载时刻 + 完整应答窗口(含)”内才采信, - * 否则视为时钟不可比、回退挂载近似,避免把可作答的卡片锁死;真正过期的 - * 提交仍由桌面挂起表权威拒绝。 + * 盖章用的是桌面时钟,而倒计时读本机时钟:远端浏览器时钟偏移足够大时,一个 + * 仍在挂起的提问会在挂载瞬间显示过期。仅采信“挂载时刻之后”的截止时间;真正 + * 过期的提交仍由桌面挂起表权威拒绝。 */ function useAnswerCountdown(active: boolean, deadlineAt?: number) { const [mountedAt] = useState(() => Date.now()); const deadline = - deadlineAt !== undefined && - deadlineAt > mountedAt && - deadlineAt <= mountedAt + ASK_USER_QUESTION_TIMEOUT_MS + deadlineAt !== undefined && deadlineAt > mountedAt ? deadlineAt : mountedAt + ASK_USER_QUESTION_TIMEOUT_MS; const [remainingMs, setRemainingMs] = useState(() => deadline - Date.now()); diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 1d5a9014a..3ec3609bc 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -1314,6 +1314,11 @@ export const translations: Record> = { "settings.systemTools": "系统工具", "settings.systemToolsDesc": "查看 LiveAgent 在 Agent 模式下自动注册的内置工具,并为每个工具设置审批策略(放行 / 执行前询问 / 拒绝)。", + "settings.interactiveTimeout.title": "交互式应答超时", + "settings.interactiveTimeout.desc": "AskUserQuestion 提问卡与工具审批栏的等待窗口,二者共用。", + "settings.interactiveTimeout.unit": "分钟", + "settings.interactiveTimeout.hint": + "正数分钟 = 超时窗口;填很大的数(如 99999)≈ 永不超时。超时后按各交互既定姿态落定(提问自动选推荐项并继续、审批按拒绝)。", "settings.builtinToolCategory.fs": "文件系统", "settings.builtinToolCategory.process": "终端与进程", "settings.builtinToolCategory.intelligence": "智能与记忆", @@ -3629,6 +3634,12 @@ export const translations: Record> = { "settings.systemTools": "System Tools", "settings.systemToolsDesc": "View the built-in tools that LiveAgent registers automatically in Agent mode, and set an approval policy per tool (allow / ask before running / deny).", + "settings.interactiveTimeout.title": "Interactive answer timeout", + "settings.interactiveTimeout.desc": + "Wait window shared by the AskUserQuestion card and the tool approval bar.", + "settings.interactiveTimeout.unit": "min", + "settings.interactiveTimeout.hint": + "Positive minutes = answer window; use a very large number (e.g. 99999) for effectively no timeout. On timeout, each prompt settles its own way (questions auto-pick the recommended option and continue; approvals are denied).", "settings.builtinToolCategory.fs": "File System", "settings.builtinToolCategory.process": "Terminal & Processes", "settings.builtinToolCategory.intelligence": "Intelligence & Memory", diff --git a/crates/agent-gui/src/lib/chat/askUserQuestion.ts b/crates/agent-gui/src/lib/chat/askUserQuestion.ts index 38fdf437e..a5748cfa0 100644 --- a/crates/agent-gui/src/lib/chat/askUserQuestion.ts +++ b/crates/agent-gui/src/lib/chat/askUserQuestion.ts @@ -7,7 +7,9 @@ export const ASK_USER_QUESTION_TOOL_NAME = "AskUserQuestion"; export const ASK_USER_QUESTION_MAX_QUESTIONS = 4; export const ASK_USER_QUESTION_MIN_OPTIONS = 2; export const ASK_USER_QUESTION_MAX_OPTIONS = 6; -/** 每轮提问的应答窗口:超时后按推荐项(缺省第一项)自动落定继续执行。 */ +/** 每轮提问的应答窗口默认值(毫秒):超时后按推荐项(缺省第一项)自动落定继续执行。 + * 运行时实际窗口由设置 system.interactiveTimeoutMinutes 驱动(正数分钟;超长≈永不超时), + * 此常量仅作未注入时的兜底默认,保持历史行为与测试注入口径一致。 */ export const ASK_USER_QUESTION_TIMEOUT_MS = 3 * 60 * 1000; /** UI 合成"其他(自行输入)"应答的最大长度;超出部分截断。 */ export const ASK_USER_QUESTION_CUSTOM_MAX_LENGTH = 2000; diff --git a/crates/agent-gui/src/lib/settings/index.ts b/crates/agent-gui/src/lib/settings/index.ts index d924432fc..e94d7857e 100644 --- a/crates/agent-gui/src/lib/settings/index.ts +++ b/crates/agent-gui/src/lib/settings/index.ts @@ -198,6 +198,11 @@ export type SystemProxyConfig = { /** 工具审批策略:allow 直接执行、ask 执行前请求用户批准、deny 直接拒绝。 */ export type ToolPolicy = "allow" | "ask" | "deny"; +/** 交互式应答(AskUserQuestion 提问卡 + 工具审批栏)的等待窗口,单位分钟。 + * 正数 = 超时窗口,超时后按各交互既定姿态落定(提问自动选推荐项并继续、 + * 审批按拒绝)。两处交互共用同一窗口。填很大的数(如 99999)≈ 永不超时。 */ +export const INTERACTIVE_TIMEOUT_DEFAULT_MINUTES = 3; + export type SystemSettings = { executionMode: ExecutionMode; workdir: string; @@ -207,6 +212,8 @@ export type SystemSettings = { * 可选:旧快照缺失该字段时视为空表(全部走默认),保证零回归。 */ toolPolicies?: Record; + /** 交互式应答超时(分钟):正数=窗口,超长≈永不。缺省 3,保持历史行为。 */ + interactiveTimeoutMinutes: number; workspaceProjects: WorkspaceProject[]; activeWorkspaceProjectId?: string; hiddenWorkspaceProjectPaths: string[]; @@ -1642,12 +1649,20 @@ export function normalizeSystemProxyConfig(input: unknown): SystemProxyConfig { }; } +/** 归一化交互式应答超时(分钟):正数=窗口(超长≈永不),缺省/非法/非正回 3。 */ +export function normalizeInteractiveTimeoutMinutes(input: unknown): number { + return typeof input === "number" && Number.isFinite(input) && input > 0 + ? input + : INTERACTIVE_TIMEOUT_DEFAULT_MINUTES; +} + export function normalizeSystemSettings(input: unknown): SystemSettings { const obj = (input && typeof input === "object" ? input : {}) as Record; return { executionMode: normalizeExecutionMode(obj.executionMode), workdir: normalizeWorkdir(obj.workdir), toolPolicies: normalizeToolPolicies(obj.toolPolicies), + interactiveTimeoutMinutes: normalizeInteractiveTimeoutMinutes(obj.interactiveTimeoutMinutes), workspaceProjects: normalizeWorkspaceProjects(obj.workspaceProjects), activeWorkspaceProjectId: typeof obj.activeWorkspaceProjectId === "string" && obj.activeWorkspaceProjectId.trim() @@ -2209,6 +2224,7 @@ export function getDefaultSettings(): AppSettings { missingWorkspaceProjectPaths: [], archivedWorkspaceProjectPaths: [], systemProxy: getDefaultSystemProxyConfig(), + interactiveTimeoutMinutes: INTERACTIVE_TIMEOUT_DEFAULT_MINUTES, }, customProviders, mcp: { diff --git a/crates/agent-gui/src/lib/tools/askUserQuestionTools.ts b/crates/agent-gui/src/lib/tools/askUserQuestionTools.ts index f365203b7..a47cfbbfa 100644 --- a/crates/agent-gui/src/lib/tools/askUserQuestionTools.ts +++ b/crates/agent-gui/src/lib/tools/askUserQuestionTools.ts @@ -46,6 +46,15 @@ function sweepStalePresetDeadlines(now: number) { } } +// 运行时应答窗口(毫秒):由设置 system.interactiveTimeoutMinutes 经 App.tsx 注入 +// (正数分钟换算而来;永不超时用很大的值表达)。未注入时回退默认常量。 +let configuredAskUserQuestionTimeoutMs = ASK_USER_QUESTION_TIMEOUT_MS; + +/** 由设置层注入运行时应答窗口(毫秒)。 */ +export function setAskUserQuestionTimeoutMs(timeoutMs: number): void { + configuredAskUserQuestionTimeoutMs = timeoutMs; +} + /** 网关侧上报工具参数时取(必要时预置)应答截止时间;挂起后与工具内计时同源。 */ export function ensureAskUserQuestionDeadlineAt(toolCallId: string): number { const trimmed = toolCallId.trim(); @@ -55,7 +64,7 @@ export function ensureAskUserQuestionDeadlineAt(toolCallId: string): number { sweepStalePresetDeadlines(now); const preset = presetDeadlineByToolCallId.get(trimmed); if (preset !== undefined) return preset; - const deadlineAt = now + ASK_USER_QUESTION_TIMEOUT_MS; + const deadlineAt = now + configuredAskUserQuestionTimeoutMs; presetDeadlineByToolCallId.set(trimmed, deadlineAt); return deadlineAt; } @@ -112,11 +121,13 @@ export function cancelPendingAskUserQuestionsForConversation(conversationId: str } } -const ASK_USER_QUESTION_TIMEOUT_MINUTES = Math.round(ASK_USER_QUESTION_TIMEOUT_MS / 60_000); +/** 工具描述按运行时窗口动态生成:写明具体分钟数与自动选推荐项的兜底语义。 + * 填很大的分钟数(≈永不超时)时,数字本身表明等待极久。 */ +function buildAskUserQuestionToolDescription(timeoutMs: number): string { + const minutes = Math.max(1, Math.round(timeoutMs / 60_000)); + return `Ask the user up to ${ASK_USER_QUESTION_MAX_QUESTIONS} multiple-choice questions and wait for their selections. Use this whenever you need a decision only the user can make: ambiguous requirements, mutually exclusive approaches, or trade-offs you cannot resolve from the conversation and the workspace. -const ASK_USER_QUESTION_TOOL_DESCRIPTION = `Ask the user up to ${ASK_USER_QUESTION_MAX_QUESTIONS} multiple-choice questions and wait for their selections. Use this whenever you need a decision only the user can make: ambiguous requirements, mutually exclusive approaches, or trade-offs you cannot resolve from the conversation and the workspace. - -The questions render as an interactive card; execution pauses until the user answers every question, then the selections come back as the tool result. If the user does not answer within ${ASK_USER_QUESTION_TIMEOUT_MINUTES} minutes, the recommended (or first) option of every question is auto-selected and execution continues — the result text tells you which happened. +The questions render as an interactive card; execution pauses until the user answers every question, then the selections come back as the tool result. If the user does not answer within ${minutes} minutes, the recommended (or first) option of every question is auto-selected and execution continues — the result text tells you which happened. Rules: - Ask 1-${ASK_USER_QUESTION_MAX_QUESTIONS} focused questions per call; each question needs ${ASK_USER_QUESTION_MIN_OPTIONS}-${ASK_USER_QUESTION_MAX_OPTIONS} options (3-4 is ideal), and every question in one call must have the SAME number of options. @@ -124,6 +135,7 @@ Rules: - The UI automatically appends an "Other" free-text option to every question, so the user can always type their own answer. Do NOT add your own catch-all option (e.g. "Other", "Custom", "其他", "自定义"). When the user types an answer, the result marks it as user-typed and returns their exact words instead of a listed label — treat it as authoritative. - Give each question a short header (2-6 chars works best) — it becomes the tab label when several questions show at once. - Do not use this for questions answerable from the code or the conversation, and never ask for confirmation of work you can safely do.`; +} const askUserQuestionParameters = Type.Object({ questions: Type.Array( @@ -171,13 +183,13 @@ function buildErrorResult(toolCall: ToolCall, text: string): ToolResultMessage { export function createAskUserQuestionTools(params: { conversationId: string; - /** 应答窗口毫秒数;仅测试注入,生产始终用默认值。 */ + /** 应答窗口毫秒数;仅测试注入,生产读运行时配置(setAskUserQuestionTimeoutMs)。 */ timeoutMs?: number; }): BuiltinToolBundle { - const timeoutMs = params.timeoutMs ?? ASK_USER_QUESTION_TIMEOUT_MS; + const timeoutMs = params.timeoutMs ?? configuredAskUserQuestionTimeoutMs; const toolAskUserQuestion: Tool = { name: ASK_USER_QUESTION_TOOL_NAME, - description: ASK_USER_QUESTION_TOOL_DESCRIPTION, + description: buildAskUserQuestionToolDescription(timeoutMs), parameters: askUserQuestionParameters, }; @@ -209,7 +221,7 @@ export function createAskUserQuestionTools(params: { // 测试注入 timeoutMs 时忽略预置,始终以注入值为准。 const presetDeadlineAt = presetDeadlineByToolCallId.get(toolCall.id); presetDeadlineByToolCallId.delete(toolCall.id); - const deadlineAt = + const deadlineAt: number = params.timeoutMs !== undefined || presetDeadlineAt === undefined ? Date.now() + timeoutMs : presetDeadlineAt; diff --git a/crates/agent-gui/src/lib/tools/toolApproval.ts b/crates/agent-gui/src/lib/tools/toolApproval.ts index 28bcf414a..78ae076d6 100644 --- a/crates/agent-gui/src/lib/tools/toolApproval.ts +++ b/crates/agent-gui/src/lib/tools/toolApproval.ts @@ -12,6 +12,15 @@ import { ASK_USER_QUESTION_TIMEOUT_MS } from "../chat/askUserQuestion"; /** 审批窗口毫秒数:复用 AskUserQuestion 的时长常量,行为口径一致。 */ export const TOOL_APPROVAL_TIMEOUT_MS = ASK_USER_QUESTION_TIMEOUT_MS; +// 运行时审批窗口(毫秒):由设置 system.interactiveTimeoutMinutes 经 App.tsx 注入 +// (与 AskUserQuestion 同一设置、同一窗口;永不超时用很大的值表达)。未注入回退默认常量。 +let configuredToolApprovalTimeoutMs = TOOL_APPROVAL_TIMEOUT_MS; + +/** 由设置层注入运行时审批窗口(毫秒)。 */ +export function setToolApprovalTimeoutMs(timeoutMs: number): void { + configuredToolApprovalTimeoutMs = timeoutMs; +} + /** approve:本次放行;deny:本次拒绝;approve_session:本会话内该工具后续免审。 */ export type ToolApprovalDecision = "approve" | "deny" | "approve_session"; @@ -155,7 +164,7 @@ export function requestToolApproval(params: { timeoutMs?: number; }): Promise { const toolCallId = params.toolCallId.trim(); - const timeoutMs = params.timeoutMs ?? TOOL_APPROVAL_TIMEOUT_MS; + const timeoutMs = params.timeoutMs ?? configuredToolApprovalTimeoutMs; const deadlineAt = Date.now() + timeoutMs; if (params.signal?.aborted) { diff --git a/crates/agent-gui/src/pages/settings/SystemToolsSection.tsx b/crates/agent-gui/src/pages/settings/SystemToolsSection.tsx index c81f90cdd..8599c29dd 100644 --- a/crates/agent-gui/src/pages/settings/SystemToolsSection.tsx +++ b/crates/agent-gui/src/pages/settings/SystemToolsSection.tsx @@ -5,9 +5,10 @@ // // 说明:MCP 工具按 server、插件工具按工具的策略已就地内联到各自 Hub 卡片旁 //(需运行时数据),不在本节;本节聚焦内置工具,补上内置工具此前不可管控的缺口。 -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { ToolPolicyToggle } from "../../components/hub/ToolPolicyToggle"; import { Wrench } from "../../components/icons"; +import { Input } from "../../components/ui/input"; import { useLocale } from "../../i18n"; import { type ToolPolicy, updateSystem } from "../../lib/settings"; import { BUILTIN_TOOL_CATALOG, BUILTIN_TOOL_CATEGORIES } from "../../lib/tools/builtinToolCatalog"; @@ -51,6 +52,22 @@ export function SystemToolsSection(props: SettingsSectionProps) { const overriddenCount = Object.keys(policies).length; + // 交互式应答超时(分钟):正数=超时窗口,超长≈永不超时。草稿 + blur 提交,避免 + // 逐字符触发设置同步;空/非法回退不提交,非正由归一化回默认 3。 + const interactiveTimeoutMinutes = settings.system.interactiveTimeoutMinutes; + const [timeoutDraft, setTimeoutDraft] = useState(null); + const timeoutInputValue = timeoutDraft ?? String(interactiveTimeoutMinutes); + const commitTimeoutDraft = () => { + if (timeoutDraft === null) return; + const parsed = Number.parseInt(timeoutDraft, 10); + if (!Number.isFinite(parsed)) { + setTimeoutDraft(null); + return; + } + setSettings((prev) => updateSystem(prev, { interactiveTimeoutMinutes: parsed })); + setTimeoutDraft(null); + }; + return (
@@ -70,6 +87,36 @@ export function SystemToolsSection(props: SettingsSectionProps) { ) : null}
+
+
+
+
{t("settings.interactiveTimeout.title")}
+

+ {t("settings.interactiveTimeout.desc")} +

+
+
+ setTimeoutDraft(event.currentTarget.value)} + onBlur={commitTimeoutDraft} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + }} + className="w-20" + /> + + {t("settings.interactiveTimeout.unit")} + +
+
+

+ {t("settings.interactiveTimeout.hint")} +

+
+
{groups.map(({ category, entries }) => (
diff --git a/crates/agent-gui/test/chat/ask-user-question-card.test.mjs b/crates/agent-gui/test/chat/ask-user-question-card.test.mjs index 86056f407..c5f524567 100644 --- a/crates/agent-gui/test/chat/ask-user-question-card.test.mjs +++ b/crates/agent-gui/test/chat/ask-user-question-card.test.mjs @@ -236,10 +236,10 @@ test("a deadline already past at mount is distrusted and the pending card stays assert.equal(submitted[0][0].selectedLabel, "Second"); }); -test("a deadline beyond the full answer window is distrusted and clamps the countdown", () => { +test("a far-future deadline (long timeout window) is trusted and counts down to it", () => { const card = createCardHarness(); const tree = card.render({ - // 本机时钟慢于桌面盖章时钟:截止时间看似远超完整应答窗口。 + // 超长窗口(≈永不超时):截止时间远超默认窗口,仍被采信并显示其剩余时间。 deadlineAt: Date.now() + ASK_USER_QUESTION_TIMEOUT_MS + 5 * 60 * 1000, onSubmit: async () => ({ ok: true }), }); @@ -249,8 +249,7 @@ test("a deadline beyond the full answer window is distrusted and clamps the coun (node) => node.type === "button" && node.props?.role === "radio", ); assert.equal(optionButtons.every((button) => button.props.disabled === false), true); - // 倒计时按挂载近似显示完整窗口,而不是把偏移量当剩余时间。 - assert.match(treeText(tree), /(?:3:00|2:59) chat\.askUser\.timeoutHint/); + assert.match(treeText(tree), /(?:8:00|7:59) chat\.askUser\.timeoutHint/); }); test("a complete answer before the deadline submits the selected non-first option", async () => { diff --git a/crates/agent-gui/test/settings/normalization.test.mjs b/crates/agent-gui/test/settings/normalization.test.mjs index 6396fc646..8926de5d5 100644 --- a/crates/agent-gui/test/settings/normalization.test.mjs +++ b/crates/agent-gui/test/settings/normalization.test.mjs @@ -2206,6 +2206,21 @@ test("close window behavior defaults to minimize and only accepts exit", () => { ); }); +test("interactive timeout minutes normalize to positive with default 3", () => { + // 默认 3 分钟,保持历史行为。 + assert.equal(settings.getDefaultSettings().system.interactiveTimeoutMinutes, 3); + assert.equal(settings.normalizeSystemSettings({}).interactiveTimeoutMinutes, 3); + + // 正数原样保留(无上限,超长≈永不超时);0/负数/非法回默认 3。 + assert.equal(settings.normalizeInteractiveTimeoutMinutes(5), 5); + assert.equal(settings.normalizeInteractiveTimeoutMinutes(60), 60); + assert.equal(settings.normalizeInteractiveTimeoutMinutes(99999), 99999); + assert.equal(settings.normalizeInteractiveTimeoutMinutes(0), 3); + assert.equal(settings.normalizeInteractiveTimeoutMinutes(-5), 3); + assert.equal(settings.normalizeInteractiveTimeoutMinutes(NaN), 3); + assert.equal(settings.normalizeInteractiveTimeoutMinutes("12"), 3); +}); + test("system proxy config normalizes defaults, ports, and password flags", () => { const defaults = settings.getDefaultSettings().system.systemProxy; assert.deepEqual(defaults, { diff --git a/crates/agent-gui/test/tools/ask-user-question-tools.test.mjs b/crates/agent-gui/test/tools/ask-user-question-tools.test.mjs index e18246b2f..d196b52b9 100644 --- a/crates/agent-gui/test/tools/ask-user-question-tools.test.mjs +++ b/crates/agent-gui/test/tools/ask-user-question-tools.test.mjs @@ -263,6 +263,33 @@ test("timeout falls back to the first option when no recommendation exists", asy assert.equal(tools.hasPendingAskUserQuestion("call-ask-first-fallback"), false); }); +test("a very long timeout window behaves like never within the test window", async () => { + const { tools } = loadModules(); + // 1 小时窗口:测试等待 ~60ms 内不会自动落定,等价于“永不”。 + const bundle = tools.createAskUserQuestionTools({ + conversationId: "conv-long-window", + timeoutMs: 60 * 60 * 1000, + }); + const toolCall = createToolCall(buildQuestionsArgs(), "call-ask-long-window"); + + const resultPromise = bundle.executeToolCall(toolCall); + const deadline = tools.getAskUserQuestionDeadlineAt("call-ask-long-window"); + assert.ok(Number.isFinite(deadline)); + assert.ok(deadline > Date.now()); + await new Promise((resolve) => setTimeout(resolve, 60)); + assert.equal(tools.hasPendingAskUserQuestion("call-ask-long-window"), true); + + // 用户作答后才落定,且不标记超时。 + tools.answerAskUserQuestion("call-ask-long-window", [ + { questionId: "storage", selectedLabel: "应用数据目录" }, + { questionId: "q2", selectedLabel: "不迁移" }, + ]); + const result = await resultPromise; + assert.equal(result.isError, false); + assert.equal(result.details.timedOut, undefined); + assert.equal(tools.hasPendingAskUserQuestion("call-ask-long-window"), false); +}); + test("immediate answers are pending synchronously and never fall through to timeout defaults", async () => { const { tools } = loadModules(); const bundle = tools.createAskUserQuestionTools({ conversationId: "conv-fast", timeoutMs: 100 }); diff --git a/crates/agent-gui/test/tools/tool-approval.test.mjs b/crates/agent-gui/test/tools/tool-approval.test.mjs index 75996aa9a..bd0e36e0f 100644 --- a/crates/agent-gui/test/tools/tool-approval.test.mjs +++ b/crates/agent-gui/test/tools/tool-approval.test.mjs @@ -64,6 +64,25 @@ test("超时落定为 timeout", async () => { assert.equal(hasPendingToolApproval("c4"), false); }); +test("很长的超时窗口 ≈ 永不:挂起等待用户决定,不在测试窗口内自动落定", async () => { + const promise = requestToolApproval({ + toolCallId: "c-long-window", + toolName: "Bash", + conversationId: "conv-long-window", + timeoutMs: 60 * 60 * 1000, // 1 小时 + }); + assert.equal(hasPendingToolApproval("c-long-window"), true); + const pending = getPendingToolApproval("c-long-window"); + assert.ok(pending && pending.deadlineAt > Date.now() + 60 * 60 * 1000 - 60_000); + // 等一拍,确认不会自动 timeout。 + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal(hasPendingToolApproval("c-long-window"), true); + // 用户决定后才落定。 + answerToolApproval("c-long-window", "approve"); + assert.deepEqual(await promise, { kind: "decided", decision: "approve" }); + assert.equal(hasPendingToolApproval("c-long-window"), false); +}); + test("AbortSignal 触发 → cancelled;已 aborted 的信号立即 cancelled", async () => { const controller = new AbortController(); const promise = requestToolApproval({ diff --git a/docs/features/chat-runtime.md b/docs/features/chat-runtime.md index 4a964f538..f093c4429 100644 --- a/docs/features/chat-runtime.md +++ b/docs/features/chat-runtime.md @@ -82,7 +82,7 @@ Hooks 支持 shell script 和 HTTP requests,设置由 GUI/WebUI 同步维护 | 能力 | 语义 | |---|---| | 工具形态 | chat-only 内置工具;模型一次最多提 4 个问题,每题 2-6 个选项且**同轮各题选项数一致**,至多一个"推荐"项且**固定排在首位**。 | -| 挂起语义 | `execute` 在工具挂起表(toolCallId 键)上等待;用户提交后 resolve,停止按钮经 AbortSignal 以"未应答"落定(`details.cancelled`);**3 分钟未作答按推荐项(缺省第一项)自动落定**(`details.timedOut`),卡片展示倒计时。**倒计时双端同源**:桌面端在网关上报的工具参数上盖 `__askUserQuestionDeadlineAt` 权威截止时间戳(`gatewayToolPreview` 统一盖章,execute 复用同一预置值),WebUI/重连场景按真实剩余时间倒数。 | +| 挂起语义 | `execute` 在工具挂起表(toolCallId 键)上等待;用户提交后 resolve,停止按钮经 AbortSignal 以"未应答"落定(`details.cancelled`);**应答窗口由设置 `system.interactiveTimeoutMinutes` 配置(正数分钟,默认 3,与工具审批栏共用同一窗口;填很大的数如 99999 ≈ 永不超时);超时后按推荐项(缺省第一项)自动落定**(`details.timedOut`),卡片展示倒计时。**倒计时双端同源**:桌面端在网关上报的工具参数上盖 `__askUserQuestionDeadlineAt` 权威截止时间戳(`gatewayToolPreview` 统一盖章,execute 复用同一预置值),WebUI/重连场景按真实剩余时间倒数。 | | 卡片 UI | `components/chat/AskUserQuestionCard.tsx`(双端镜像):多问题以顶部 tabs 切换,单选 + 推荐标记,全部作答后提交;应答落定后只读回显。**问题与选项全部生成完毕后整卡出现**(`runAgentConversationTurn` 跳过 AskUserQuestion 的 tool_call_delta,双端不做流式渐显)。 | | 双端应答 | GUI 直接调用 `answerAskUserQuestion`;WebUI 走 `chat_queue.tool_answer`(item_id=toolCallId,request_json=选择数组)由桌面端落到同一挂起表,协议零改动;远端应答**校验 conversation_id 与挂起提问所属会话一致**,防串会话应答。 | | 结果回模型 | 标准 `ToolResultMessage`:content 列出每题的最终选择,`details.kind = "ask_user_question"` 驱动历史回放渲染。 | From fbece99a0a2c7a878ba549dcc14dfccec9c32939 Mon Sep 17 00:00:00 2001 From: FlowerRealm Date: Mon, 3 Aug 2026 20:27:38 -0700 Subject: [PATCH 2/7] refactor(chat): merge interactive timeout into AskUserQuestion row as a fine-grained slider Move the interactive answer timeout off its standalone input card and into the AskUserQuestion tool row, replacing the fixed read-only 'allow' label with a stop-based slider. The stop table keeps 1-60 minutes fine-grained (1,2,3,4,5,6,8,10,12,15,20,25,30,40,50,60) and jumps straight to 99999 past one hour, so the slider reads as a plain duration without exposing any internal 'never' semantics. The tool detail copy no longer hardcodes '3 minutes'; it now points at the adjustable slider. --- crates/agent-gateway/web/src/i18n/config.ts | 11 +-- .../src/pages/settings/SystemToolsSection.tsx | 85 +++++++++---------- crates/agent-gui/src/i18n/config.ts | 11 +-- .../src/pages/settings/SystemToolsSection.tsx | 85 +++++++++---------- 4 files changed, 80 insertions(+), 112 deletions(-) diff --git a/crates/agent-gateway/web/src/i18n/config.ts b/crates/agent-gateway/web/src/i18n/config.ts index 5b624eda5..d3c30dd57 100644 --- a/crates/agent-gateway/web/src/i18n/config.ts +++ b/crates/agent-gateway/web/src/i18n/config.ts @@ -1245,10 +1245,7 @@ export const translations: Record> = { "settings.systemToolsDesc": "查看 LiveAgent 在 Agent 模式下自动注册的内置工具,并为每个工具设置审批策略(放行 / 执行前询问 / 拒绝)。", "settings.interactiveTimeout.title": "交互式应答超时", - "settings.interactiveTimeout.desc": "AskUserQuestion 提问卡与工具审批栏的等待窗口,二者共用。", "settings.interactiveTimeout.unit": "分钟", - "settings.interactiveTimeout.hint": - "正数分钟 = 超时窗口;填很大的数(如 99999)≈ 永不超时。超时后按各交互既定姿态落定(提问自动选推荐项并继续、审批按拒绝)。", "settings.builtinToolCategory.fs": "文件系统", "settings.builtinToolCategory.process": "终端与进程", "settings.builtinToolCategory.intelligence": "智能与记忆", @@ -1321,7 +1318,7 @@ export const translations: Record> = { "settings.builtinTool.ask_user_question.name": "用户提问", "settings.builtinTool.ask_user_question.desc": "以选项卡片向你提问并等待选择", "settings.builtinTool.ask_user_question.detail": - "模型在需要你决策时发起选择题(一次最多 4 个问题,每题 2-6 个选项且各题数量一致,推荐项排在首位)。卡片暂停执行等待作答,3 分钟内未作答自动按推荐项继续执行;桌面端与 WebUI 均可作答,点击停止可跳过。仅在对话场景注册。", + "模型在需要你决策时发起选择题(一次最多 4 个问题,每题 2-6 个选项且各题数量一致,推荐项排在首位)。卡片暂停执行等待作答,超时时间可用下方滑块调整,超时后未作答自动按推荐项继续执行;桌面端与 WebUI 均可作答,点击停止可跳过。仅在对话场景注册。", "settings.builtinTool.cron_task_manager.name": "定时任务", "settings.builtinTool.cron_task_manager.desc": "创建与管理定时自动任务", "settings.builtinTool.cron_task_manager.detail": @@ -3472,11 +3469,7 @@ export const translations: Record> = { "settings.systemToolsDesc": "View the built-in tools that LiveAgent registers automatically in Agent mode, and set an approval policy per tool (allow / ask before running / deny).", "settings.interactiveTimeout.title": "Interactive answer timeout", - "settings.interactiveTimeout.desc": - "Wait window shared by the AskUserQuestion card and the tool approval bar.", "settings.interactiveTimeout.unit": "min", - "settings.interactiveTimeout.hint": - "Positive minutes = answer window; use a very large number (e.g. 99999) for effectively no timeout. On timeout, each prompt settles its own way (questions auto-pick the recommended option and continue; approvals are denied).", "settings.builtinToolCategory.fs": "File System", "settings.builtinToolCategory.process": "Terminal & Processes", "settings.builtinToolCategory.intelligence": "Intelligence & Memory", @@ -3551,7 +3544,7 @@ export const translations: Record> = { "settings.builtinTool.ask_user_question.desc": "Ask you multiple-choice questions in a card and wait for your selections", "settings.builtinTool.ask_user_question.detail": - "Lets the model ask you multiple-choice questions when a decision is yours to make (up to 4 questions per call, 2-6 options each with the same count across questions, recommended option shown first). Execution pauses on an interactive card until you answer — from the desktop or the WebUI; after 3 minutes without an answer the recommended options are auto-selected, and pressing Stop skips the question. Chat sessions only.", + "Lets the model ask you multiple-choice questions when a decision is yours to make (up to 4 questions per call, 2-6 options each with the same count across questions, recommended option shown first). Execution pauses on an interactive card until you answer — from the desktop or the WebUI; the answer window is adjustable with the slider below, and when it elapses without an answer the recommended options are auto-selected. Pressing Stop skips the question. Chat sessions only.", "settings.builtinTool.cron_task_manager.name": "Scheduled Tasks", "settings.builtinTool.cron_task_manager.desc": "Create and manage scheduled automations", "settings.builtinTool.cron_task_manager.detail": diff --git a/crates/agent-gateway/web/src/pages/settings/SystemToolsSection.tsx b/crates/agent-gateway/web/src/pages/settings/SystemToolsSection.tsx index 8599c29dd..85c0aea37 100644 --- a/crates/agent-gateway/web/src/pages/settings/SystemToolsSection.tsx +++ b/crates/agent-gateway/web/src/pages/settings/SystemToolsSection.tsx @@ -5,15 +5,20 @@ // // 说明:MCP 工具按 server、插件工具按工具的策略已就地内联到各自 Hub 卡片旁 //(需运行时数据),不在本节;本节聚焦内置工具,补上内置工具此前不可管控的缺口。 -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import { ToolPolicyToggle } from "../../components/hub/ToolPolicyToggle"; import { Wrench } from "../../components/icons"; -import { Input } from "../../components/ui/input"; import { useLocale } from "../../i18n"; import { type ToolPolicy, updateSystem } from "../../lib/settings"; import { BUILTIN_TOOL_CATALOG, BUILTIN_TOOL_CATEGORIES } from "../../lib/tools/builtinToolCatalog"; import type { SettingsSectionProps } from "./types"; +// 交互式应答超时(分钟)档位表:60 分钟内档位密(细调),超过 60 分钟直接跳最大档 +// 99999(≈ 超长等待)。滑块只在这些档位上取整,保证时间始终是常见可读的分钟数。 +const TIMEOUT_STOPS = [ + 1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 25, 30, 40, 50, 60, 99999, +]; + export function SystemToolsSection(props: SettingsSectionProps) { const { settings, setSettings } = props; const { t } = useLocale(); @@ -52,20 +57,19 @@ export function SystemToolsSection(props: SettingsSectionProps) { const overriddenCount = Object.keys(policies).length; - // 交互式应答超时(分钟):正数=超时窗口,超长≈永不超时。草稿 + blur 提交,避免 - // 逐字符触发设置同步;空/非法回退不提交,非正由归一化回默认 3。 - const interactiveTimeoutMinutes = settings.system.interactiveTimeoutMinutes; - const [timeoutDraft, setTimeoutDraft] = useState(null); - const timeoutInputValue = timeoutDraft ?? String(interactiveTimeoutMinutes); - const commitTimeoutDraft = () => { - if (timeoutDraft === null) return; - const parsed = Number.parseInt(timeoutDraft, 10); - if (!Number.isFinite(parsed)) { - setTimeoutDraft(null); - return; + // 交互式应答超时(分钟):AskUserQuestion 提问卡与工具审批栏共用同一等待窗口。 + // 滑块按档位表映射(TIMEOUT_STOPS,见文件头),最右档 = 99999。 + const timeoutMinutes = settings.system.interactiveTimeoutMinutes; + const timeoutStopIndex = TIMEOUT_STOPS.reduce( + (best, stop, i) => + Math.abs(stop - timeoutMinutes) < Math.abs(TIMEOUT_STOPS[best] - timeoutMinutes) ? i : best, + 0, + ); + const onTimeoutStopChange = (index: number) => { + const next = TIMEOUT_STOPS[index]; + if (next !== timeoutMinutes) { + setSettings((prev) => updateSystem(prev, { interactiveTimeoutMinutes: next })); } - setSettings((prev) => updateSystem(prev, { interactiveTimeoutMinutes: parsed })); - setTimeoutDraft(null); }; return ( @@ -87,36 +91,6 @@ export function SystemToolsSection(props: SettingsSectionProps) { ) : null}
-
-
-
-
{t("settings.interactiveTimeout.title")}
-

- {t("settings.interactiveTimeout.desc")} -

-
-
- setTimeoutDraft(event.currentTarget.value)} - onBlur={commitTimeoutDraft} - onKeyDown={(event) => { - if (event.key === "Enter") event.currentTarget.blur(); - }} - className="w-20" - /> - - {t("settings.interactiveTimeout.unit")} - -
-
-

- {t("settings.interactiveTimeout.hint")} -

-
-
{groups.map(({ category, entries }) => (
@@ -127,7 +101,7 @@ export function SystemToolsSection(props: SettingsSectionProps) { {entries.map((entry) => { const policy = effectivePolicy(entry.toolName, entry.isReadOnly); return ( -
+
@@ -146,7 +120,24 @@ export function SystemToolsSection(props: SettingsSectionProps) { {t(`settings.builtinTool.${entry.id}.desc`)}
- {entry.isReadOnly ? ( + {entry.isReadOnly && entry.id === "ask_user_question" ? ( +
+ onTimeoutStopChange(Number(event.currentTarget.value))} + className="w-full accent-primary" + /> +
+ {timeoutMinutes}{" "} + {t("settings.interactiveTimeout.unit")} +
+
+ ) : entry.isReadOnly ? ( {t("settings.toolPolicy.allow")} diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 3ec3609bc..1a91e3e5f 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -1315,10 +1315,7 @@ export const translations: Record> = { "settings.systemToolsDesc": "查看 LiveAgent 在 Agent 模式下自动注册的内置工具,并为每个工具设置审批策略(放行 / 执行前询问 / 拒绝)。", "settings.interactiveTimeout.title": "交互式应答超时", - "settings.interactiveTimeout.desc": "AskUserQuestion 提问卡与工具审批栏的等待窗口,二者共用。", "settings.interactiveTimeout.unit": "分钟", - "settings.interactiveTimeout.hint": - "正数分钟 = 超时窗口;填很大的数(如 99999)≈ 永不超时。超时后按各交互既定姿态落定(提问自动选推荐项并继续、审批按拒绝)。", "settings.builtinToolCategory.fs": "文件系统", "settings.builtinToolCategory.process": "终端与进程", "settings.builtinToolCategory.intelligence": "智能与记忆", @@ -1391,7 +1388,7 @@ export const translations: Record> = { "settings.builtinTool.ask_user_question.name": "用户提问", "settings.builtinTool.ask_user_question.desc": "以选项卡片向你提问并等待选择", "settings.builtinTool.ask_user_question.detail": - "模型在需要你决策时发起选择题(一次最多 4 个问题,每题 2-6 个选项且各题数量一致,推荐项排在首位)。卡片暂停执行等待作答,3 分钟内未作答自动按推荐项继续执行;桌面端与 WebUI 均可作答,点击停止可跳过。仅在对话场景注册。", + "模型在需要你决策时发起选择题(一次最多 4 个问题,每题 2-6 个选项且各题数量一致,推荐项排在首位)。卡片暂停执行等待作答,超时时间可用下方滑块调整,超时后未作答自动按推荐项继续执行;桌面端与 WebUI 均可作答,点击停止可跳过。仅在对话场景注册。", "settings.builtinTool.cron_task_manager.name": "定时任务", "settings.builtinTool.cron_task_manager.desc": "创建与管理定时自动任务", "settings.builtinTool.cron_task_manager.detail": @@ -3635,11 +3632,7 @@ export const translations: Record> = { "settings.systemToolsDesc": "View the built-in tools that LiveAgent registers automatically in Agent mode, and set an approval policy per tool (allow / ask before running / deny).", "settings.interactiveTimeout.title": "Interactive answer timeout", - "settings.interactiveTimeout.desc": - "Wait window shared by the AskUserQuestion card and the tool approval bar.", "settings.interactiveTimeout.unit": "min", - "settings.interactiveTimeout.hint": - "Positive minutes = answer window; use a very large number (e.g. 99999) for effectively no timeout. On timeout, each prompt settles its own way (questions auto-pick the recommended option and continue; approvals are denied).", "settings.builtinToolCategory.fs": "File System", "settings.builtinToolCategory.process": "Terminal & Processes", "settings.builtinToolCategory.intelligence": "Intelligence & Memory", @@ -3714,7 +3707,7 @@ export const translations: Record> = { "settings.builtinTool.ask_user_question.desc": "Ask you multiple-choice questions in a card and wait for your selections", "settings.builtinTool.ask_user_question.detail": - "Lets the model ask you multiple-choice questions when a decision is yours to make (up to 4 questions per call, 2-6 options each with the same count across questions, recommended option shown first). Execution pauses on an interactive card until you answer — from the desktop or the WebUI; after 3 minutes without an answer the recommended options are auto-selected, and pressing Stop skips the question. Chat sessions only.", + "Lets the model ask you multiple-choice questions when a decision is yours to make (up to 4 questions per call, 2-6 options each with the same count across questions, recommended option shown first). Execution pauses on an interactive card until you answer — from the desktop or the WebUI; the answer window is adjustable with the slider below, and when it elapses without an answer the recommended options are auto-selected. Pressing Stop skips the question. Chat sessions only.", "settings.builtinTool.cron_task_manager.name": "Scheduled Tasks", "settings.builtinTool.cron_task_manager.desc": "Create and manage scheduled automations", "settings.builtinTool.cron_task_manager.detail": diff --git a/crates/agent-gui/src/pages/settings/SystemToolsSection.tsx b/crates/agent-gui/src/pages/settings/SystemToolsSection.tsx index 8599c29dd..85c0aea37 100644 --- a/crates/agent-gui/src/pages/settings/SystemToolsSection.tsx +++ b/crates/agent-gui/src/pages/settings/SystemToolsSection.tsx @@ -5,15 +5,20 @@ // // 说明:MCP 工具按 server、插件工具按工具的策略已就地内联到各自 Hub 卡片旁 //(需运行时数据),不在本节;本节聚焦内置工具,补上内置工具此前不可管控的缺口。 -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import { ToolPolicyToggle } from "../../components/hub/ToolPolicyToggle"; import { Wrench } from "../../components/icons"; -import { Input } from "../../components/ui/input"; import { useLocale } from "../../i18n"; import { type ToolPolicy, updateSystem } from "../../lib/settings"; import { BUILTIN_TOOL_CATALOG, BUILTIN_TOOL_CATEGORIES } from "../../lib/tools/builtinToolCatalog"; import type { SettingsSectionProps } from "./types"; +// 交互式应答超时(分钟)档位表:60 分钟内档位密(细调),超过 60 分钟直接跳最大档 +// 99999(≈ 超长等待)。滑块只在这些档位上取整,保证时间始终是常见可读的分钟数。 +const TIMEOUT_STOPS = [ + 1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 25, 30, 40, 50, 60, 99999, +]; + export function SystemToolsSection(props: SettingsSectionProps) { const { settings, setSettings } = props; const { t } = useLocale(); @@ -52,20 +57,19 @@ export function SystemToolsSection(props: SettingsSectionProps) { const overriddenCount = Object.keys(policies).length; - // 交互式应答超时(分钟):正数=超时窗口,超长≈永不超时。草稿 + blur 提交,避免 - // 逐字符触发设置同步;空/非法回退不提交,非正由归一化回默认 3。 - const interactiveTimeoutMinutes = settings.system.interactiveTimeoutMinutes; - const [timeoutDraft, setTimeoutDraft] = useState(null); - const timeoutInputValue = timeoutDraft ?? String(interactiveTimeoutMinutes); - const commitTimeoutDraft = () => { - if (timeoutDraft === null) return; - const parsed = Number.parseInt(timeoutDraft, 10); - if (!Number.isFinite(parsed)) { - setTimeoutDraft(null); - return; + // 交互式应答超时(分钟):AskUserQuestion 提问卡与工具审批栏共用同一等待窗口。 + // 滑块按档位表映射(TIMEOUT_STOPS,见文件头),最右档 = 99999。 + const timeoutMinutes = settings.system.interactiveTimeoutMinutes; + const timeoutStopIndex = TIMEOUT_STOPS.reduce( + (best, stop, i) => + Math.abs(stop - timeoutMinutes) < Math.abs(TIMEOUT_STOPS[best] - timeoutMinutes) ? i : best, + 0, + ); + const onTimeoutStopChange = (index: number) => { + const next = TIMEOUT_STOPS[index]; + if (next !== timeoutMinutes) { + setSettings((prev) => updateSystem(prev, { interactiveTimeoutMinutes: next })); } - setSettings((prev) => updateSystem(prev, { interactiveTimeoutMinutes: parsed })); - setTimeoutDraft(null); }; return ( @@ -87,36 +91,6 @@ export function SystemToolsSection(props: SettingsSectionProps) { ) : null}
-
-
-
-
{t("settings.interactiveTimeout.title")}
-

- {t("settings.interactiveTimeout.desc")} -

-
-
- setTimeoutDraft(event.currentTarget.value)} - onBlur={commitTimeoutDraft} - onKeyDown={(event) => { - if (event.key === "Enter") event.currentTarget.blur(); - }} - className="w-20" - /> - - {t("settings.interactiveTimeout.unit")} - -
-
-

- {t("settings.interactiveTimeout.hint")} -

-
-
{groups.map(({ category, entries }) => (
@@ -127,7 +101,7 @@ export function SystemToolsSection(props: SettingsSectionProps) { {entries.map((entry) => { const policy = effectivePolicy(entry.toolName, entry.isReadOnly); return ( -
+
@@ -146,7 +120,24 @@ export function SystemToolsSection(props: SettingsSectionProps) { {t(`settings.builtinTool.${entry.id}.desc`)}
- {entry.isReadOnly ? ( + {entry.isReadOnly && entry.id === "ask_user_question" ? ( +
+ onTimeoutStopChange(Number(event.currentTarget.value))} + className="w-full accent-primary" + /> +
+ {timeoutMinutes}{" "} + {t("settings.interactiveTimeout.unit")} +
+
+ ) : entry.isReadOnly ? ( {t("settings.toolPolicy.allow")} From c9d8f307264cf70980c83c9c0cb1b06ec773f7be Mon Sep 17 00:00:00 2001 From: FlowerRealm Date: Mon, 3 Aug 2026 20:38:13 -0700 Subject: [PATCH 3/7] refactor(chat): trim redundant timeout slider comments Keep a single one-line comment on the stop table and drop the duplicate block inside the render body; both mirrored files stay byte-identical. --- .../web/src/pages/settings/SystemToolsSection.tsx | 5 +---- crates/agent-gui/src/pages/settings/SystemToolsSection.tsx | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/crates/agent-gateway/web/src/pages/settings/SystemToolsSection.tsx b/crates/agent-gateway/web/src/pages/settings/SystemToolsSection.tsx index 85c0aea37..156442bb7 100644 --- a/crates/agent-gateway/web/src/pages/settings/SystemToolsSection.tsx +++ b/crates/agent-gateway/web/src/pages/settings/SystemToolsSection.tsx @@ -13,8 +13,7 @@ import { type ToolPolicy, updateSystem } from "../../lib/settings"; import { BUILTIN_TOOL_CATALOG, BUILTIN_TOOL_CATEGORIES } from "../../lib/tools/builtinToolCatalog"; import type { SettingsSectionProps } from "./types"; -// 交互式应答超时(分钟)档位表:60 分钟内档位密(细调),超过 60 分钟直接跳最大档 -// 99999(≈ 超长等待)。滑块只在这些档位上取整,保证时间始终是常见可读的分钟数。 +// 档位表:60 分钟内细调,超过 1 小时直跳最大档 99999。 const TIMEOUT_STOPS = [ 1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 25, 30, 40, 50, 60, 99999, ]; @@ -57,8 +56,6 @@ export function SystemToolsSection(props: SettingsSectionProps) { const overriddenCount = Object.keys(policies).length; - // 交互式应答超时(分钟):AskUserQuestion 提问卡与工具审批栏共用同一等待窗口。 - // 滑块按档位表映射(TIMEOUT_STOPS,见文件头),最右档 = 99999。 const timeoutMinutes = settings.system.interactiveTimeoutMinutes; const timeoutStopIndex = TIMEOUT_STOPS.reduce( (best, stop, i) => diff --git a/crates/agent-gui/src/pages/settings/SystemToolsSection.tsx b/crates/agent-gui/src/pages/settings/SystemToolsSection.tsx index 85c0aea37..156442bb7 100644 --- a/crates/agent-gui/src/pages/settings/SystemToolsSection.tsx +++ b/crates/agent-gui/src/pages/settings/SystemToolsSection.tsx @@ -13,8 +13,7 @@ import { type ToolPolicy, updateSystem } from "../../lib/settings"; import { BUILTIN_TOOL_CATALOG, BUILTIN_TOOL_CATEGORIES } from "../../lib/tools/builtinToolCatalog"; import type { SettingsSectionProps } from "./types"; -// 交互式应答超时(分钟)档位表:60 分钟内档位密(细调),超过 60 分钟直接跳最大档 -// 99999(≈ 超长等待)。滑块只在这些档位上取整,保证时间始终是常见可读的分钟数。 +// 档位表:60 分钟内细调,超过 1 小时直跳最大档 99999。 const TIMEOUT_STOPS = [ 1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 25, 30, 40, 50, 60, 99999, ]; @@ -57,8 +56,6 @@ export function SystemToolsSection(props: SettingsSectionProps) { const overriddenCount = Object.keys(policies).length; - // 交互式应答超时(分钟):AskUserQuestion 提问卡与工具审批栏共用同一等待窗口。 - // 滑块按档位表映射(TIMEOUT_STOPS,见文件头),最右档 = 99999。 const timeoutMinutes = settings.system.interactiveTimeoutMinutes; const timeoutStopIndex = TIMEOUT_STOPS.reduce( (best, stop, i) => From ed674c4c461b496ed6a3de7f40178dc336b15463 Mon Sep 17 00:00:00 2001 From: devil233-ui Date: Thu, 6 Aug 2026 12:17:38 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=F0=9F=90=9B=20fix(settings):=20persist=20i?= =?UTF-8?q?nteractiveTimeoutMinutes=20in=20Rust=20save=20whitelist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端改完滑块重启即回默认值:该键此前不在 save_system 白名单里, 落库时被丢弃。补入白名单 + normalize_interactive_timeout_minutes_value (正数保留、上限钳 99999、非正/非法回默认 3,与前端归一化同口径), 并更新 row_count 断言(9→10)与 key 列表,新增 round-trip 测试覆盖 7 个归一化用例。 --- .../src/commands/config/settings/mod.rs | 8 ++++ .../src/commands/config/settings/system.rs | 21 +++++++++ .../src/commands/config/settings/tests.rs | 47 ++++++++++++++++++- 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/mod.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/mod.rs index ecf34f264..d1bc6a7dc 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/mod.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/mod.rs @@ -37,6 +37,14 @@ const SYSTEM_HIDDEN_WORKSPACE_PROJECT_PATHS_KEY: &str = "hiddenWorkspaceProjectP const SYSTEM_MISSING_WORKSPACE_PROJECT_PATHS_KEY: &str = "missingWorkspaceProjectPaths"; const SYSTEM_ARCHIVED_WORKSPACE_PROJECT_PATHS_KEY: &str = "archivedWorkspaceProjectPaths"; const SYSTEM_SYSTEM_PROXY_KEY: &str = "systemProxy"; +// 交互式应答超时(分钟):AskUserQuestion 提问卡与工具审批栏共用的等待窗口。 +// 与 toolPolicies 同理必须进保存白名单,否则前端改完滑块重启即回默认值。 +const SYSTEM_INTERACTIVE_TIMEOUT_MINUTES_KEY: &str = "interactiveTimeoutMinutes"; +/// 交互式应答超时的默认值与上限(分钟)。上限与前端档位表最大档一致: +/// 分钟数换算成毫秒后由前端分段续期计时,不再受 setTimeout 32 位延迟上限影响, +/// 但仍保留上限以免手改配置写入荒谬值(并与前端归一化口径保持一致)。 +const DEFAULT_INTERACTIVE_TIMEOUT_MINUTES: f64 = 3.0; +const MAX_INTERACTIVE_TIMEOUT_MINUTES: f64 = 99_999.0; const DEFAULT_WORKSPACE_PROJECT_ID: &str = "default-project"; const DEFAULT_WORKSPACE_PROJECT_NAME: &str = "Default Project"; pub(crate) const PROVIDER_API_KEY_UPDATES_FIELD: &str = "providerApiKeyUpdates"; diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/system.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/system.rs index 6653c5226..a7e7b6199 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/system.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/system.rs @@ -183,6 +183,20 @@ fn normalize_archived_workspace_project_paths(raw: Option<&Value>) -> Value { Value::Array(out) } +/// 归一化交互式应答超时(分钟):正数原样保留、上限 99999(≈永不超时), +/// 缺省/非法/非正回默认 3。与前端 normalizeInteractiveTimeoutMinutes 同口径, +/// 保证桌面端落库值与 GUI/WebUI 归一化结果一致。 +fn normalize_interactive_timeout_minutes_value(raw: Option<&Value>) -> Value { + let minutes = raw + .and_then(Value::as_f64) + .filter(|minutes| minutes.is_finite() && *minutes > 0.0) + .map(|minutes| minutes.min(MAX_INTERACTIVE_TIMEOUT_MINUTES)) + .unwrap_or(DEFAULT_INTERACTIVE_TIMEOUT_MINUTES); + Number::from_f64(minutes) + .map(Value::Number) + .unwrap_or_else(|| json!(DEFAULT_INTERACTIVE_TIMEOUT_MINUTES)) +} + fn normalize_system_proxy_value(raw: Option<&Value>) -> Value { let obj = match raw { Some(Value::Object(map)) => map.clone(), @@ -340,6 +354,12 @@ fn system_value_with_defaults(raw: Option, default_workdir: &str) -> Valu SYSTEM_SYSTEM_PROXY_KEY.to_string(), normalize_system_proxy_value(system.get(SYSTEM_SYSTEM_PROXY_KEY)), ); + system.insert( + SYSTEM_INTERACTIVE_TIMEOUT_MINUTES_KEY.to_string(), + normalize_interactive_timeout_minutes_value( + system.get(SYSTEM_INTERACTIVE_TIMEOUT_MINUTES_KEY), + ), + ); Value::Object(system) } @@ -387,6 +407,7 @@ fn save_system_with_default_workdir( SYSTEM_MISSING_WORKSPACE_PROJECT_PATHS_KEY, SYSTEM_ARCHIVED_WORKSPACE_PROJECT_PATHS_KEY, SYSTEM_SYSTEM_PROXY_KEY, + SYSTEM_INTERACTIVE_TIMEOUT_MINUTES_KEY, ] { let value = system.get(key).cloned().unwrap_or(Value::Null); tx.execute( diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs index 4793f7443..fe5fb0109 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs @@ -1120,7 +1120,7 @@ mod tests { }; let loaded = load_system(&conn).expect("load system"); - assert_eq!(row_count, 9); + assert_eq!(row_count, 10); assert_eq!( keys, vec![ @@ -1128,6 +1128,7 @@ mod tests { SYSTEM_ARCHIVED_WORKSPACE_PROJECT_PATHS_KEY.to_string(), SYSTEM_EXECUTION_MODE_KEY.to_string(), SYSTEM_HIDDEN_WORKSPACE_PROJECT_PATHS_KEY.to_string(), + SYSTEM_INTERACTIVE_TIMEOUT_MINUTES_KEY.to_string(), SYSTEM_MISSING_WORKSPACE_PROJECT_PATHS_KEY.to_string(), SYSTEM_SYSTEM_PROXY_KEY.to_string(), SYSTEM_TOOL_POLICIES_KEY.to_string(), @@ -1144,6 +1145,7 @@ mod tests { "missingWorkspaceProjectPaths": [], "archivedWorkspaceProjectPaths": [], "systemProxy": default_system_proxy_json(), + "interactiveTimeoutMinutes": 3.0, "workdir": default_workdir.clone(), "toolPolicies": { "Bash": "ask", "server:docs-mcp": "deny" }, "workspaceProjects": [ @@ -1188,6 +1190,46 @@ mod tests { ); } + /// 交互式应答超时必须真正落库:该键此前不在保存白名单里,前端调完滑块 + /// 重启即回默认值,功能等于没生效。顺带覆盖归一化(非正/非法回默认、 + /// 超上限钳制),与前端 normalizeInteractiveTimeoutMinutes 同口径。 + #[test] + fn save_system_round_trips_interactive_timeout_minutes() { + let cases = [ + (json!(30), json!(30.0)), + (json!(99999), json!(99999.0)), + // 超上限钳制到 99999;非正/非法/缺省回默认 3。 + (json!(200000), json!(99999.0)), + (json!(0), json!(3.0)), + (json!(-5), json!(3.0)), + (json!("12"), json!(3.0)), + (Value::Null, json!(3.0)), + ]; + + for (input, expected) in cases { + let mut conn = open_memory_db(); + save_system_with_default_workdir( + &mut conn, + json!({ + "executionMode": "tools", + "workdir": "/tmp/liveagent-default-project", + "interactiveTimeoutMinutes": input, + }), + "/tmp/liveagent-default-project", + ) + .expect("save system"); + + let loaded = load_system(&conn) + .expect("load system") + .expect("system settings"); + assert_eq!( + loaded.get(SYSTEM_INTERACTIVE_TIMEOUT_MINUTES_KEY), + Some(&expected), + "interactiveTimeoutMinutes must survive the save/load round trip" + ); + } + } + #[test] fn save_system_backfills_empty_workdir_with_default_project() { let mut conn = open_memory_db(); @@ -1211,6 +1253,7 @@ mod tests { "missingWorkspaceProjectPaths": [], "archivedWorkspaceProjectPaths": [], "systemProxy": default_system_proxy_json(), + "interactiveTimeoutMinutes": 3.0, "workdir": "/tmp/liveagent-default-project", "toolPolicies": null, "workspaceProjects": [ @@ -1262,6 +1305,7 @@ mod tests { "missingWorkspaceProjectPaths": [], "archivedWorkspaceProjectPaths": [], "systemProxy": default_system_proxy_json(), + "interactiveTimeoutMinutes": 3.0, "workdir": "/tmp/liveagent-default-project", "toolPolicies": null, "workspaceProjects": [ @@ -1295,6 +1339,7 @@ mod tests { "missingWorkspaceProjectPaths": [], "archivedWorkspaceProjectPaths": [], "systemProxy": default_system_proxy_json(), + "interactiveTimeoutMinutes": 3.0, "workdir": "/tmp/liveagent-default-project", "workspaceProjects": [ { From 30deec3fb0f6aa3f5cf06b7d13d10fc5fdf68fab Mon Sep 17 00:00:00 2001 From: devil233-ui Date: Thu, 6 Aug 2026 12:17:45 +0800 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=94=A7=20fix(chat):=20segment=20long?= =?UTF-8?q?=20setTimeout=20delays=20to=20avoid=2032-bit=20overflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 99999 分钟换算成毫秒(≈5.99e9)超过 setTimeout 的 IDL long 上限 (2^31-1),裸调用会被 ToInt32 回绕:99999 被截成约 19.7 天, 而 35792~200000 分钟区间回绕成负数被钳为 0 → 「永不超时」瞬间 立刻超时。新增 scheduleAtDeadline 分段续期(单跳最多 2^31-1 ms, 到点按剩余时间续排,回调恒异步),替换 AskUserQuestion 与工具审批 的裸 setTimeout;补最大档回归 + scheduleAtDeadline 语义测试。 两端镜像,tsc/lint/mirror 均过。 --- .../web/src/lib/chat/askUserQuestion.ts | 40 ++++++++++ .../agent-gui/src/lib/chat/askUserQuestion.ts | 40 ++++++++++ .../src/lib/tools/askUserQuestionTools.ts | 9 ++- .../agent-gui/src/lib/tools/toolApproval.ts | 8 +- .../tools/ask-user-question-tools.test.mjs | 75 +++++++++++++++++++ .../test/tools/tool-approval.test.mjs | 22 ++++++ 6 files changed, 187 insertions(+), 7 deletions(-) diff --git a/crates/agent-gateway/web/src/lib/chat/askUserQuestion.ts b/crates/agent-gateway/web/src/lib/chat/askUserQuestion.ts index a5748cfa0..e0d01bed7 100644 --- a/crates/agent-gateway/web/src/lib/chat/askUserQuestion.ts +++ b/crates/agent-gateway/web/src/lib/chat/askUserQuestion.ts @@ -11,6 +11,46 @@ export const ASK_USER_QUESTION_MAX_OPTIONS = 6; * 运行时实际窗口由设置 system.interactiveTimeoutMinutes 驱动(正数分钟;超长≈永不超时), * 此常量仅作未注入时的兜底默认,保持历史行为与测试注入口径一致。 */ export const ASK_USER_QUESTION_TIMEOUT_MS = 3 * 60 * 1000; +/** 交互式应答超时(分钟)的默认值与上限,与桌面端 Rust 归一化同口径。 + * 上限存在的意义不是规避 setTimeout 溢出(那由分段续期解决), + * 而是避免手改配置写入荒谬值。 */ +export const INTERACTIVE_TIMEOUT_MIN_MINUTES = 1; +export const INTERACTIVE_TIMEOUT_MAX_MINUTES = 99_999; +/** setTimeout 的延迟形参是 IDL long(32 位有符号),超过 2^31-1 会被 ToInt32 + * 回绕:99999 分钟被截成约 19.7 天,而 35792~200000 分钟之间的值回绕成负数后 + * 被钳为 0,导致「永不超时」瞬间立刻超时。故长延迟必须分段续期。 */ +export const MAX_SAFE_TIMEOUT_DELAY_MS = 2_147_483_647; + +/** + * 分段续期的长延迟定时器:单次 setTimeout 只排最多 MAX_SAFE_TIMEOUT_DELAY_MS, + * 到点后按剩余时间继续排,直到真正跨过 deadline 才触发 onDeadline。返回取消函数。 + * + * 与裸 setTimeout 一致,回调恒异步触发(deadline 已过也先让出一轮), + * 这样调用方仍可在同一同步块里先拿到取消函数再在回调中引用它。 + */ +export function scheduleAtDeadline(deadlineAt: number, onDeadline: () => void): () => void { + let timerId: ReturnType | undefined; + let cancelled = false; + const arm = () => { + if (cancelled) return; + const remainingMs = deadlineAt - Date.now(); + if (remainingMs <= 0) { + onDeadline(); + return; + } + timerId = setTimeout(arm, Math.min(remainingMs, MAX_SAFE_TIMEOUT_DELAY_MS)); + }; + // 首拍也走 setTimeout,保证回调不会在本函数返回前同步触发。 + timerId = setTimeout( + arm, + Math.min(Math.max(0, deadlineAt - Date.now()), MAX_SAFE_TIMEOUT_DELAY_MS), + ); + return () => { + cancelled = true; + if (timerId !== undefined) clearTimeout(timerId); + }; +} + /** UI 合成"其他(自行输入)"应答的最大长度;超出部分截断。 */ export const ASK_USER_QUESTION_CUSTOM_MAX_LENGTH = 2000; /** diff --git a/crates/agent-gui/src/lib/chat/askUserQuestion.ts b/crates/agent-gui/src/lib/chat/askUserQuestion.ts index a5748cfa0..e0d01bed7 100644 --- a/crates/agent-gui/src/lib/chat/askUserQuestion.ts +++ b/crates/agent-gui/src/lib/chat/askUserQuestion.ts @@ -11,6 +11,46 @@ export const ASK_USER_QUESTION_MAX_OPTIONS = 6; * 运行时实际窗口由设置 system.interactiveTimeoutMinutes 驱动(正数分钟;超长≈永不超时), * 此常量仅作未注入时的兜底默认,保持历史行为与测试注入口径一致。 */ export const ASK_USER_QUESTION_TIMEOUT_MS = 3 * 60 * 1000; +/** 交互式应答超时(分钟)的默认值与上限,与桌面端 Rust 归一化同口径。 + * 上限存在的意义不是规避 setTimeout 溢出(那由分段续期解决), + * 而是避免手改配置写入荒谬值。 */ +export const INTERACTIVE_TIMEOUT_MIN_MINUTES = 1; +export const INTERACTIVE_TIMEOUT_MAX_MINUTES = 99_999; +/** setTimeout 的延迟形参是 IDL long(32 位有符号),超过 2^31-1 会被 ToInt32 + * 回绕:99999 分钟被截成约 19.7 天,而 35792~200000 分钟之间的值回绕成负数后 + * 被钳为 0,导致「永不超时」瞬间立刻超时。故长延迟必须分段续期。 */ +export const MAX_SAFE_TIMEOUT_DELAY_MS = 2_147_483_647; + +/** + * 分段续期的长延迟定时器:单次 setTimeout 只排最多 MAX_SAFE_TIMEOUT_DELAY_MS, + * 到点后按剩余时间继续排,直到真正跨过 deadline 才触发 onDeadline。返回取消函数。 + * + * 与裸 setTimeout 一致,回调恒异步触发(deadline 已过也先让出一轮), + * 这样调用方仍可在同一同步块里先拿到取消函数再在回调中引用它。 + */ +export function scheduleAtDeadline(deadlineAt: number, onDeadline: () => void): () => void { + let timerId: ReturnType | undefined; + let cancelled = false; + const arm = () => { + if (cancelled) return; + const remainingMs = deadlineAt - Date.now(); + if (remainingMs <= 0) { + onDeadline(); + return; + } + timerId = setTimeout(arm, Math.min(remainingMs, MAX_SAFE_TIMEOUT_DELAY_MS)); + }; + // 首拍也走 setTimeout,保证回调不会在本函数返回前同步触发。 + timerId = setTimeout( + arm, + Math.min(Math.max(0, deadlineAt - Date.now()), MAX_SAFE_TIMEOUT_DELAY_MS), + ); + return () => { + cancelled = true; + if (timerId !== undefined) clearTimeout(timerId); + }; +} + /** UI 合成"其他(自行输入)"应答的最大长度;超出部分截断。 */ export const ASK_USER_QUESTION_CUSTOM_MAX_LENGTH = 2000; /** diff --git a/crates/agent-gui/src/lib/tools/askUserQuestionTools.ts b/crates/agent-gui/src/lib/tools/askUserQuestionTools.ts index a47cfbbfa..a6377af99 100644 --- a/crates/agent-gui/src/lib/tools/askUserQuestionTools.ts +++ b/crates/agent-gui/src/lib/tools/askUserQuestionTools.ts @@ -13,6 +13,7 @@ import { buildDefaultAskUserQuestionAnswers, parseAskUserQuestionItems, resolveAskUserQuestionAnswers, + scheduleAtDeadline, } from "../chat/askUserQuestion"; import { type BuiltinToolBundle, createBuiltinMetadataMap } from "./builtinTypes"; @@ -229,13 +230,13 @@ export function createAskUserQuestionTools(params: { const settle = (value: AskUserQuestionSettlement) => { pendingByToolCallId.delete(toolCall.id); signal?.removeEventListener("abort", onAbort); - clearTimeout(timeoutId); + cancelTimeout(); resolve(value); }; const onAbort = () => settle({ kind: "cancelled" }); - const timeoutId = setTimeout( - () => settle({ kind: "timeout", answers: buildDefaultAskUserQuestionAnswers(questions) }), - Math.max(0, deadlineAt - Date.now()), + // 分段续期计时:超长窗口(≈永不超时)不会被 setTimeout 的 32 位延迟上限回绕。 + const cancelTimeout = scheduleAtDeadline(deadlineAt, () => + settle({ kind: "timeout", answers: buildDefaultAskUserQuestionAnswers(questions) }), ); pendingByToolCallId.set(toolCall.id, { conversationId: params.conversationId, diff --git a/crates/agent-gui/src/lib/tools/toolApproval.ts b/crates/agent-gui/src/lib/tools/toolApproval.ts index 78ae076d6..b9864890f 100644 --- a/crates/agent-gui/src/lib/tools/toolApproval.ts +++ b/crates/agent-gui/src/lib/tools/toolApproval.ts @@ -7,7 +7,7 @@ // 远端(WebUI)应答经 gateway chat_queue.tool_approval 转发到桌面后走同一入口 // answerToolApproval(第 3 步接线)。 -import { ASK_USER_QUESTION_TIMEOUT_MS } from "../chat/askUserQuestion"; +import { ASK_USER_QUESTION_TIMEOUT_MS, scheduleAtDeadline } from "../chat/askUserQuestion"; /** 审批窗口毫秒数:复用 AskUserQuestion 的时长常量,行为口径一致。 */ export const TOOL_APPROVAL_TIMEOUT_MS = ASK_USER_QUESTION_TIMEOUT_MS; @@ -178,7 +178,7 @@ export function requestToolApproval(params: { pendingByToolCallId.delete(toolCallId); } params.signal?.removeEventListener("abort", onAbort); - clearTimeout(timeoutId); + cancelTimeout(); if (settlement.kind === "decided" && settlement.decision === "approve_session") { rememberSessionApproval(params.conversationId, params.toolName); } @@ -186,7 +186,9 @@ export function requestToolApproval(params: { resolve(settlement); }; const onAbort = () => settle({ kind: "cancelled" }); - const timeoutId = setTimeout(() => settle({ kind: "timeout" }), Math.max(0, timeoutMs)); + // 分段续期定时器:审批窗口可配到远超 setTimeout 的 32 位延迟上限, + // 直接传大延迟会被静默截断(甚至立即触发)。见 scheduleAtDeadline。 + const cancelTimeout = scheduleAtDeadline(deadlineAt, () => settle({ kind: "timeout" })); const pending: PendingToolApproval = { conversationId: params.conversationId, toolName: params.toolName, diff --git a/crates/agent-gui/test/tools/ask-user-question-tools.test.mjs b/crates/agent-gui/test/tools/ask-user-question-tools.test.mjs index d196b52b9..fb4e73f83 100644 --- a/crates/agent-gui/test/tools/ask-user-question-tools.test.mjs +++ b/crates/agent-gui/test/tools/ask-user-question-tools.test.mjs @@ -290,6 +290,81 @@ test("a very long timeout window behaves like never within the test window", asy assert.equal(tools.hasPendingAskUserQuestion("call-ask-long-window"), false); }); +// 回归:最大档 99999 分钟换算成毫秒后超过 setTimeout 的 32 位延迟上限, +// 裸 setTimeout 会 ToInt32 回绕(99999 分钟被截成约 19.7 天,35792~200000 +// 分钟之间更会回绕成负数并被钳为 0 → 「永不超时」瞬间立刻超时)。 +// scheduleAtDeadline 分段续期后,deadline 必须仍是完整窗口且不会提前落定。 +test("the largest configurable window is not truncated by the 32-bit setTimeout limit", async () => { + const { shared, tools } = loadModules(); + const timeoutMs = shared.INTERACTIVE_TIMEOUT_MAX_MINUTES * 60_000; + assert.ok(timeoutMs > shared.MAX_SAFE_TIMEOUT_DELAY_MS, "该用例必须跨过 32 位上限才有意义"); + + const bundle = tools.createAskUserQuestionTools({ + conversationId: "conv-max-window", + timeoutMs, + }); + const toolCall = createToolCall(buildQuestionsArgs(), "call-ask-max-window"); + + const startedAt = Date.now(); + const resultPromise = bundle.executeToolCall(toolCall); + const deadline = tools.getAskUserQuestionDeadlineAt("call-ask-max-window"); + // deadline 记的是完整窗口,而不是回绕后的约 19.7 天。 + assert.ok(deadline >= startedAt + timeoutMs - 1000, "deadline 必须是完整窗口"); + + await new Promise((resolve) => setTimeout(resolve, 60)); + assert.equal(tools.hasPendingAskUserQuestion("call-ask-max-window"), true); + + tools.answerAskUserQuestion("call-ask-max-window", [ + { questionId: "storage", selectedLabel: "应用数据目录" }, + { questionId: "q2", selectedLabel: "不迁移" }, + ]); + const result = await resultPromise; + assert.equal(result.isError, false); + assert.equal(result.details.timedOut, undefined); +}); + +// scheduleAtDeadline 自身语义:分段续期、可取消、回调恒异步。 +test("scheduleAtDeadline caps each hop, stays cancellable, and fires asynchronously", async () => { + const { shared } = loadModules(); + + // 1) 已过期的 deadline 也不同步触发(调用方要能先拿到取消函数)。 + let firedSync = true; + const cancelPast = shared.scheduleAtDeadline(Date.now() - 10_000, () => { + firedSync = false; + }); + assert.equal(firedSync, true, "回调不得在 scheduleAtDeadline 返回前同步触发"); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(firedSync, false, "已过期的 deadline 应在下一拍触发"); + cancelPast(); + + // 2) 短 deadline 正常触发。 + const firedAt = await new Promise((resolve) => { + const t0 = Date.now(); + shared.scheduleAtDeadline(t0 + 30, () => resolve(Date.now() - t0)); + }); + assert.ok(firedAt >= 25, "应等到 deadline 才触发,实际 " + firedAt + "ms"); + + // 3) 取消后不再触发。 + let cancelledFired = false; + const cancel = shared.scheduleAtDeadline(Date.now() + 20, () => { + cancelledFired = true; + }); + cancel(); + await new Promise((resolve) => setTimeout(resolve, 60)); + assert.equal(cancelledFired, false, "取消后不得触发"); + + // 4) 超长 deadline 不会因回绕而立即触发(裸 setTimeout 在此会立刻落定)。 + const overflowMinutes = 40_000; // ToInt32 后为负数的区间 + assert.ok(((overflowMinutes * 60_000) | 0) < 0, "该分钟数必须落在回绕成负数的区间"); + let overflowFired = false; + const cancelOverflow = shared.scheduleAtDeadline(Date.now() + overflowMinutes * 60_000, () => { + overflowFired = true; + }); + await new Promise((resolve) => setTimeout(resolve, 60)); + assert.equal(overflowFired, false, "超长窗口不得因 32 位回绕而立即触发"); + cancelOverflow(); +}); + test("immediate answers are pending synchronously and never fall through to timeout defaults", async () => { const { tools } = loadModules(); const bundle = tools.createAskUserQuestionTools({ conversationId: "conv-fast", timeoutMs: 100 }); diff --git a/crates/agent-gui/test/tools/tool-approval.test.mjs b/crates/agent-gui/test/tools/tool-approval.test.mjs index bd0e36e0f..db75fc31e 100644 --- a/crates/agent-gui/test/tools/tool-approval.test.mjs +++ b/crates/agent-gui/test/tools/tool-approval.test.mjs @@ -83,6 +83,28 @@ test("很长的超时窗口 ≈ 永不:挂起等待用户决定,不在测试窗 assert.equal(hasPendingToolApproval("c-long-window"), false); }); +// 回归:最大档 99999 分钟换算成毫秒后远超 setTimeout 的 32 位延迟上限, +// 直接传入会被 ToInt32 回绕(35792~200000 分钟区间甚至回绕成负数→立即触发), +// 「永不超时」会瞬间变成秒拒。分段续期后不得在测试窗口内落定。 +test("最大档窗口不被 setTimeout 32 位延迟上限回绕成立即超时", async () => { + const promise = requestToolApproval({ + toolCallId: "c-max-window", + toolName: "Bash", + conversationId: "conv-max-window", + timeoutMs: 99_999 * 60_000, + }); + assert.equal(hasPendingToolApproval("c-max-window"), true); + await new Promise((resolve) => setTimeout(resolve, 60)); + assert.equal( + hasPendingToolApproval("c-max-window"), + true, + "最大档窗口不得因延迟回绕而立即落定为 timeout", + ); + answerToolApproval("c-max-window", "approve"); + assert.deepEqual(await promise, { kind: "decided", decision: "approve" }); + assert.equal(hasPendingToolApproval("c-max-window"), false); +}); + test("AbortSignal 触发 → cancelled;已 aborted 的信号立即 cancelled", async () => { const controller = new AbortController(); const promise = requestToolApproval({ From c2731a95eb69ca5e3ba7a5c21187cfb74bc66052 Mon Sep 17 00:00:00 2001 From: devil233-ui Date: Thu, 6 Aug 2026 12:17:50 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=A7=B9=20fix(chat):=20bound=20countdo?= =?UTF-8?q?wn=20trust=20window=20by=20max=20configurable=20timeout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原 PR 把 deadline 上界校验整个取消,远端浏览器时钟偏移时会显示 荒谬长倒计时,且把回归写进断言。上界改为最大可配置窗口 (99999 分钟):长窗口仍被采信,超出视为时钟不可比回退挂载近似。 同时两端 SystemToolsSection 过 biome 格式化(lint 从红转绿, CI 不再失败)。补超上限不采信测试。 --- .../components/chat/AskUserQuestionCard.tsx | 16 ++++++++----- .../src/pages/settings/SystemToolsSection.tsx | 8 +++---- .../components/chat/AskUserQuestionCard.tsx | 16 ++++++++----- .../src/pages/settings/SystemToolsSection.tsx | 8 +++---- .../test/chat/ask-user-question-card.test.mjs | 23 ++++++++++++++++--- 5 files changed, 48 insertions(+), 23 deletions(-) diff --git a/crates/agent-gateway/web/src/components/chat/AskUserQuestionCard.tsx b/crates/agent-gateway/web/src/components/chat/AskUserQuestionCard.tsx index f21f99264..38d5b3473 100644 --- a/crates/agent-gateway/web/src/components/chat/AskUserQuestionCard.tsx +++ b/crates/agent-gateway/web/src/components/chat/AskUserQuestionCard.tsx @@ -10,6 +10,7 @@ import { ASK_USER_QUESTION_TIMEOUT_MS, type AskUserQuestionAnswer, type AskUserQuestionItem, + INTERACTIVE_TIMEOUT_MAX_MINUTES, } from "../../lib/chat/askUserQuestion"; import { cn } from "../../lib/shared/utils"; import { Check, Sparkles } from "../icons"; @@ -25,18 +26,21 @@ function formatCountdown(remainingMs: number) { /** * 倒计时提示:优先使用调用方传入的权威截止时间(GUI 读工具挂起表,WebUI 读 - * 网关参数上的 deadline 盖章),两端与桌面计时同源。超长窗口(≈永不超时)时 - * 显示很长倒计时;截止时间缺失/已过期时回退为挂载时刻的默认窗口近似,避免把 - * 可作答的卡片锁死。倒计时归零立即禁止交互,随后 tool_result 把卡片切到只读态。 + * 网关参数上的 deadline 盖章),两端与桌面计时同源。应答窗口可由设置调到很长 + * (≈永不超时),因此上界按「可配置的最大窗口」判定而不是默认窗口;截止时间 + * 缺失/已过期/明显不可比时回退为挂载时刻近似,避免把可作答的卡片锁死。 + * 倒计时归零立即禁止交互,随后 tool_result 把卡片切到只读态。 * * 盖章用的是桌面时钟,而倒计时读本机时钟:远端浏览器时钟偏移足够大时,一个 - * 仍在挂起的提问会在挂载瞬间显示过期。仅采信“挂载时刻之后”的截止时间;真正 - * 过期的提交仍由桌面挂起表权威拒绝。 + * 仍在挂起的提问会在挂载瞬间显示过期(或显示远超任何合法窗口的剩余时间)。 + * 因此仅采信落在“挂载时刻(不含)~挂载时刻 + 最大可配置窗口(含)”内的截止 + * 时间,否则视为时钟不可比;真正过期的提交仍由桌面挂起表权威拒绝。 */ function useAnswerCountdown(active: boolean, deadlineAt?: number) { const [mountedAt] = useState(() => Date.now()); + const maxTrustedDeadline = mountedAt + INTERACTIVE_TIMEOUT_MAX_MINUTES * 60_000; const deadline = - deadlineAt !== undefined && deadlineAt > mountedAt + deadlineAt !== undefined && deadlineAt > mountedAt && deadlineAt <= maxTrustedDeadline ? deadlineAt : mountedAt + ASK_USER_QUESTION_TIMEOUT_MS; const [remainingMs, setRemainingMs] = useState(() => deadline - Date.now()); diff --git a/crates/agent-gateway/web/src/pages/settings/SystemToolsSection.tsx b/crates/agent-gateway/web/src/pages/settings/SystemToolsSection.tsx index 156442bb7..435ab339a 100644 --- a/crates/agent-gateway/web/src/pages/settings/SystemToolsSection.tsx +++ b/crates/agent-gateway/web/src/pages/settings/SystemToolsSection.tsx @@ -14,9 +14,7 @@ import { BUILTIN_TOOL_CATALOG, BUILTIN_TOOL_CATEGORIES } from "../../lib/tools/b import type { SettingsSectionProps } from "./types"; // 档位表:60 分钟内细调,超过 1 小时直跳最大档 99999。 -const TIMEOUT_STOPS = [ - 1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 25, 30, 40, 50, 60, 99999, -]; +const TIMEOUT_STOPS = [1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 25, 30, 40, 50, 60, 99999]; export function SystemToolsSection(props: SettingsSectionProps) { const { settings, setSettings } = props; @@ -126,7 +124,9 @@ export function SystemToolsSection(props: SettingsSectionProps) { step={1} value={timeoutStopIndex} aria-label={t("settings.interactiveTimeout.title")} - onChange={(event) => onTimeoutStopChange(Number(event.currentTarget.value))} + onChange={(event) => + onTimeoutStopChange(Number(event.currentTarget.value)) + } className="w-full accent-primary" />
diff --git a/crates/agent-gui/src/components/chat/AskUserQuestionCard.tsx b/crates/agent-gui/src/components/chat/AskUserQuestionCard.tsx index f21f99264..38d5b3473 100644 --- a/crates/agent-gui/src/components/chat/AskUserQuestionCard.tsx +++ b/crates/agent-gui/src/components/chat/AskUserQuestionCard.tsx @@ -10,6 +10,7 @@ import { ASK_USER_QUESTION_TIMEOUT_MS, type AskUserQuestionAnswer, type AskUserQuestionItem, + INTERACTIVE_TIMEOUT_MAX_MINUTES, } from "../../lib/chat/askUserQuestion"; import { cn } from "../../lib/shared/utils"; import { Check, Sparkles } from "../icons"; @@ -25,18 +26,21 @@ function formatCountdown(remainingMs: number) { /** * 倒计时提示:优先使用调用方传入的权威截止时间(GUI 读工具挂起表,WebUI 读 - * 网关参数上的 deadline 盖章),两端与桌面计时同源。超长窗口(≈永不超时)时 - * 显示很长倒计时;截止时间缺失/已过期时回退为挂载时刻的默认窗口近似,避免把 - * 可作答的卡片锁死。倒计时归零立即禁止交互,随后 tool_result 把卡片切到只读态。 + * 网关参数上的 deadline 盖章),两端与桌面计时同源。应答窗口可由设置调到很长 + * (≈永不超时),因此上界按「可配置的最大窗口」判定而不是默认窗口;截止时间 + * 缺失/已过期/明显不可比时回退为挂载时刻近似,避免把可作答的卡片锁死。 + * 倒计时归零立即禁止交互,随后 tool_result 把卡片切到只读态。 * * 盖章用的是桌面时钟,而倒计时读本机时钟:远端浏览器时钟偏移足够大时,一个 - * 仍在挂起的提问会在挂载瞬间显示过期。仅采信“挂载时刻之后”的截止时间;真正 - * 过期的提交仍由桌面挂起表权威拒绝。 + * 仍在挂起的提问会在挂载瞬间显示过期(或显示远超任何合法窗口的剩余时间)。 + * 因此仅采信落在“挂载时刻(不含)~挂载时刻 + 最大可配置窗口(含)”内的截止 + * 时间,否则视为时钟不可比;真正过期的提交仍由桌面挂起表权威拒绝。 */ function useAnswerCountdown(active: boolean, deadlineAt?: number) { const [mountedAt] = useState(() => Date.now()); + const maxTrustedDeadline = mountedAt + INTERACTIVE_TIMEOUT_MAX_MINUTES * 60_000; const deadline = - deadlineAt !== undefined && deadlineAt > mountedAt + deadlineAt !== undefined && deadlineAt > mountedAt && deadlineAt <= maxTrustedDeadline ? deadlineAt : mountedAt + ASK_USER_QUESTION_TIMEOUT_MS; const [remainingMs, setRemainingMs] = useState(() => deadline - Date.now()); diff --git a/crates/agent-gui/src/pages/settings/SystemToolsSection.tsx b/crates/agent-gui/src/pages/settings/SystemToolsSection.tsx index 156442bb7..435ab339a 100644 --- a/crates/agent-gui/src/pages/settings/SystemToolsSection.tsx +++ b/crates/agent-gui/src/pages/settings/SystemToolsSection.tsx @@ -14,9 +14,7 @@ import { BUILTIN_TOOL_CATALOG, BUILTIN_TOOL_CATEGORIES } from "../../lib/tools/b import type { SettingsSectionProps } from "./types"; // 档位表:60 分钟内细调,超过 1 小时直跳最大档 99999。 -const TIMEOUT_STOPS = [ - 1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 25, 30, 40, 50, 60, 99999, -]; +const TIMEOUT_STOPS = [1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 25, 30, 40, 50, 60, 99999]; export function SystemToolsSection(props: SettingsSectionProps) { const { settings, setSettings } = props; @@ -126,7 +124,9 @@ export function SystemToolsSection(props: SettingsSectionProps) { step={1} value={timeoutStopIndex} aria-label={t("settings.interactiveTimeout.title")} - onChange={(event) => onTimeoutStopChange(Number(event.currentTarget.value))} + onChange={(event) => + onTimeoutStopChange(Number(event.currentTarget.value)) + } className="w-full accent-primary" />
diff --git a/crates/agent-gui/test/chat/ask-user-question-card.test.mjs b/crates/agent-gui/test/chat/ask-user-question-card.test.mjs index c5f524567..ae1e6d5b0 100644 --- a/crates/agent-gui/test/chat/ask-user-question-card.test.mjs +++ b/crates/agent-gui/test/chat/ask-user-question-card.test.mjs @@ -8,9 +8,8 @@ const i18nPath = fileURLToPath(new URL("../../src/i18n/index.ts", import.meta.ur const iconsPath = fileURLToPath(new URL("../../src/components/icons/index.ts", import.meta.url)); const utilsPath = fileURLToPath(new URL("../../src/lib/shared/utils.ts", import.meta.url)); -const { ASK_USER_QUESTION_TIMEOUT_MS } = createTsModuleLoader().loadModule( - "src/lib/chat/askUserQuestion.ts", -); +const { ASK_USER_QUESTION_TIMEOUT_MS, INTERACTIVE_TIMEOUT_MAX_MINUTES } = + createTsModuleLoader().loadModule("src/lib/chat/askUserQuestion.ts"); const questions = [ { @@ -252,6 +251,24 @@ test("a far-future deadline (long timeout window) is trusted and counts down to assert.match(treeText(tree), /(?:8:00|7:59) chat\.askUser\.timeoutHint/); }); +test("a deadline beyond the largest configurable window is distrusted as clock skew", () => { + const card = createCardHarness(); + const tree = card.render({ + // 本机时钟慢于桌面盖章时钟:截止时间超出「最大可配置窗口」,视为时钟不可比。 + // 上界随配置上限而非默认窗口,长窗口(≈永不超时)本身仍被采信(见上一条)。 + deadlineAt: Date.now() + (INTERACTIVE_TIMEOUT_MAX_MINUTES + 1) * 60_000, + onSubmit: async () => ({ ok: true }), + }); + + const optionButtons = findAll( + tree, + (node) => node.type === "button" && node.props?.role === "radio", + ); + // 卡片仍可作答,倒计时回退为挂载时刻的默认窗口近似,而不是把偏移量当剩余时间。 + assert.equal(optionButtons.every((button) => button.props.disabled === false), true); + assert.match(treeText(tree), /(?:3:00|2:59) chat\.askUser\.timeoutHint/); +}); + test("a complete answer before the deadline submits the selected non-first option", async () => { const submitted = []; const card = createCardHarness({ draftSelections: { choice: "Second" } }); From eabcae6756021ca3d28c413dc1d1748b9bf17c9c Mon Sep 17 00:00:00 2001 From: devil233-ui Date: Thu, 6 Aug 2026 13:11:28 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=93=B8=20docs(pr386):=20add=20screens?= =?UTF-8?q?hot=20for=20interactive=20timeout=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/images/pr386-interactive-timeout.png | Bin 0 -> 63740 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/images/pr386-interactive-timeout.png diff --git a/docs/images/pr386-interactive-timeout.png b/docs/images/pr386-interactive-timeout.png new file mode 100644 index 0000000000000000000000000000000000000000..9035581d9c23e8cf2ad0faa57a027f08da40f802 GIT binary patch literal 63740 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 YR&maTn0i@1@Cm|G85QXYN#nr(0||DdBLDyZ literal 0 HcmV?d00001