























1-import { describe, expect, it, vi, beforeEach } from "vitest";
1+import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
22import type { InboundContext } from "./inbound-context.js";
33import { dispatchOutbound } from "./outbound-dispatch.js";
44import type { GatewayAccount, GatewayPluginRuntime } from "./types.js";
@@ -10,7 +10,10 @@ const sendMediaMock = vi.hoisted(() =>
1010vi.fn(async (_params: unknown) => ({ id: "media-1", timestamp: "2026-04-25T00:00:00.000Z" })),
1111);
1212const sendTextMock = vi.hoisted(() =>
13-vi.fn(async (_params: unknown) => ({ id: "text-1", timestamp: "2026-04-25T00:00:00.000Z" })),
13+vi.fn(async (..._params: unknown[]) => ({
14+id: "text-1",
15+timestamp: "2026-04-25T00:00:00.000Z",
16+})),
1417);
1518const audioFileToSilkBase64Mock = vi.hoisted(() => vi.fn(async () => "silk-base64"));
1619@@ -107,7 +110,7 @@ function makeRuntime(params: {
107110isControlCommandMessage?: (text?: string, cfg?: unknown) => boolean;
108111onDeliver?: (
109112deliver: (
110-payload: { text?: string; audioAsVoice?: boolean },
113+payload: { text?: string; mediaUrl?: string; mediaUrls?: string[]; audioAsVoice?: boolean },
111114info: { kind: string },
112115) => Promise<void>,
113116) => Promise<void>;
@@ -127,7 +130,12 @@ function makeRuntime(params: {
127130rawParams as {
128131dispatcherOptions: {
129132deliver: (
130-payload: { text?: string; audioAsVoice?: boolean },
133+payload: {
134+text?: string;
135+mediaUrl?: string;
136+mediaUrls?: string[];
137+audioAsVoice?: boolean;
138+},
131139info: { kind: string },
132140) => Promise<void>;
133141};
@@ -171,6 +179,10 @@ describe("dispatchOutbound", () => {
171179vi.clearAllMocks();
172180});
173181182+afterEach(() => {
183+vi.useRealTimers();
184+});
185+174186it("keeps waiting past 300s when a slow provider timeout is configured", async () => {
175187vi.useFakeTimers();
176188try {
@@ -257,6 +269,113 @@ describe("dispatchOutbound", () => {
257269expect(sendTextMock).not.toHaveBeenCalled();
258270});
259271272+it("delivers text-only tool progress immediately in partial streaming mode", async () => {
273+const runtime = makeRuntime({
274+onDeliver: async (deliver) => {
275+await deliver({ text: "Working: checking logs" }, { kind: "tool" });
276+await deliver({ text: "final answer" }, { kind: "block" });
277+},
278+});
279+280+await dispatchOutbound(makeInbound(), {
281+ runtime,
282+cfg: {},
283+account: { ...account, config: { streaming: { mode: "partial" } } },
284+});
285+286+expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual([
287+"Working: checking logs",
288+"final answer",
289+]);
290+expect(sendMediaMock).not.toHaveBeenCalled();
291+});
292+293+it("delivers text-only tool progress immediately in recommended C2C streaming mode", async () => {
294+const runtime = makeRuntime({
295+onDeliver: async (deliver) => {
296+await deliver({ text: "Working: checking logs" }, { kind: "tool" });
297+await deliver({ text: "final answer" }, { kind: "block" });
298+},
299+});
300+301+await dispatchOutbound(makeInbound(), {
302+ runtime,
303+cfg: {},
304+account: { ...account, config: { streaming: true } },
305+});
306+307+expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual([
308+"Working: checking logs",
309+"final answer",
310+]);
311+expect(sendMediaMock).not.toHaveBeenCalled();
312+});
313+314+it("keeps immediate tool progress media-like text inert with markdown support enabled", async () => {
315+const progress = "progress ";
316+const runtime = makeRuntime({
317+onDeliver: async (deliver) => {
318+await deliver({ text: progress }, { kind: "tool" });
319+await deliver({ text: "final answer" }, { kind: "block" });
320+},
321+});
322+323+await dispatchOutbound(makeInbound(), {
324+ runtime,
325+cfg: {},
326+account: { ...account, markdownSupport: true, config: { streaming: { mode: "partial" } } },
327+});
328+329+expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual([progress, "final answer"]);
330+expect(sendTextMock.mock.calls[0]?.[3]).toMatchObject({ forcePlainText: true });
331+expect(sendMediaMock).not.toHaveBeenCalled();
332+});
333+334+it("keeps text-only tool progress buffered when streaming is off", async () => {
335+const runtime = makeRuntime({
336+onDeliver: async (deliver) => {
337+await deliver({ text: "Working: checking logs" }, { kind: "tool" });
338+await deliver({ text: "final answer" }, { kind: "block" });
339+},
340+});
341+342+await dispatchOutbound(makeInbound(), {
343+ runtime,
344+cfg: {},
345+account: { ...account, config: { streaming: false } },
346+});
347+348+expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual(["final answer"]);
349+expect(sendMediaMock).not.toHaveBeenCalled();
350+});
351+352+it("renews pending tool-media fallback when partial progress is delivered", async () => {
353+vi.useFakeTimers();
354+const mediaUrl = "https://example.com/progress.png";
355+const runtime = makeRuntime({
356+onDeliver: async (deliver) => {
357+await deliver({ mediaUrl }, { kind: "tool" });
358+await vi.advanceTimersByTimeAsync(59_000);
359+await deliver({ text: "Working: checking logs" }, { kind: "tool" });
360+await vi.advanceTimersByTimeAsync(1_000);
361+expect(sendMediaMock).not.toHaveBeenCalled();
362+await deliver({ text: "final answer" }, { kind: "block" });
363+},
364+});
365+366+await dispatchOutbound(makeInbound(), {
367+ runtime,
368+cfg: {},
369+account: { ...account, config: { streaming: { mode: "partial" } } },
370+});
371+372+expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual([
373+"Working: checking logs",
374+"final answer",
375+]);
376+expect(sendMediaMock).toHaveBeenCalledTimes(1);
377+});
378+260379it("marks recognized C2C framework slash commands as text commands", async () => {
261380let finalized: Record<string, unknown> | undefined;
262381const runtime = makeRuntime({
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。