




















1-import { describe, expect, it, vi } from "vitest";
1+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22import { createEventHandlers } from "./tui-event-handlers.js";
33import type { AgentEvent, BtwEvent, ChatEvent, TuiStateAccess } from "./tui-types.js";
44@@ -651,3 +651,188 @@ describe("tui-event-handlers: handleAgentEvent", () => {
651651expect(loadHistory).toHaveBeenCalledTimes(1);
652652});
653653});
654+655+describe("tui-event-handlers: streaming watchdog", () => {
656+beforeEach(() => {
657+vi.useFakeTimers();
658+});
659+660+afterEach(() => {
661+vi.useRealTimers();
662+});
663+664+const makeState = (overrides?: Partial<TuiStateAccess>): TuiStateAccess => ({
665+agentDefaultId: "main",
666+sessionMainKey: "agent:main:main",
667+sessionScope: "global",
668+agents: [],
669+currentAgentId: "main",
670+currentSessionKey: "agent:main:main",
671+currentSessionId: "session-1",
672+activeChatRunId: null,
673+pendingOptimisticUserMessage: false,
674+historyLoaded: true,
675+sessionInfo: { verboseLevel: "on" },
676+initialSessionApplied: true,
677+isConnected: true,
678+autoMessageSent: false,
679+toolsExpanded: false,
680+showThinking: false,
681+connectionStatus: "connected",
682+activityStatus: "idle",
683+statusTimeout: null,
684+lastCtrlCAt: 0,
685+ ...overrides,
686+});
687+688+const createHarness = (options?: { streamingWatchdogMs?: number }) => {
689+const state = makeState();
690+const chatLog = createMockChatLog();
691+const btw = createMockBtwPresenter();
692+const tui = { requestRender: vi.fn() } as unknown as MockTui & HandlerTui;
693+const setActivityStatus = vi.fn();
694+const handlers = createEventHandlers({
695+ chatLog,
696+ btw,
697+ tui,
698+ state,
699+ setActivityStatus,
700+streamingWatchdogMs: options?.streamingWatchdogMs,
701+});
702+return { state, chatLog, tui, setActivityStatus, handlers };
703+};
704+705+it("resets activityStatus to idle when no stream delta arrives for the watchdog window", () => {
706+const { state, chatLog, setActivityStatus, handlers } = createHarness({
707+streamingWatchdogMs: 5_000,
708+});
709+710+handlers.handleChatEvent({
711+runId: "run-stuck",
712+sessionKey: state.currentSessionKey,
713+state: "delta",
714+message: { content: "hello" },
715+} satisfies ChatEvent);
716+717+expect(setActivityStatus).toHaveBeenLastCalledWith("streaming");
718+expect(state.activeChatRunId).toBe("run-stuck");
719+720+vi.advanceTimersByTime(5_001);
721+722+expect(setActivityStatus).toHaveBeenLastCalledWith("idle");
723+expect(state.activeChatRunId).toBeNull();
724+expect(chatLog.addSystem).toHaveBeenCalledWith(
725+expect.stringContaining("streaming watchdog"),
726+);
727+728+handlers.dispose?.();
729+});
730+731+it("refreshes the watchdog window on each new stream delta", () => {
732+const { state, setActivityStatus, handlers } = createHarness({
733+streamingWatchdogMs: 5_000,
734+});
735+736+handlers.handleChatEvent({
737+runId: "run-flow",
738+sessionKey: state.currentSessionKey,
739+state: "delta",
740+message: { content: "first" },
741+} satisfies ChatEvent);
742+743+vi.advanceTimersByTime(3_000);
744+745+handlers.handleChatEvent({
746+runId: "run-flow",
747+sessionKey: state.currentSessionKey,
748+state: "delta",
749+message: { content: "second" },
750+} satisfies ChatEvent);
751+752+vi.advanceTimersByTime(3_000);
753+754+// 6s total, but the latest delta was only 3s ago, so the watchdog must not
755+// have fired yet.
756+expect(setActivityStatus).not.toHaveBeenCalledWith("idle");
757+expect(state.activeChatRunId).toBe("run-flow");
758+759+vi.advanceTimersByTime(2_500);
760+761+expect(setActivityStatus).toHaveBeenLastCalledWith("idle");
762+expect(state.activeChatRunId).toBeNull();
763+764+handlers.dispose?.();
765+});
766+767+it("cancels the watchdog when the run finalizes normally", () => {
768+const { state, chatLog, setActivityStatus, handlers } = createHarness({
769+streamingWatchdogMs: 5_000,
770+});
771+772+handlers.handleChatEvent({
773+runId: "run-normal",
774+sessionKey: state.currentSessionKey,
775+state: "delta",
776+message: { content: "hi" },
777+} satisfies ChatEvent);
778+handlers.handleChatEvent({
779+runId: "run-normal",
780+sessionKey: state.currentSessionKey,
781+state: "final",
782+message: { content: [{ type: "text", text: "done" }], stopReason: "stop" },
783+} satisfies ChatEvent);
784+785+vi.advanceTimersByTime(10_000);
786+787+// After a normal final, the watchdog timer must have been cancelled and
788+// cannot later re-overwrite the status or emit the warning banner.
789+const statusCalls = setActivityStatus.mock.calls.map((c) => c[0]);
790+expect(statusCalls.filter((s) => s === "idle").length).toBe(1);
791+expect(chatLog.addSystem).not.toHaveBeenCalledWith(
792+expect.stringContaining("streaming watchdog"),
793+);
794+expect(state.activeChatRunId).toBeNull();
795+796+handlers.dispose?.();
797+});
798+799+it("is disabled when streamingWatchdogMs is 0", () => {
800+const { state, chatLog, setActivityStatus, handlers } = createHarness({
801+streamingWatchdogMs: 0,
802+});
803+804+handlers.handleChatEvent({
805+runId: "run-no-watchdog",
806+sessionKey: state.currentSessionKey,
807+state: "delta",
808+message: { content: "hi" },
809+} satisfies ChatEvent);
810+811+vi.advanceTimersByTime(60_000);
812+813+expect(setActivityStatus).not.toHaveBeenCalledWith("idle");
814+expect(chatLog.addSystem).not.toHaveBeenCalled();
815+expect(state.activeChatRunId).toBe("run-no-watchdog");
816+817+handlers.dispose?.();
818+});
819+820+it("dispose clears a pending watchdog without firing it", () => {
821+const { setActivityStatus, chatLog, handlers, state } = createHarness({
822+streamingWatchdogMs: 5_000,
823+});
824+825+handlers.handleChatEvent({
826+runId: "run-dispose",
827+sessionKey: state.currentSessionKey,
828+state: "delta",
829+message: { content: "hi" },
830+} satisfies ChatEvent);
831+832+handlers.dispose?.();
833+vi.advanceTimersByTime(10_000);
834+835+expect(setActivityStatus).not.toHaveBeenCalledWith("idle");
836+expect(chatLog.addSystem).not.toHaveBeenCalled();
837+});
838+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。