




















@@ -917,10 +917,10 @@ describe("runReplyAgent typing (heartbeat)", () => {
917917vi.useRealTimers();
918918});
919919920-it("announces model fallback only when verbose mode is enabled", async () => {
920+it("announces model fallback transitions across verbose levels", async () => {
921921const cases = [
922-{ name: "verbose on", verbose: "on" as const, expectNotice: true },
923-{ name: "verbose off", verbose: "off" as const, expectNotice: false },
922+{ name: "verbose on", verbose: "on" as const },
923+{ name: "verbose off", verbose: "off" as const },
924924] as const;
925925for (const testCase of cases) {
926926const sessionEntry: SessionEntry = {
@@ -974,13 +974,9 @@ describe("runReplyAgent typing (heartbeat)", () => {
974974const payload = Array.isArray(res)
975975 ? (res[0] as { text?: string })
976976 : (res as { text?: string });
977-if (testCase.expectNotice) {
978-expect(payload.text, testCase.name).toContain("Model Fallback:");
979-expect(payload.text, testCase.name).toContain("deepinfra/moonshotai/Kimi-K2.5");
980-expect(sessionEntry.fallbackNoticeReason, testCase.name).toBe("rate limit");
981-continue;
982-}
983-expect(payload.text, testCase.name).not.toContain("Model Fallback:");
977+expect(payload.text, testCase.name).toContain("Model Fallback:");
978+expect(payload.text, testCase.name).toContain("deepinfra/moonshotai/Kimi-K2.5");
979+expect(sessionEntry.fallbackNoticeReason, testCase.name).toBe("rate limit");
984980expect(
985981phases.filter((phase) => phase === "fallback"),
986982testCase.name,
@@ -989,6 +985,111 @@ describe("runReplyAgent typing (heartbeat)", () => {
989985}
990986});
991987988+it("keeps fallback transition notices when block streaming has no final text", async () => {
989+const sessionEntry: SessionEntry = {
990+sessionId: "session",
991+updatedAt: Date.now(),
992+};
993+const sessionStore = { main: sessionEntry };
994+const onBlockReply = vi.fn();
995+996+state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => {
997+await params.onBlockReply?.({ text: "streamed answer" });
998+return { payloads: [], meta: {} };
999+});
1000+const fallbackSpy = vi
1001+.spyOn(modelFallbackModule, "runWithModelFallback")
1002+.mockImplementationOnce(
1003+async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
1004+result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
1005+provider: "deepinfra",
1006+model: "moonshotai/Kimi-K2.5",
1007+attempts: [
1008+{
1009+provider: "fireworks",
1010+model: "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo",
1011+error: "Provider fireworks is in cooldown (all profiles unavailable)",
1012+reason: "rate_limit",
1013+},
1014+],
1015+}),
1016+);
1017+try {
1018+const { run } = createMinimalRun({
1019+blockStreamingEnabled: true,
1020+opts: { onBlockReply },
1021+ sessionEntry,
1022+ sessionStore,
1023+sessionKey: "main",
1024+});
1025+const res = await run();
1026+const payloads = Array.isArray(res) ? res : res ? [res] : [];
1027+1028+expect(onBlockReply).toHaveBeenCalled();
1029+expect(payloads).toHaveLength(1);
1030+expect(payloads[0]?.text).toContain("Model Fallback:");
1031+expect(payloads[0]?.text).not.toContain("streamed answer");
1032+} finally {
1033+fallbackSpy.mockRestore();
1034+}
1035+});
1036+1037+it("threads fallback notices without consuming the first assistant reply slot", async () => {
1038+const sessionEntry: SessionEntry = {
1039+sessionId: "session",
1040+updatedAt: Date.now(),
1041+};
1042+const sessionStore = { main: sessionEntry };
1043+1044+state.runEmbeddedPiAgentMock.mockResolvedValueOnce({
1045+payloads: [{ text: "final" }],
1046+meta: {},
1047+});
1048+const fallbackSpy = vi
1049+.spyOn(modelFallbackModule, "runWithModelFallback")
1050+.mockImplementationOnce(
1051+async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
1052+result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
1053+provider: "deepinfra",
1054+model: "moonshotai/Kimi-K2.5",
1055+attempts: [
1056+{
1057+provider: "fireworks",
1058+model: "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo",
1059+error: "Provider fireworks is in cooldown (all profiles unavailable)",
1060+reason: "rate_limit",
1061+},
1062+],
1063+}),
1064+);
1065+try {
1066+const { run } = createMinimalRun({
1067+ sessionEntry,
1068+ sessionStore,
1069+sessionKey: "main",
1070+runOverrides: {
1071+config: {
1072+channels: {
1073+whatsapp: {
1074+replyToMode: "first",
1075+},
1076+},
1077+},
1078+},
1079+});
1080+const res = await run();
1081+const payloads = Array.isArray(res) ? res : res ? [res] : [];
1082+1083+expect(payloads).toHaveLength(2);
1084+expect(payloads[0]?.text).toContain("Model Fallback:");
1085+expect(payloads[0]?.replyToId).toBe("msg");
1086+expect(payloads[1]?.text).toBe("final");
1087+expect(payloads[1]?.replyToId).toBe("msg");
1088+} finally {
1089+fallbackSpy.mockRestore();
1090+}
1091+});
1092+9921093it("surfaces a configured backend failure when fallback produces no visible reply", async () => {
9931094state.runEmbeddedPiAgentMock.mockResolvedValueOnce({
9941095payloads: [{ text: "NO_REPLY" }],
@@ -1121,7 +1222,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
11211222expect(payload?.text).toContain("no visible reply");
11221223});
112312241124-it("does not surface fallback silence when fallback already replied through a messaging tool", async () => {
1225+it("announces fallback without silence failure when fallback already replied through a messaging tool", async () => {
11251226state.runEmbeddedPiAgentMock.mockResolvedValueOnce({
11261227payloads: [{ text: "already sent" }],
11271228messagingToolSentTexts: ["already sent"],
@@ -1162,7 +1263,12 @@ describe("runReplyAgent typing (heartbeat)", () => {
11621263},
11631264});
116412651165-await expect(run()).resolves.toBeUndefined();
1266+const res = await run();
1267+const payload = Array.isArray(res) ? res[0] : res;
1268+1269+expect(payload?.isError).not.toBe(true);
1270+expect(payload?.text).toContain("Model Fallback:");
1271+expect(payload?.text).not.toContain("no visible reply");
11661272} finally {
11671273fallbackSpy.mockRestore();
11681274}
@@ -1222,7 +1328,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
12221328}
12231329});
122413301225-it("does not surface fallback silence when fallback already completed a cron side effect", async () => {
1331+it("announces fallback without silence failure when fallback already completed a cron side effect", async () => {
12261332state.runEmbeddedPiAgentMock.mockResolvedValueOnce({
12271333payloads: [{ text: "NO_REPLY" }],
12281334successfulCronAdds: 1,
@@ -1262,13 +1368,18 @@ describe("runReplyAgent typing (heartbeat)", () => {
12621368},
12631369});
126413701265-await expect(run()).resolves.toBeUndefined();
1371+const res = await run();
1372+const payload = Array.isArray(res) ? res[0] : res;
1373+1374+expect(payload?.isError).not.toBe(true);
1375+expect(payload?.text).toContain("Model Fallback:");
1376+expect(payload?.text).not.toContain("no visible reply");
12661377} finally {
12671378fallbackSpy.mockRestore();
12681379}
12691380});
127013811271-it("does not surface fallback silence when fallback committed target-only messaging delivery", async () => {
1382+it("announces fallback without silence failure when fallback committed target-only messaging delivery", async () => {
12721383state.runEmbeddedPiAgentMock.mockResolvedValueOnce({
12731384payloads: [{ text: "NO_REPLY" }],
12741385messagingToolSentTargets: [{ tool: "message", provider: "discord", to: "channel:C1" }],
@@ -1308,13 +1419,18 @@ describe("runReplyAgent typing (heartbeat)", () => {
13081419},
13091420});
131014211311-await expect(run()).resolves.toBeUndefined();
1422+const res = await run();
1423+const payload = Array.isArray(res) ? res[0] : res;
1424+1425+expect(payload?.isError).not.toBe(true);
1426+expect(payload?.text).toContain("Model Fallback:");
1427+expect(payload?.text).not.toContain("no visible reply");
13121428} finally {
13131429fallbackSpy.mockRestore();
13141430}
13151431});
131614321317-it("does not surface fallback silence when fallback already delivered an approval prompt", async () => {
1433+it("announces fallback without silence failure when fallback already delivered an approval prompt", async () => {
13181434state.runEmbeddedPiAgentMock.mockResolvedValueOnce({
13191435payloads: [],
13201436didSendDeterministicApprovalPrompt: true,
@@ -1351,7 +1467,12 @@ describe("runReplyAgent typing (heartbeat)", () => {
13511467},
13521468});
135314691354-await expect(run()).resolves.toBeUndefined();
1470+const res = await run();
1471+const payload = Array.isArray(res) ? res[0] : res;
1472+1473+expect(payload?.isError).not.toBe(true);
1474+expect(payload?.text).toContain("Model Fallback:");
1475+expect(payload?.text).not.toContain("no visible reply");
13551476} finally {
13561477fallbackSpy.mockRestore();
13571478}
@@ -1608,7 +1729,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
16081729}
16091730});
161017311611-it("emits fallback lifecycle events while verbose is off", async () => {
1732+it("announces fallback transitions and emits lifecycle events while verbose is off", async () => {
16121733const sessionEntry: SessionEntry = {
16131734sessionId: "session",
16141735updatedAt: Date.now(),
@@ -1676,8 +1797,8 @@ describe("runReplyAgent typing (heartbeat)", () => {
1676179716771798const firstText = Array.isArray(first) ? first[0]?.text : first?.text;
16781799const secondText = Array.isArray(second) ? second[0]?.text : second?.text;
1679-expect(firstText).not.toContain("Model Fallback:");
1680-expect(secondText).not.toContain("Model Fallback cleared:");
1800+expect(firstText).toContain("Model Fallback:");
1801+expect(secondText).toContain("Model Fallback cleared:");
16811802expect(countMatching(phases, (phase) => phase === "fallback")).toBe(1);
16821803expect(countMatching(phases, (phase) => phase === "fallback_cleared")).toBe(1);
16831804} finally {
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。