




















@@ -109,6 +109,7 @@ function makeInboundRuntime(): GatewayPluginRuntime["channel"]["inbound"] {
109109function makeRuntime(params: {
110110onFinalize?: (ctx: Record<string, unknown>) => void;
111111isControlCommandMessage?: (text?: string, cfg?: unknown) => boolean;
112+skipFreshSettledDelivery?: boolean;
112113onDispatch?: (dispatcherOptions: {
113114deliver: (
114115payload: { text?: string; mediaUrl?: string; mediaUrls?: string[]; audioAsVoice?: boolean },
@@ -119,6 +120,7 @@ function makeRuntime(params: {
119120info: { kind: string; reason: "empty" | "silent" | "heartbeat" },
120121) => void;
121122onSettled?: () => unknown;
123+onFreshSettledDelivery?: () => unknown;
122124}) => Promise<void>;
123125onDeliver?: (
124126deliver: (
@@ -160,6 +162,7 @@ function makeRuntime(params: {
160162info: { kind: string; reason: "empty" | "silent" | "heartbeat" },
161163) => void;
162164onSettled?: () => unknown;
165+onFreshSettledDelivery?: () => unknown;
163166};
164167}
165168).dispatcherOptions;
@@ -169,6 +172,9 @@ function makeRuntime(params: {
169172await params.onDeliver?.(dispatcherOptions.deliver);
170173}
171174await dispatcherOptions.onSettled?.();
175+if (!params.skipFreshSettledDelivery) {
176+await dispatcherOptions.onFreshSettledDelivery?.();
177+}
172178}),
173179finalizeInboundContext: vi.fn((rawCtx: Record<string, unknown>) => {
174180params.onFinalize?.(rawCtx);
@@ -478,6 +484,96 @@ describe("dispatchOutbound", () => {
478484expect(sendMediaMock).not.toHaveBeenCalled();
479485});
480486487+it("waits for fresh settled delivery after a skipped silent block", async () => {
488+vi.useFakeTimers();
489+const runtime = makeRuntime({
490+onDispatch: async ({ deliver, onSkip }) => {
491+await deliver({ text: "visible tool message" }, { kind: "tool" });
492+onSkip?.({ text: "NO_REPLY" }, { kind: "block", reason: "silent" });
493+await vi.advanceTimersByTimeAsync(60_000);
494+expect(sendTextMock).not.toHaveBeenCalled();
495+},
496+});
497+498+await dispatchOutbound(makeInbound(), {
499+ runtime,
500+cfg: {},
501+account: { ...account, config: { streaming: false } },
502+});
503+504+expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual(["visible tool message"]);
505+expect(sendMediaMock).not.toHaveBeenCalled();
506+});
507+508+it("does not send stale tool fallback when fresh settled delivery is suppressed", async () => {
509+vi.useFakeTimers();
510+const runtime = makeRuntime({
511+skipFreshSettledDelivery: true,
512+onDispatch: async ({ deliver, onSkip }) => {
513+await deliver({ text: "stale visible tool message" }, { kind: "tool" });
514+onSkip?.({ text: "NO_REPLY" }, { kind: "block", reason: "silent" });
515+},
516+});
517+518+await dispatchOutbound(makeInbound(), {
519+ runtime,
520+cfg: {},
521+account: { ...account, config: { streaming: false } },
522+});
523+524+expect(sendTextMock).not.toHaveBeenCalled();
525+expect(sendMediaMock).not.toHaveBeenCalled();
526+expect(vi.getTimerCount()).toBe(0);
527+});
528+529+it("bounds tool media flushes without racing the fallback timer", async () => {
530+vi.useFakeTimers();
531+sendMediaMock.mockImplementationOnce(() => new Promise(() => {}));
532+sendMediaMock.mockImplementationOnce(() => new Promise(() => {}));
533+const firstMediaUrl = "https://example.com/progress-1.png";
534+const secondMediaUrl = "https://example.com/progress-2.png";
535+const runtime = makeRuntime({
536+onDispatch: async ({ deliver, onSkip }) => {
537+await deliver({ mediaUrl: firstMediaUrl }, { kind: "tool" });
538+await deliver({ mediaUrl: secondMediaUrl }, { kind: "tool" });
539+await deliver({ text: "visible tool message" }, { kind: "tool" });
540+onSkip?.({ text: "NO_REPLY" }, { kind: "block", reason: "silent" });
541+},
542+});
543+544+const dispatchPromise = dispatchOutbound(makeInbound(), {
545+ runtime,
546+cfg: {},
547+account: { ...account, config: { streaming: false } },
548+});
549+550+await vi.advanceTimersByTimeAsync(90_000);
551+await dispatchPromise;
552+553+expect(sendMediaMock).toHaveBeenCalledTimes(2);
554+expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual(["visible tool message"]);
555+});
556+557+it("clears the media timeout after a successful silent-final flush", async () => {
558+vi.useFakeTimers();
559+const mediaUrl = "https://example.com/progress.png";
560+const runtime = makeRuntime({
561+onDispatch: async ({ deliver, onSkip }) => {
562+await deliver({ mediaUrl }, { kind: "tool" });
563+onSkip?.({ text: "NO_REPLY" }, { kind: "block", reason: "silent" });
564+},
565+});
566+567+await dispatchOutbound(makeInbound(), {
568+ runtime,
569+cfg: {},
570+account: { ...account, config: { streaming: false } },
571+});
572+573+expect(sendMediaMock).toHaveBeenCalledTimes(1);
574+expect(vi.getTimerCount()).toBe(0);
575+});
576+481577it.each([
482578{ name: "empty text", payload: {} },
483579{ name: "silent token", payload: { text: "NO_REPLY" } },
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。