From a950f7a97b44681011a4d085f432c11544556e8f Mon Sep 17 00:00:00 2001 From: Lulu <58587930+lulu-sk@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:10:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(codex):=20=E9=81=BF=E5=85=8D=E7=BB=88?= =?UTF-8?q?=E7=AB=AF=E7=BC=A9=E6=94=BE=E9=87=8D=E5=A4=8D=E5=A4=84=E7=90=86?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex TUI 在 PTY resize 后会重绘可见历史,已处理的重连或最终错误 可能再次进入输出扫描,导致重复通知、计时状态回退或重复自动继续。 最后一次重连结束后的 stream disconnected 历史行也需要明确落为最终失败。 - 将带历史标记的 stream disconnected 识别为明确最终错误 - 按 PTY 记录已处理错误,只在缩放重绘窗口内屏蔽同一错误 - 用户主动发起新任务时清空旧记录,避免误伤真实同类错误 - 在 PTY 关闭时清理历史,并限制记录容量 - 补充错误分类、缩放窗口和回放去重回归测试 验证: - npm run test(205 个测试文件,1279 项测试) - npm run build:web - npm run i18n:check - npm run i18n:report Signed-off-by: Lulu <58587930+lulu-sk@users.noreply.github.com> --- web/src/App.tsx | 77 +++++++++++-- web/src/lib/TerminalManager.ts | 19 ++++ .../lib/codex-cli-error-classifier.test.ts | 17 +++ web/src/lib/codex-cli-error-classifier.ts | 2 +- web/src/lib/codex-error-replay-guard.test.ts | 101 ++++++++++++++++++ web/src/lib/codex-error-replay-guard.ts | 50 +++++++++ .../terminal-manager-resize-replay.test.ts | 51 +++++++++ 7 files changed, 308 insertions(+), 9 deletions(-) create mode 100644 web/src/lib/codex-error-replay-guard.test.ts create mode 100644 web/src/lib/codex-error-replay-guard.ts create mode 100644 web/src/lib/terminal-manager-resize-replay.test.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index 3e893c1..952a7ed 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -79,6 +79,10 @@ import { type CodexCliErrorClassification, type CodexCliErrorKind, } from "@/lib/codex-cli-error-classifier"; +import { + rememberCodexErrorReplayKey, + shouldSuppressCodexErrorResizeReplay, +} from "@/lib/codex-error-replay-guard"; import { DEFAULT_CODEX_ERROR_HANDLING_PREFS, isCodexAutoContinueErrorKind, @@ -1807,6 +1811,7 @@ export default function CodexFlowManagerUI() { const resumeCompletionGuardByTabRef = useRef>({}); const ptyNotificationBuffersRef = useRef>({}); const codexErrorScanByTabRef = useRef>({}); + const codexHandledErrorKeysByPtyRef = useRef>({}); const ptyListenersRef = useRef void>>({}); const ptyToTabRef = useRef>({}); const hydratedPtyBindingQueueRef = useRef>([]); @@ -1910,6 +1915,7 @@ export default function CodexFlowManagerUI() { } } codexErrorScanByTabRef.current = {}; + codexHandledErrorKeysByPtyRef.current = {}; if (hydratedPtyBindingTimerRef.current !== null) { try { window.clearTimeout(hydratedPtyBindingTimerRef.current); } catch {} hydratedPtyBindingTimerRef.current = null; @@ -2808,11 +2814,16 @@ export default function CodexFlowManagerUI() { }, [notifyLog]); /** - * 重置指定标签页的 Codex 错误扫描状态,可选择保留自动 continue 尝试次数。 + * 重置指定标签页的 Codex 错误扫描状态;用户主动发送时同时开启新的错误回放边界。 */ const resetCodexErrorScanForTab = useCallback((tabId: string, source: string, options?: { keepAttempts?: boolean }): void => { const id = String(tabId || "").trim(); if (!id) return; + if (source === "user-send") { + const ptyId = ptyByTabRef.current[id]; + if (ptyId) + delete codexHandledErrorKeysByPtyRef.current[ptyId]; + } const state = codexErrorScanByTabRef.current[id]; if (state && typeof state.autoContinueTimerId === "number") { try { window.clearTimeout(state.autoContinueTimerId); } catch {} @@ -4139,12 +4150,44 @@ export default function CodexFlowManagerUI() { void playCompletionChime(); } + /** + * 记录 PTY 已经实际处理过的 Codex 错误,供后续缩放重绘去重。 + */ + function rememberHandledCodexError(ptyId: string, classification: CodexCliErrorClassification): void { + const id = String(ptyId || "").trim(); + if (!id) return; + codexHandledErrorKeysByPtyRef.current[id] = rememberCodexErrorReplayKey( + codexHandledErrorKeysByPtyRef.current[id], + classification, + ); + } + + /** + * 判断当前识别结果是否是 PTY 缩放重绘产生的已处理错误回放。 + */ + function shouldSuppressCodexResizeReplay( + tabId: string, + ptyId: string, + classification: CodexCliErrorClassification, + ): boolean { + const suppressed = shouldSuppressCodexErrorResizeReplay({ + resizeReplayActive: tm.isPtyResizeReplayWindowActive(tabId), + handledErrorKeys: codexHandledErrorKeysByPtyRef.current[ptyId], + classification, + }); + if (suppressed) + notifyLog(`codexError.resizeReplay.suppress tab=${tabId} pty=${ptyId} kind=${classification.kind}`); + return suppressed; + } + /** * 处理已识别的 Codex CLI 错误,统一落计时失败、通知用户并按设置安排自动 continue。 */ function handleCodexCliErrorDetected(tabId: string, classification: CodexCliErrorClassification, errorKey: string) { const id = String(tabId || "").trim(); if (!id) return; + const ptyId = ptyByTabRef.current[id]; + if (ptyId) rememberHandledCodexError(ptyId, classification); const autoContinue = scheduleCodexAutoContinue(id, classification, errorKey); failAgentTurnTimer(id, classification, autoContinue); @@ -4185,6 +4228,8 @@ export default function CodexFlowManagerUI() { function handleCodexCliReconnectDetected(tabId: string, classification: CodexCliErrorClassification) { const id = String(tabId || "").trim(); if (!id) return; + const ptyId = ptyByTabRef.current[id]; + if (ptyId) rememberHandledCodexError(ptyId, classification); markAgentTurnReconnecting(id, classification); if (!codexErrorHandlingPrefsRef.current.notifyReconnectErrors) { notifyLog(`codexError.reconnecting tab=${id} kind=${classification.kind} notify=0`); @@ -4280,6 +4325,8 @@ export default function CodexFlowManagerUI() { const chunkRuntimeStatus = detectCodexCliRuntimeStatusText(cleanedChunk); if (chunkRuntimeStatus?.phase === "idle") { const idleClassification = classifyCodexCliErrorText(cleanedChunk); + if (idleClassification?.phase === "final" && shouldSuppressCodexResizeReplay(tabId, ptyId, idleClassification)) + return; if (typeof previous.pendingFinalErrorTimerId === "number") { try { window.clearTimeout(previous.pendingFinalErrorTimerId); } catch {} } @@ -4362,6 +4409,8 @@ export default function CodexFlowManagerUI() { reconnectAttempt: reconnectClassification.reconnectAttempt || chunkRuntimeStatus.reconnectAttempt, reconnectMaxAttempts: reconnectClassification.reconnectMaxAttempts || chunkRuntimeStatus.reconnectMaxAttempts, }; + if (shouldSuppressCodexResizeReplay(tabId, ptyId, normalizedReconnectClassification)) + return; const reconnectErrorKey = buildCodexCliErrorKey(normalizedReconnectClassification); restoreFailedAgentTurnToReconnecting(tabId, normalizedReconnectClassification, "pty-runtime-status"); const restoredScanState = codexErrorScanByTabRef.current[tabId] || previous; @@ -4413,15 +4462,17 @@ export default function CodexFlowManagerUI() { if (chunkRuntimeStatus?.phase === "reconnecting" && previous.pendingFinalError && previous.pendingFinalErrorKey) { const pendingAgeMs = Math.max(0, now - Number(previous.pendingFinalErrorAt || now)); if (pendingAgeMs <= CODEX_RECONNECT_AFTER_ERROR_GRACE_MS) { - if (typeof previous.pendingFinalErrorTimerId === "number") { - try { window.clearTimeout(previous.pendingFinalErrorTimerId); } catch {} - } const reconnectClassification: CodexCliErrorClassification = { ...previous.pendingFinalError, phase: "reconnecting", reconnectAttempt: chunkRuntimeStatus.reconnectAttempt, reconnectMaxAttempts: chunkRuntimeStatus.reconnectMaxAttempts, }; + if (shouldSuppressCodexResizeReplay(tabId, ptyId, reconnectClassification)) + return; + if (typeof previous.pendingFinalErrorTimerId === "number") { + try { window.clearTimeout(previous.pendingFinalErrorTimerId); } catch {} + } const reconnectErrorKey = buildCodexCliErrorKey(reconnectClassification); codexErrorScanByTabRef.current[tabId] = { ...latestScanState, @@ -4457,6 +4508,8 @@ export default function CodexFlowManagerUI() { return; } + if (shouldSuppressCodexResizeReplay(tabId, ptyId, classification)) + return; const errorKey = buildCodexCliErrorKey(classification); if (classification.phase === "reconnecting") { if (typeof previous.pendingFinalErrorTimerId === "number") { @@ -4650,7 +4703,13 @@ export default function CodexFlowManagerUI() { ptyNotificationBuffersRef.current[ptyId] = buffer; } - function unregisterPtyListener(ptyId: string | undefined | null) { + /** + * 解除 PTY 输出监听;仅在会话确定关闭时清除其错误回放历史。 + */ + function unregisterPtyListener( + ptyId: string | undefined | null, + options?: { forgetCodexErrorHistory?: boolean }, + ): void { if (!ptyId) return; const tabId = ptyToTabRef.current[ptyId]; const off = ptyListenersRef.current[ptyId]; @@ -4662,6 +4721,8 @@ export default function CodexFlowManagerUI() { if (tabId) { resetCodexErrorScanForTab(tabId, "pty-unregister"); } + if (options?.forgetCodexErrorHistory) + delete codexHandledErrorKeysByPtyRef.current[ptyId]; delete ptyToTabRef.current[ptyId]; notifyLog(`unregisterPtyListener pty=${ptyId}`); } @@ -7251,7 +7312,7 @@ export default function CodexFlowManagerUI() { const ptyId = ptyByTabRef.current[tabId]; if (ptyId) { try { window.host.pty.close(ptyId); } catch {} - unregisterPtyListener(ptyId); + unregisterPtyListener(ptyId, { forgetCodexErrorHistory: true }); delete ptyByTabRef.current[tabId]; } delete ptyAliveRef.current[tabId]; @@ -8187,7 +8248,7 @@ export default function CodexFlowManagerUI() { setPtyAlive((m) => ({ ...m, [tabId]: false })); delete ptyByTabRef.current[tabId]; setPtyByTab((m) => { const n = { ...m }; delete n[tabId]; return n; }); - unregisterPtyListener(id); + unregisterPtyListener(id, { forgetCodexErrorHistory: true }); } catch {} }) : () => {}; @@ -8514,7 +8575,7 @@ export default function CodexFlowManagerUI() { if (pid) { try { window.host.pty.close(pid); } catch {} delete ptyByTabRef.current[tabId]; - unregisterPtyListener(pid); + unregisterPtyListener(pid, { forgetCodexErrorHistory: true }); } delete tabExecEnvByTabRef.current[tabId]; delete geminiWindowsEditorReadyByTabRef.current[tabId]; diff --git a/web/src/lib/TerminalManager.ts b/web/src/lib/TerminalManager.ts index ffdf42a..76b4126 100644 --- a/web/src/lib/TerminalManager.ts +++ b/web/src/lib/TerminalManager.ts @@ -88,6 +88,7 @@ const GEMINI_WSL_EDITOR_TRIGGER_DISPATCH_THRESHOLD_MS = 2500; const TERMINAL_PTY_RESIZE_STABLE_DELAY_MS = 220; const TERMINAL_PTY_RESIZE_MIN_INTERVAL_MS = 320; const TERMINAL_PTY_RESIZE_MAX_DEFER_MS = 1200; +const TERMINAL_PTY_RESIZE_REPLAY_GUARD_MS = 2500; const TERMINAL_RESIZE_DEBOUNCE_MS = 150; const TERMINAL_WINDOW_RESIZE_FAST_DEBOUNCE_MS = 220; const TERMINAL_WINDOW_RESIZE_STABLE_DEBOUNCE_MS = 1280; @@ -503,6 +504,24 @@ export default class TerminalManager { return !!this.getActiveWindowResizeSession(tabId); } + /** + * 判断 PTY 是否正处于缩放或刚完成缩放,用于识别终端全屏重绘产生的历史输出回放。 + * @param tabId tab 标识 + */ + isPtyResizeReplayWindowActive(tabId: string): boolean { + const id = String(tabId || "").trim(); + if (!id) return false; + if (this.isWindowResizeSessionActive(id)) return true; + if (typeof this.pendingTimerByTab[id] === "number") return true; + if (typeof this.pendingPtyResizeTimerByTab[id] === "number") return true; + if (typeof this.pendingPtyResizeSinceByTab[id] === "number") return true; + if (this.resizePtyPauseByTab[id]) return true; + + const lastPtyResizeAt = this.lastPtyResizeAtByTab[id]; + if (typeof lastPtyResizeAt !== "number") return false; + return Math.max(0, this.nowMs() - lastPtyResizeAt) <= TERMINAL_PTY_RESIZE_REPLAY_GUARD_MS; + } + /** * 判断窗口 resize 会话是否需要等待稳定样本,避免 restore/shrink 中间尺寸逐段下发。 * @param session 窗口 resize 会话 diff --git a/web/src/lib/codex-cli-error-classifier.test.ts b/web/src/lib/codex-cli-error-classifier.test.ts index 6609599..8cc53a9 100644 --- a/web/src/lib/codex-cli-error-classifier.test.ts +++ b/web/src/lib/codex-cli-error-classifier.test.ts @@ -140,6 +140,23 @@ describe("codex-cli-error-classifier(Codex TUI 错误识别)", () => { expect(result?.reconnectMaxAttempts).toBe(5); }); + it("最后一次 Reconnecting 后进入历史区的 stream disconnected 应识别为最终失败", () => { + const text = ` + Reconnecting... 5/5 (5h 16m 19s esc to interrupt) + Stream disconnected before completion: Incomplete response returned, reason: upstream_error + › 继续 + ■ stream disconnected before completion: Incomplete response returned, reason: upstream_error + › Explain this codebase + `; + + const result = classifyCodexCliErrorText(text); + expect(result?.kind).toBe("networkStream"); + expect(result?.phase).toBe("final"); + expect(result?.explicitFinal).toBe(true); + expect(shouldDelayCodexCliFinalErrorForReconnect(result)).toBe(false); + expect(detectCodexCliRuntimeStatusText(text)?.phase).toBe("idle"); + }); + it("新的 Working 状态会压住旧的 Reconnecting 错误", () => { const text = ` Reconnecting... 1/5 (4m 18s esc to interrupt) diff --git a/web/src/lib/codex-cli-error-classifier.ts b/web/src/lib/codex-cli-error-classifier.ts index 27e95c9..6428023 100644 --- a/web/src/lib/codex-cli-error-classifier.ts +++ b/web/src/lib/codex-cli-error-classifier.ts @@ -126,7 +126,7 @@ const RECONNECTING_STATUS_PATTERN = /Reconnecting\.\.\.\s+(\d+)\/(\d+)/gi; const WORKING_STATUS_PATTERN = /\bWorking\s*\(/gi; const CODEX_HISTORY_LINE_MARKER_PATTERN = /(?:^|\n)[^\S\n]*[■▪]\s+/gu; const CODEX_FINAL_HISTORY_LINE_PATTERN = - /^(?:unexpected status\s+\d{3}|exceeded retry limit|Conversation interrupted|Goal budget reached|you['’]ve hit your usage limit|usage limit(?:ed)?(?:\s+reached)?|selected model is at capacity|model is at capacity|currently experiencing high demand|400\s+Bad Request|403\s+Forbidden|413\s+(?:Payload Too Large|Request Entity Too Large)|sign in with ChatGPT)\b/i; + /^(?:unexpected status\s+\d{3}|exceeded retry limit|stream disconnected before completion|Conversation interrupted|Goal budget reached|you['’]ve hit your usage limit|usage limit(?:ed)?(?:\s+reached)?|selected model is at capacity|model is at capacity|currently experiencing high demand|400\s+Bad Request|403\s+Forbidden|413\s+(?:Payload Too Large|Request Entity Too Large)|sign in with ChatGPT)\b/i; const EXPLICIT_FINAL_ERROR_PATTERN = /(?:exceeded retry limit|selected model is at capacity|you['’]ve hit your usage limit|usage limit(?:ed)?(?:\s+reached)?|currently experiencing high demand|400\s+Bad Request|

\s*400\s+Bad Request\s*<\/h1>|unexpected status\s+413\b|413\s+Payload Too Large|413\s+Request Entity Too Large|

\s*413\s+(?:Payload Too Large|Request Entity Too Large)\s*<\/h1>)/i; const SOURCE_LOCATION_LINE_PATTERN = /^(?:(?:[A-Za-z]:)?[./\\\w@ -]+\.(?:ts|tsx|js|jsx|json|cjs|mjs|md|css|scss|html|yml|yaml|txt|cs|shader|uxml|uss):\d+(?::\d+)?:)/i; diff --git a/web/src/lib/codex-error-replay-guard.test.ts b/web/src/lib/codex-error-replay-guard.test.ts new file mode 100644 index 0000000..28a49a6 --- /dev/null +++ b/web/src/lib/codex-error-replay-guard.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { + classifyCodexCliErrorText, + type CodexCliErrorClassification, +} from "./codex-cli-error-classifier"; +import { + CODEX_ERROR_REPLAY_HISTORY_LIMIT, + buildCodexErrorReplayKey, + rememberCodexErrorReplayKey, + shouldSuppressCodexErrorResizeReplay, +} from "./codex-error-replay-guard"; + +const FINAL_ERROR: CodexCliErrorClassification = { + kind: "networkStream", + severity: "temporary", + retryable: true, + matchedText: "Stream disconnected before completion: Upstream request failed", + phase: "final", +}; + +describe("codex-error-replay-guard(Codex 缩放错误回放防护)", () => { + it("真实分类结果会把同一错误的重连态与最终态归为同一特征", () => { + const reconnectingError = classifyCodexCliErrorText(` + Reconnecting... 4/5 (2m 10s esc to interrupt) + Stream disconnected before completion: Upstream request failed + `); + const finalError = classifyCodexCliErrorText(` + ■ stream disconnected before completion: Upstream request failed + › Continue + `); + + expect(reconnectingError?.phase).toBe("reconnecting"); + expect(finalError?.phase).toBe("final"); + expect(reconnectingError).not.toBeNull(); + expect(finalError).not.toBeNull(); + expect(buildCodexErrorReplayKey(reconnectingError!)).toBe(buildCodexErrorReplayKey(finalError!)); + }); + + it("错误特征会忽略阶段、大小写、空白和重连次数", () => { + const reconnectingError: CodexCliErrorClassification = { + ...FINAL_ERROR, + matchedText: " stream disconnected before completion: upstream request failed ", + phase: "reconnecting", + reconnectAttempt: 4, + reconnectMaxAttempts: 5, + }; + + expect(buildCodexErrorReplayKey(reconnectingError)).toBe(buildCodexErrorReplayKey(FINAL_ERROR)); + }); + + it("缩放时会屏蔽已经处理过的同一条错误", () => { + const handledErrorKeys = rememberCodexErrorReplayKey([], FINAL_ERROR); + + expect(shouldSuppressCodexErrorResizeReplay({ + resizeReplayActive: true, + handledErrorKeys, + classification: FINAL_ERROR, + })).toBe(true); + }); + + it("缩放时不会屏蔽内容不同的新错误", () => { + const handledErrorKeys = rememberCodexErrorReplayKey([], FINAL_ERROR); + const newError: CodexCliErrorClassification = { + ...FINAL_ERROR, + matchedText: "Stream disconnected before completion: Incomplete response returned", + }; + + expect(shouldSuppressCodexErrorResizeReplay({ + resizeReplayActive: true, + handledErrorKeys, + classification: newError, + })).toBe(false); + }); + + it("非缩放期间不会屏蔽再次真实出现的同一条错误", () => { + const handledErrorKeys = rememberCodexErrorReplayKey([], FINAL_ERROR); + + expect(shouldSuppressCodexErrorResizeReplay({ + resizeReplayActive: false, + handledErrorKeys, + classification: FINAL_ERROR, + })).toBe(false); + }); + + it("错误历史达到上限后只保留最近记录", () => { + let handledErrorKeys: string[] = []; + for (let index = 0; index <= CODEX_ERROR_REPLAY_HISTORY_LIMIT; index++) { + handledErrorKeys = rememberCodexErrorReplayKey(handledErrorKeys, { + ...FINAL_ERROR, + matchedText: `error-${index}`, + }); + } + + expect(handledErrorKeys).toHaveLength(CODEX_ERROR_REPLAY_HISTORY_LIMIT); + expect(handledErrorKeys).not.toContain(buildCodexErrorReplayKey({ ...FINAL_ERROR, matchedText: "error-0" })); + expect(handledErrorKeys).toContain(buildCodexErrorReplayKey({ + ...FINAL_ERROR, + matchedText: `error-${CODEX_ERROR_REPLAY_HISTORY_LIMIT}`, + })); + }); +}); diff --git a/web/src/lib/codex-error-replay-guard.ts b/web/src/lib/codex-error-replay-guard.ts new file mode 100644 index 0000000..77a073a --- /dev/null +++ b/web/src/lib/codex-error-replay-guard.ts @@ -0,0 +1,50 @@ +import type { CodexCliErrorClassification } from "./codex-cli-error-classifier"; + +export const CODEX_ERROR_REPLAY_HISTORY_LIMIT = 32; + +type CodexErrorReplayCandidate = Pick; + +type CodexErrorResizeReplayCheck = { + resizeReplayActive: boolean; + handledErrorKeys: readonly string[] | undefined; + classification: CodexErrorReplayCandidate; +}; + +/** + * 生成跨重绘稳定的错误特征,忽略错误阶段和重连次数。 + */ +export function buildCodexErrorReplayKey(classification: CodexErrorReplayCandidate): string { + const kind = String(classification?.kind || "").trim().toLowerCase(); + const matchedText = String(classification?.matchedText || "") + .replace(/\s+/g, " ") + .trim() + .toLowerCase(); + return `${kind}:${matchedText}`; +} + +/** + * 记录已经处理过的错误特征,并通过固定上限避免历史无限增长。 + */ +export function rememberCodexErrorReplayKey( + handledErrorKeys: readonly string[] | undefined, + classification: CodexErrorReplayCandidate, + limit = CODEX_ERROR_REPLAY_HISTORY_LIMIT, +): string[] { + const key = buildCodexErrorReplayKey(classification); + const boundedLimit = Math.max(1, Math.floor(Number(limit) || CODEX_ERROR_REPLAY_HISTORY_LIMIT)); + const nextKeys = (handledErrorKeys || []).filter((item) => item && item !== key); + nextKeys.push(key); + return nextKeys.slice(-boundedLimit); +} + +/** + * 判断当前错误是否只是终端缩放时回放的已处理历史错误。 + */ +export function shouldSuppressCodexErrorResizeReplay({ + resizeReplayActive, + handledErrorKeys, + classification, +}: CodexErrorResizeReplayCheck): boolean { + if (!resizeReplayActive || !handledErrorKeys?.length) return false; + return handledErrorKeys.includes(buildCodexErrorReplayKey(classification)); +} diff --git a/web/src/lib/terminal-manager-resize-replay.test.ts b/web/src/lib/terminal-manager-resize-replay.test.ts new file mode 100644 index 0000000..76691e4 --- /dev/null +++ b/web/src/lib/terminal-manager-resize-replay.test.ts @@ -0,0 +1,51 @@ +// @vitest-environment jsdom + +import { describe, expect, it, vi } from "vitest"; + +const createTerminalAdapterMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/adapters/TerminalAdapter", () => ({ + createTerminalAdapter: createTerminalAdapterMock, +})); + +import TerminalManager from "./TerminalManager"; + +/** + * 创建 TerminalManager 测试所需的最小 PTY 宿主。 + */ +function createHostPtyStub() { + return { + onData: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + write: vi.fn(), + resize: vi.fn(), + close: vi.fn(), + }; +} + +describe("TerminalManager(PTY 缩放重绘窗口)", () => { + it("没有缩放活动时返回 false", () => { + const tm = new TerminalManager(() => "pty-a", createHostPtyStub() as any, {}); + + expect(tm.isPtyResizeReplayWindowActive("tab-a")).toBe(false); + }); + + it("等待下发 PTY resize 时返回 true", () => { + const tm = new TerminalManager(() => "pty-a", createHostPtyStub() as any, {}); + (tm as any).pendingPtyResizeTimerByTab["tab-a"] = 1; + + expect(tm.isPtyResizeReplayWindowActive("tab-a")).toBe(true); + }); + + it("PTY resize 后只在保护时间内返回 true", () => { + const tm = new TerminalManager(() => "pty-a", createHostPtyStub() as any, {}); + const now = vi.spyOn(tm as any, "nowMs"); + (tm as any).lastPtyResizeAtByTab["tab-a"] = 1_000; + + now.mockReturnValue(3_500); + expect(tm.isPtyResizeReplayWindowActive("tab-a")).toBe(true); + + now.mockReturnValue(3_501); + expect(tm.isPtyResizeReplayWindowActive("tab-a")).toBe(false); + }); +});