mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 02:31:38 +00:00
test(reply): merge duplicate runReplyAgent streaming and fallback cases
This commit is contained in:
@@ -337,66 +337,62 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
|||||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("suppresses partial streaming for NO_REPLY", async () => {
|
it("suppresses NO_REPLY partials but allows normal No-prefix partials", async () => {
|
||||||
const onPartialReply = vi.fn();
|
const cases = [
|
||||||
state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => {
|
{
|
||||||
await params.onPartialReply?.({ text: "NO_REPLY" });
|
partials: ["NO_REPLY"],
|
||||||
return { payloads: [{ text: "NO_REPLY" }], meta: {} };
|
finalText: "NO_REPLY",
|
||||||
});
|
expectedForwarded: [] as string[],
|
||||||
|
shouldType: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
partials: ["NO_", "NO_RE", "NO_REPLY"],
|
||||||
|
finalText: "NO_REPLY",
|
||||||
|
expectedForwarded: [] as string[],
|
||||||
|
shouldType: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
partials: ["No", "No, that is valid"],
|
||||||
|
finalText: "No, that is valid",
|
||||||
|
expectedForwarded: ["No", "No, that is valid"],
|
||||||
|
shouldType: true,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
const { run, typing } = createMinimalRun({
|
for (const testCase of cases) {
|
||||||
opts: { isHeartbeat: false, onPartialReply },
|
const onPartialReply = vi.fn();
|
||||||
typingMode: "message",
|
state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => {
|
||||||
});
|
for (const text of testCase.partials) {
|
||||||
await run();
|
await params.onPartialReply?.({ text });
|
||||||
|
}
|
||||||
|
return { payloads: [{ text: testCase.finalText }], meta: {} };
|
||||||
|
});
|
||||||
|
|
||||||
expect(onPartialReply).not.toHaveBeenCalled();
|
const { run, typing } = createMinimalRun({
|
||||||
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
opts: { isHeartbeat: false, onPartialReply },
|
||||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
typingMode: "message",
|
||||||
});
|
});
|
||||||
|
await run();
|
||||||
|
|
||||||
it("suppresses partial streaming for NO_REPLY prefixes", async () => {
|
if (testCase.expectedForwarded.length === 0) {
|
||||||
const onPartialReply = vi.fn();
|
expect(onPartialReply).not.toHaveBeenCalled();
|
||||||
state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => {
|
} else {
|
||||||
await params.onPartialReply?.({ text: "NO_" });
|
expect(onPartialReply).toHaveBeenCalledTimes(testCase.expectedForwarded.length);
|
||||||
await params.onPartialReply?.({ text: "NO_RE" });
|
testCase.expectedForwarded.forEach((text, index) => {
|
||||||
await params.onPartialReply?.({ text: "NO_REPLY" });
|
expect(onPartialReply).toHaveBeenNthCalledWith(index + 1, {
|
||||||
return { payloads: [{ text: "NO_REPLY" }], meta: {} };
|
text,
|
||||||
});
|
mediaUrls: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const { run, typing } = createMinimalRun({
|
if (testCase.shouldType) {
|
||||||
opts: { isHeartbeat: false, onPartialReply },
|
expect(typing.startTypingOnText).toHaveBeenCalled();
|
||||||
typingMode: "message",
|
} else {
|
||||||
});
|
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
||||||
await run();
|
}
|
||||||
|
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||||
expect(onPartialReply).not.toHaveBeenCalled();
|
}
|
||||||
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
|
||||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not suppress partial streaming for normal 'No' prefixes", async () => {
|
|
||||||
const onPartialReply = vi.fn();
|
|
||||||
state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => {
|
|
||||||
await params.onPartialReply?.({ text: "No" });
|
|
||||||
await params.onPartialReply?.({ text: "No, that is valid" });
|
|
||||||
return { payloads: [{ text: "No, that is valid" }], meta: {} };
|
|
||||||
});
|
|
||||||
|
|
||||||
const { run, typing } = createMinimalRun({
|
|
||||||
opts: { isHeartbeat: false, onPartialReply },
|
|
||||||
typingMode: "message",
|
|
||||||
});
|
|
||||||
await run();
|
|
||||||
|
|
||||||
expect(onPartialReply).toHaveBeenCalledTimes(2);
|
|
||||||
expect(onPartialReply).toHaveBeenNthCalledWith(1, { text: "No", mediaUrls: undefined });
|
|
||||||
expect(onPartialReply).toHaveBeenNthCalledWith(2, {
|
|
||||||
text: "No, that is valid",
|
|
||||||
mediaUrls: undefined,
|
|
||||||
});
|
|
||||||
expect(typing.startTypingOnText).toHaveBeenCalled();
|
|
||||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not start typing on assistant message start without prior text in message mode", async () => {
|
it("does not start typing on assistant message start without prior text in message mode", async () => {
|
||||||
@@ -488,41 +484,48 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("signals typing on tool results", async () => {
|
it("handles typing for normal and silent tool results", async () => {
|
||||||
const onToolResult = vi.fn();
|
const cases = [
|
||||||
state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => {
|
{
|
||||||
await params.onToolResult?.({ text: "tooling", mediaUrls: [] });
|
toolText: "tooling",
|
||||||
return { payloads: [{ text: "final" }], meta: {} };
|
shouldType: true,
|
||||||
});
|
shouldForward: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
toolText: "NO_REPLY",
|
||||||
|
shouldType: false,
|
||||||
|
shouldForward: false,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
const { run, typing } = createMinimalRun({
|
for (const testCase of cases) {
|
||||||
typingMode: "message",
|
const onToolResult = vi.fn();
|
||||||
opts: { onToolResult },
|
state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => {
|
||||||
});
|
await params.onToolResult?.({ text: testCase.toolText, mediaUrls: [] });
|
||||||
await run();
|
return { payloads: [{ text: "final" }], meta: {} };
|
||||||
|
});
|
||||||
|
|
||||||
expect(typing.startTypingOnText).toHaveBeenCalledWith("tooling");
|
const { run, typing } = createMinimalRun({
|
||||||
expect(onToolResult).toHaveBeenCalledWith({
|
typingMode: "message",
|
||||||
text: "tooling",
|
opts: { onToolResult },
|
||||||
mediaUrls: [],
|
});
|
||||||
});
|
await run();
|
||||||
});
|
|
||||||
|
|
||||||
it("skips typing for silent tool results", async () => {
|
if (testCase.shouldType) {
|
||||||
const onToolResult = vi.fn();
|
expect(typing.startTypingOnText).toHaveBeenCalledWith(testCase.toolText);
|
||||||
state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => {
|
} else {
|
||||||
await params.onToolResult?.({ text: "NO_REPLY", mediaUrls: [] });
|
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
||||||
return { payloads: [{ text: "final" }], meta: {} };
|
}
|
||||||
});
|
|
||||||
|
|
||||||
const { run, typing } = createMinimalRun({
|
if (testCase.shouldForward) {
|
||||||
typingMode: "message",
|
expect(onToolResult).toHaveBeenCalledWith({
|
||||||
opts: { onToolResult },
|
text: testCase.toolText,
|
||||||
});
|
mediaUrls: [],
|
||||||
await run();
|
});
|
||||||
|
} else {
|
||||||
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
expect(onToolResult).not.toHaveBeenCalled();
|
||||||
expect(onToolResult).not.toHaveBeenCalled();
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("retries transient HTTP failures once with timer-driven backoff", async () => {
|
it("retries transient HTTP failures once with timer-driven backoff", async () => {
|
||||||
@@ -979,100 +982,67 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("backfills fallback reason when fallback is already active", async () => {
|
it("updates fallback reason summary while fallback stays active", async () => {
|
||||||
const sessionEntry: SessionEntry = {
|
const cases = [
|
||||||
sessionId: "session",
|
{
|
||||||
updatedAt: Date.now(),
|
existingReason: undefined,
|
||||||
fallbackNoticeSelectedModel: "anthropic/claude",
|
reportedReason: "rate_limit",
|
||||||
fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5",
|
expectedReason: "rate limit",
|
||||||
modelProvider: "deepinfra",
|
},
|
||||||
model: "moonshotai/Kimi-K2.5",
|
{
|
||||||
};
|
existingReason: "rate limit",
|
||||||
const sessionStore = { main: sessionEntry };
|
reportedReason: "timeout",
|
||||||
|
expectedReason: "timeout",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
state.runEmbeddedPiAgentMock.mockResolvedValue({
|
for (const testCase of cases) {
|
||||||
payloads: [{ text: "final" }],
|
const sessionEntry: SessionEntry = {
|
||||||
meta: {},
|
sessionId: "session",
|
||||||
});
|
updatedAt: Date.now(),
|
||||||
const fallbackSpy = vi
|
fallbackNoticeSelectedModel: "anthropic/claude",
|
||||||
.spyOn(modelFallbackModule, "runWithModelFallback")
|
fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5",
|
||||||
.mockImplementation(
|
...(testCase.existingReason ? { fallbackNoticeReason: testCase.existingReason } : {}),
|
||||||
async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
|
modelProvider: "deepinfra",
|
||||||
result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
|
model: "moonshotai/Kimi-K2.5",
|
||||||
provider: "deepinfra",
|
};
|
||||||
model: "moonshotai/Kimi-K2.5",
|
const sessionStore = { main: sessionEntry };
|
||||||
attempts: [
|
|
||||||
{
|
state.runEmbeddedPiAgentMock.mockResolvedValue({
|
||||||
provider: "anthropic",
|
payloads: [{ text: "final" }],
|
||||||
model: "claude",
|
meta: {},
|
||||||
error: "Provider anthropic is in cooldown (all profiles unavailable)",
|
|
||||||
reason: "rate_limit",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
const { run } = createMinimalRun({
|
|
||||||
resolvedVerboseLevel: "on",
|
|
||||||
sessionEntry,
|
|
||||||
sessionStore,
|
|
||||||
sessionKey: "main",
|
|
||||||
});
|
});
|
||||||
const res = await run();
|
const fallbackSpy = vi
|
||||||
const firstText = Array.isArray(res) ? res[0]?.text : res?.text;
|
.spyOn(modelFallbackModule, "runWithModelFallback")
|
||||||
expect(firstText).not.toContain("Model Fallback:");
|
.mockImplementation(
|
||||||
expect(sessionEntry.fallbackNoticeReason).toBe("rate limit");
|
async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
|
||||||
} finally {
|
result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
|
||||||
fallbackSpy.mockRestore();
|
provider: "deepinfra",
|
||||||
}
|
model: "moonshotai/Kimi-K2.5",
|
||||||
});
|
attempts: [
|
||||||
|
{
|
||||||
it("refreshes fallback reason summary while fallback stays active", async () => {
|
provider: "anthropic",
|
||||||
const sessionEntry: SessionEntry = {
|
model: "claude",
|
||||||
sessionId: "session",
|
error: "Provider anthropic is in cooldown (all profiles unavailable)",
|
||||||
updatedAt: Date.now(),
|
reason: testCase.reportedReason,
|
||||||
fallbackNoticeSelectedModel: "anthropic/claude",
|
},
|
||||||
fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5",
|
],
|
||||||
fallbackNoticeReason: "rate limit",
|
}),
|
||||||
modelProvider: "deepinfra",
|
);
|
||||||
model: "moonshotai/Kimi-K2.5",
|
try {
|
||||||
};
|
const { run } = createMinimalRun({
|
||||||
const sessionStore = { main: sessionEntry };
|
resolvedVerboseLevel: "on",
|
||||||
|
sessionEntry,
|
||||||
state.runEmbeddedPiAgentMock.mockResolvedValue({
|
sessionStore,
|
||||||
payloads: [{ text: "final" }],
|
sessionKey: "main",
|
||||||
meta: {},
|
});
|
||||||
});
|
const res = await run();
|
||||||
const fallbackSpy = vi
|
const firstText = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
.spyOn(modelFallbackModule, "runWithModelFallback")
|
expect(firstText).not.toContain("Model Fallback:");
|
||||||
.mockImplementation(
|
expect(sessionEntry.fallbackNoticeReason).toBe(testCase.expectedReason);
|
||||||
async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
|
} finally {
|
||||||
result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
|
fallbackSpy.mockRestore();
|
||||||
provider: "deepinfra",
|
}
|
||||||
model: "moonshotai/Kimi-K2.5",
|
|
||||||
attempts: [
|
|
||||||
{
|
|
||||||
provider: "anthropic",
|
|
||||||
model: "claude",
|
|
||||||
error: "Provider anthropic is in cooldown (all profiles unavailable)",
|
|
||||||
reason: "timeout",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
const { run } = createMinimalRun({
|
|
||||||
resolvedVerboseLevel: "on",
|
|
||||||
sessionEntry,
|
|
||||||
sessionStore,
|
|
||||||
sessionKey: "main",
|
|
||||||
});
|
|
||||||
const res = await run();
|
|
||||||
const firstText = Array.isArray(res) ? res[0]?.text : res?.text;
|
|
||||||
expect(firstText).not.toContain("Model Fallback:");
|
|
||||||
expect(sessionEntry.fallbackNoticeReason).toBe("timeout");
|
|
||||||
} finally {
|
|
||||||
fallbackSpy.mockRestore();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user