Skip to content

Commit 3de56be

Browse files
committed
fix(聊天): 提问卡截止时间容忍远端时钟偏移
倒计时归零硬禁用交互后,WebUI 的截止时间(桌面时钟盖章的绝对毫秒) 与本机 Date.now() 跨机器比较:浏览器时钟偏移超界会把仍在挂起的提问 卡一挂载就锁死。现仅当截止时间落在"挂载时刻~挂载时刻+完整应答窗口" 内才采信,否则回退挂载近似;过期提交仍由桌面挂起表权威拒绝。
1 parent 5f4bfe1 commit 3de56be

3 files changed

Lines changed: 89 additions & 5 deletions

File tree

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,21 @@ function formatCountdown(remainingMs: number) {
2828
* 网关参数上的 deadline 盖章),两端与桌面计时同源;缺失时(历史/降级数据)
2929
* 回退为挂载时刻近似。倒计时归零立即禁止交互,随后 tool_result 把卡片
3030
* 切到只读态。
31+
*
32+
* 盖章用的是桌面时钟,而倒计时读本机时钟:远端浏览器时钟偏移足够大时,
33+
* 一个仍在挂起的提问会在挂载瞬间就显示过期(或远超完整窗口)。因此仅当
34+
* 截止时间落在“挂载时刻(不含)~挂载时刻 + 完整应答窗口(含)”内才采信,
35+
* 否则视为时钟不可比、回退挂载近似,避免把可作答的卡片锁死;真正过期的
36+
* 提交仍由桌面挂起表权威拒绝。
3137
*/
3238
function useAnswerCountdown(active: boolean, deadlineAt?: number) {
33-
const [fallbackDeadline] = useState(() => Date.now() + ASK_USER_QUESTION_TIMEOUT_MS);
34-
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;
3546
const [remainingMs, setRemainingMs] = useState(() => deadline - Date.now());
3647

3748
useEffect(() => {

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,21 @@ function formatCountdown(remainingMs: number) {
2828
* 网关参数上的 deadline 盖章),两端与桌面计时同源;缺失时(历史/降级数据)
2929
* 回退为挂载时刻近似。倒计时归零立即禁止交互,随后 tool_result 把卡片
3030
* 切到只读态。
31+
*
32+
* 盖章用的是桌面时钟,而倒计时读本机时钟:远端浏览器时钟偏移足够大时,
33+
* 一个仍在挂起的提问会在挂载瞬间就显示过期(或远超完整窗口)。因此仅当
34+
* 截止时间落在“挂载时刻(不含)~挂载时刻 + 完整应答窗口(含)”内才采信,
35+
* 否则视为时钟不可比、回退挂载近似,避免把可作答的卡片锁死;真正过期的
36+
* 提交仍由桌面挂起表权威拒绝。
3137
*/
3238
function useAnswerCountdown(active: boolean, deadlineAt?: number) {
33-
const [fallbackDeadline] = useState(() => Date.now() + ASK_USER_QUESTION_TIMEOUT_MS);
34-
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;
3546
const [remainingMs, setRemainingMs] = useState(() => deadline - Date.now());
3647

3748
useEffect(() => {

crates/agent-gui/test/chat/ask-user-question-card.test.mjs

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ const i18nPath = fileURLToPath(new URL("../../src/i18n/index.ts", import.meta.ur
88
const iconsPath = fileURLToPath(new URL("../../src/components/icons/index.ts", import.meta.url));
99
const utilsPath = fileURLToPath(new URL("../../src/lib/shared/utils.ts", import.meta.url));
1010

11+
const { ASK_USER_QUESTION_TIMEOUT_MS } = createTsModuleLoader().loadModule(
12+
"src/lib/chat/askUserQuestion.ts",
13+
);
14+
1115
const questions = [
1216
{
1317
id: "choice",
@@ -30,6 +34,11 @@ function createHookHarness(initialState = {}) {
3034
[4, initialState.customTexts ?? {}],
3135
[5, initialState.submitting ?? false],
3236
]);
37+
// useAnswerCountdown 的 remainingMs(挂载后由 interval tick 驱动);
38+
// 测试用它模拟“采信的截止时间随后归零”。
39+
if (initialState.remainingMs !== undefined) {
40+
stateOverrides.set(8, initialState.remainingMs);
41+
}
3342

3443
const react = {
3544
useState(initialValue) {
@@ -146,9 +155,11 @@ test("expired countdown disables options, custom input, and submit before tool_r
146155
const card = createCardHarness({
147156
customSelected: { choice: true },
148157
customTexts: { choice: "My answer" },
158+
// 采信的截止时间(挂载时仍在窗口内)随 interval tick 归零。
159+
remainingMs: 0,
149160
});
150161
const tree = card.render({
151-
deadlineAt: Date.now() - 1,
162+
deadlineAt: Date.now() + 60_000,
152163
onSubmit: async () => {
153164
submitCalls += 1;
154165
return { ok: true };
@@ -191,6 +202,57 @@ test("expired countdown disables options, custom input, and submit before tool_r
191202
assert.equal(submitCalls, 0);
192203
});
193204

205+
// 截止时间由桌面时钟盖章、倒计时读本机时钟:偏移超界时必须回退挂载近似,
206+
// 不能把仍在挂起的提问卡一挂载就锁死(过期提交由桌面挂起表权威拒绝)。
207+
test("a deadline already past at mount is distrusted and the pending card stays answerable", async () => {
208+
const submitted = [];
209+
const card = createCardHarness({ draftSelections: { choice: "Second" } });
210+
const tree = card.render({
211+
// 本机时钟快于桌面盖章时钟:卡片挂载时截止时间看似早已过去。
212+
deadlineAt: Date.now() - 5 * 60 * 1000,
213+
onSubmit: async (answers) => {
214+
submitted.push(answers);
215+
return { ok: true };
216+
},
217+
});
218+
219+
const optionButtons = findAll(
220+
tree,
221+
(node) => node.type === "button" && node.props?.role === "radio",
222+
);
223+
assert.equal(optionButtons.length, 2);
224+
assert.equal(optionButtons.every((button) => button.props.disabled === false), true);
225+
const customOption = findAll(
226+
tree,
227+
(node) => node.type === "div" && node.props?.role === "radio",
228+
)[0];
229+
assert.equal(customOption.props["aria-disabled"], false);
230+
231+
const submitButton = findSubmitButton(tree);
232+
assert.equal(submitButton.props.disabled, false);
233+
submitButton.props.onClick();
234+
await flushPromises();
235+
assert.equal(submitted.length, 1);
236+
assert.equal(submitted[0][0].selectedLabel, "Second");
237+
});
238+
239+
test("a deadline beyond the full answer window is distrusted and clamps the countdown", () => {
240+
const card = createCardHarness();
241+
const tree = card.render({
242+
// 本机时钟慢于桌面盖章时钟:截止时间看似远超完整应答窗口。
243+
deadlineAt: Date.now() + ASK_USER_QUESTION_TIMEOUT_MS + 5 * 60 * 1000,
244+
onSubmit: async () => ({ ok: true }),
245+
});
246+
247+
const optionButtons = findAll(
248+
tree,
249+
(node) => node.type === "button" && node.props?.role === "radio",
250+
);
251+
assert.equal(optionButtons.every((button) => button.props.disabled === false), true);
252+
// 倒计时按挂载近似显示完整窗口,而不是把偏移量当剩余时间。
253+
assert.match(treeText(tree), /(?:3:00|2:59) chat\.askUser\.timeoutHint/);
254+
});
255+
194256
test("a complete answer before the deadline submits the selected non-first option", async () => {
195257
const submitted = [];
196258
const card = createCardHarness({ draftSelections: { choice: "Second" } });

0 commit comments

Comments
 (0)