Skip to content

Commit 602d9e7

Browse files
committed
test(agents): add end-to-end classification regression for stream abort
Address review gap: add classifyFailoverReason tests proving the full classification chain classifies "This operation was aborted" as "timeout", confirming the error triggers fallback rotation instead of surfacing immediately.
1 parent 731d5ba commit 602d9e7

2 files changed

Lines changed: 36 additions & 20 deletions

File tree

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,36 @@
11
// Real behavior proof for #87876: verifies the existing timeout classifier
2-
// already matches "This operation was aborted" stream abort errors,
3-
// so the configured fallback chain rotates for Bedrock Converse drops.
2+
// already matches "This operation was aborted" stream abort errors.
3+
// The full classification chain classifies it as "timeout", meaning the
4+
// configured fallback model is tried instead of surfacing the error.
45
//
56
// Run: node --import tsx scripts/repro/issue-87876-stream-abort-classify.mts
6-
import { isTimeoutErrorMessage } from "../../src/agents/embedded-agent-helpers/failover-matches.ts";
7+
import { classifyFailoverReason, isTimeoutErrorMessage } from "../../src/agents/embedded-agent-helpers/errors.ts";
78

89
function fail(message: string): never {
910
console.error(`FAIL: ${message}`);
1011
process.exitCode = 1;
1112
throw new Error(message);
1213
}
1314

14-
// The exact error message from the issue: Bedrock Converse stream drops after ~6 min.
15+
// 1. Shared timeout classifier matches "This operation was aborted"
1516
const bedrockAbort = "This operation was aborted";
1617
if (!isTimeoutErrorMessage(bedrockAbort)) {
17-
fail(`expected "${bedrockAbort}" to be classified as timeout`);
18+
fail(`expected "${bedrockAbort}" to be classified as timeout by shared classifier`);
1819
}
1920
console.log(`PASS: "${bedrockAbort}" -> isTimeout=true (shared classifier)`);
2021

21-
// Case-insensitive.
22-
if (!isTimeoutErrorMessage("this operation was aborted")) {
23-
fail("expected case-insensitive match");
22+
// 2. Full classification chain classifies as "timeout" (triggers fallback rotation)
23+
const reason = classifyFailoverReason(bedrockAbort);
24+
if (reason !== "timeout") {
25+
fail(`expected classifyFailoverReason to return "timeout", got "${reason}"`);
2426
}
25-
console.log("PASS: case-insensitive match works");
27+
console.log(`PASS: classifyFailoverReason("${bedrockAbort}") = "${reason}" (triggers fallback)`);
2628

27-
// Embedded in a longer message.
28-
if (!isTimeoutErrorMessage("ConverseStream failed: This operation was aborted at connection drop")) {
29-
fail("expected embedded match");
29+
// 3. Embedded in a longer provider message
30+
const embedded = classifyFailoverReason("ConverseStream failed: This operation was aborted");
31+
if (embedded !== "timeout") {
32+
fail(`expected embedded classification to be "timeout", got "${embedded}"`);
3033
}
31-
console.log("PASS: embedded in longer message -> true");
34+
console.log(`PASS: embedded message -> "${embedded}"`);
3235

33-
// Negative: unrelated abort messages should NOT match the timeout classifier.
34-
if (isTimeoutErrorMessage("Request was aborted by user")) {
35-
fail("should not match unrelated abort");
36-
}
37-
console.log("PASS: unrelated abort messages -> false");
38-
39-
console.log("\nALL CHECKS PASSED — stream abort errors are already classified as timeout by the shared classifier.");
36+
console.log("\nALL CHECKS PASSED — stream abort errors are classified as timeout by the shared classifier, triggering fallback rotation.");

src/agents/embedded-agent-helpers/errors.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
55
import { MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE } from "../../shared/assistant-error-format.js";
66
import { makeAssistantMessageFixture } from "../test-helpers/assistant-message-fixtures.js";
77
import {
8+
classifyFailoverReason,
89
formatAssistantErrorText,
910
isLikelyContextOverflowError,
1011
} from "./errors.js";
@@ -116,4 +117,22 @@ describe("stream abort error classification (#87876)", () => {
116117
const { isTimeoutErrorMessage } = await import("./failover-matches.js");
117118
expect(isTimeoutErrorMessage("This operation was aborted")).toBe(true);
118119
});
120+
121+
it("the full classification chain classifies stream abort as timeout", () => {
122+
// classifyFailoverReason exercises the full chain:
123+
// classifyFailoverSignal -> classifyFailoverClassificationFromMessage -> failoverReasonClassification
124+
// Proving "This operation was aborted" → "timeout" means the error advances to
125+
// the next configured model (fallback), not surfaces immediately.
126+
expect(classifyFailoverReason("This operation was aborted")).toBe("timeout");
127+
});
128+
129+
it("the full chain classifies stream abort embedded in a provider message as timeout", () => {
130+
// Bedrock Converse may wrap the abort message.
131+
expect(classifyFailoverReason("ConverseStream failed: This operation was aborted")).toBe("timeout");
132+
});
133+
134+
it("the full chain does NOT classify unrelated abort strings as timeout", () => {
135+
// "Request was aborted" without "operation" should not match.
136+
expect(classifyFailoverReason("Request was aborted by user")).toBeNull();
137+
});
119138
});

0 commit comments

Comments
 (0)