


























@@ -498,6 +498,10 @@ describe("EmbeddedTuiBackend", () => {
498498});
499499500500const backend = new EmbeddedTuiBackend();
501+const events: Array<{ event: string; payload: unknown }> = [];
502+backend.onEvent = (evt) => {
503+events.push({ event: evt.event, payload: evt.payload });
504+};
501505backend.start();
502506await backend.sendChat({
503507sessionKey: "agent:main:main",
@@ -640,6 +644,220 @@ describe("EmbeddedTuiBackend", () => {
640644await flushMicrotasks();
641645});
642646647+it("queues same-session sends behind active local runs", async () => {
648+const previous = process.env.OPENCLAW_TUI_LOCAL_RUN_SHUTDOWN_GRACE_MS;
649+process.env.OPENCLAW_TUI_LOCAL_RUN_SHUTDOWN_GRACE_MS = "5";
650+try {
651+const { EmbeddedTuiBackend } = await import("./embedded-backend.js");
652+const first = deferred<{
653+payloads: Array<{ text: string }>;
654+meta: Record<string, unknown>;
655+}>();
656+const second = deferred<{
657+payloads: Array<{ text: string }>;
658+meta: Record<string, unknown>;
659+}>();
660+const firstAbortListener = vi.fn();
661+agentCommandFromIngressMock
662+.mockImplementationOnce((opts: { abortSignal?: AbortSignal }) => {
663+opts.abortSignal?.addEventListener("abort", firstAbortListener);
664+return first.promise;
665+})
666+.mockReturnValueOnce(second.promise);
667+668+const backend = new EmbeddedTuiBackend();
669+backend.start();
670+await backend.sendChat({
671+sessionKey: "agent:main:main",
672+message: "first",
673+runId: "run-local-first",
674+});
675+676+registeredListener?.({
677+runId: "run-local-first",
678+stream: "assistant",
679+data: { text: "first response", delta: "first response" },
680+});
681+682+await backend.sendChat({
683+sessionKey: "agent:main:main",
684+message: "second",
685+runId: "run-local-second",
686+});
687+await vi.advanceTimersByTimeAsync(5);
688+await flushMicrotasks();
689+690+expect(firstAbortListener).not.toHaveBeenCalled();
691+expect(agentCommandFromIngressMock).toHaveBeenCalledTimes(1);
692+693+first.resolve({ payloads: [{ text: "first done" }], meta: {} });
694+await vi.waitFor(() => {
695+expect(agentCommandFromIngressMock).toHaveBeenCalledTimes(2);
696+});
697+698+second.resolve({ payloads: [{ text: "second done" }], meta: {} });
699+await flushMicrotasks();
700+} finally {
701+if (previous === undefined) {
702+delete process.env.OPENCLAW_TUI_LOCAL_RUN_SHUTDOWN_GRACE_MS;
703+} else {
704+process.env.OPENCLAW_TUI_LOCAL_RUN_SHUTDOWN_GRACE_MS = previous;
705+}
706+}
707+});
708+709+it("does not queue stop commands behind active local runs", async () => {
710+const { EmbeddedTuiBackend } = await import("./embedded-backend.js");
711+const first = deferred<{
712+payloads: Array<{ text: string }>;
713+meta: Record<string, unknown>;
714+}>();
715+const firstAbortListener = vi.fn(() => {
716+first.resolve({ payloads: [{ text: "first aborted" }], meta: {} });
717+});
718+agentCommandFromIngressMock.mockImplementationOnce((opts: { abortSignal?: AbortSignal }) => {
719+opts.abortSignal?.addEventListener("abort", firstAbortListener);
720+return first.promise;
721+});
722+723+const backend = new EmbeddedTuiBackend();
724+backend.start();
725+await backend.sendChat({
726+sessionKey: "agent:main:main",
727+message: "first",
728+runId: "run-local-first",
729+});
730+731+registeredListener?.({
732+runId: "run-local-first",
733+stream: "assistant",
734+data: { text: "first response", delta: "first response" },
735+});
736+737+await backend.sendChat({
738+sessionKey: "agent:main:main",
739+message: "/stop",
740+runId: "run-local-stop",
741+});
742+743+expect(firstAbortListener).toHaveBeenCalledTimes(1);
744+expect(agentCommandFromIngressMock).toHaveBeenCalledTimes(1);
745+await flushMicrotasks();
746+});
747+748+it("stops terminal local runs while post-turn maintenance is pending", async () => {
749+const { EmbeddedTuiBackend } = await import("./embedded-backend.js");
750+const first = deferred<{
751+payloads: Array<{ text: string }>;
752+meta: Record<string, unknown>;
753+}>();
754+const firstAbortListener = vi.fn(() => {
755+first.resolve({ payloads: [{ text: "first aborted" }], meta: {} });
756+});
757+agentCommandFromIngressMock.mockImplementationOnce((opts: { abortSignal?: AbortSignal }) => {
758+opts.abortSignal?.addEventListener("abort", firstAbortListener);
759+return first.promise;
760+});
761+762+const backend = new EmbeddedTuiBackend();
763+const events: Array<{ event: string; payload: unknown }> = [];
764+backend.onEvent = (evt) => {
765+events.push({ event: evt.event, payload: evt.payload });
766+};
767+backend.start();
768+await backend.sendChat({
769+sessionKey: "agent:main:main",
770+message: "first",
771+runId: "run-local-first-terminal",
772+});
773+774+registeredListener?.({
775+runId: "run-local-first-terminal",
776+stream: "lifecycle",
777+data: { phase: "end", stopReason: "stop" },
778+});
779+780+await backend.sendChat({
781+sessionKey: "agent:main:main",
782+message: "/stop",
783+runId: "run-local-stop-terminal",
784+});
785+786+expect(firstAbortListener).toHaveBeenCalledTimes(1);
787+expect(agentCommandFromIngressMock).toHaveBeenCalledTimes(1);
788+await flushMicrotasks();
789+expect(events).toContainEqual({
790+event: "chat",
791+payload: {
792+runId: "run-local-first-terminal",
793+sessionKey: "agent:main:main",
794+state: "aborted",
795+},
796+});
797+});
798+799+it("sends broad stop-like text as a normal prompt when idle", async () => {
800+const { EmbeddedTuiBackend } = await import("./embedded-backend.js");
801+const pending = deferred<{
802+payloads: Array<{ text: string }>;
803+meta: Record<string, unknown>;
804+}>();
805+agentCommandFromIngressMock.mockReturnValueOnce(pending.promise);
806+807+const backend = new EmbeddedTuiBackend();
808+backend.start();
809+await backend.sendChat({
810+sessionKey: "agent:main:main",
811+message: "do not do that",
812+runId: "run-local-normal-stop-like-text",
813+});
814+815+expect(agentCommandFromIngressMock).toHaveBeenCalledTimes(1);
816+817+pending.resolve({ payloads: [{ text: "normal prompt" }], meta: {} });
818+await flushMicrotasks();
819+});
820+821+it("sends idle slash stop as a normal prompt so the TUI receives a terminal event", async () => {
822+const { EmbeddedTuiBackend } = await import("./embedded-backend.js");
823+const pending = deferred<{
824+payloads: Array<{ text: string }>;
825+meta: Record<string, unknown>;
826+}>();
827+agentCommandFromIngressMock.mockReturnValueOnce(pending.promise);
828+829+const backend = new EmbeddedTuiBackend();
830+const events: Array<{ event: string; payload: unknown }> = [];
831+backend.onEvent = (evt) => {
832+events.push({ event: evt.event, payload: evt.payload });
833+};
834+backend.start();
835+await backend.sendChat({
836+sessionKey: "agent:main:main",
837+message: "/stop",
838+runId: "run-local-idle-stop",
839+});
840+841+expect(agentCommandFromIngressMock).toHaveBeenCalledTimes(1);
842+843+pending.resolve({ payloads: [{ text: "idle stop prompt" }], meta: {} });
844+await flushMicrotasks();
845+846+expect(events).toContainEqual({
847+event: "chat",
848+payload: {
849+runId: "run-local-idle-stop",
850+sessionKey: "agent:main:main",
851+state: "final",
852+message: {
853+role: "assistant",
854+content: [{ type: "text", text: "idle stop prompt" }],
855+timestamp: embeddedEventTimestamp,
856+},
857+},
858+});
859+});
860+643861it("queues same-session sends behind terminal local runs until maintenance settles", async () => {
644862const { EmbeddedTuiBackend } = await import("./embedded-backend.js");
645863const first = deferred<{
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。