diff --git a/src/agents/pi-embedded-helpers.classifyfailoverreason.e2e.test.ts b/src/agents/pi-embedded-helpers.classifyfailoverreason.e2e.test.ts
index 1b175e77b..daf9d9cf5 100644
--- a/src/agents/pi-embedded-helpers.classifyfailoverreason.e2e.test.ts
+++ b/src/agents/pi-embedded-helpers.classifyfailoverreason.e2e.test.ts
@@ -24,6 +24,7 @@ describe("classifyFailoverReason", () => {
expect(classifyFailoverReason("invalid request format")).toBe("format");
expect(classifyFailoverReason("credit balance too low")).toBe("billing");
expect(classifyFailoverReason("deadline exceeded")).toBe("timeout");
+ expect(classifyFailoverReason("request ended without sending any chunks")).toBe("timeout");
expect(
classifyFailoverReason(
"521
Web server is downCloudflare",
diff --git a/src/agents/pi-embedded-helpers.formatassistanterrortext.e2e.test.ts b/src/agents/pi-embedded-helpers.formatassistanterrortext.e2e.test.ts
index 9d0179e42..9ba67b6a1 100644
--- a/src/agents/pi-embedded-helpers.formatassistanterrortext.e2e.test.ts
+++ b/src/agents/pi-embedded-helpers.formatassistanterrortext.e2e.test.ts
@@ -108,4 +108,9 @@ describe("formatAssistantErrorText", () => {
const msg = makeAssistantError("429 rate limit reached");
expect(formatAssistantErrorText(msg)).toContain("rate limit reached");
});
+
+ it("returns a friendly message for empty stream chunk errors", () => {
+ const msg = makeAssistantError("request ended without sending any chunks");
+ expect(formatAssistantErrorText(msg)).toBe("LLM request timed out.");
+ });
});
diff --git a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts
index 24d6b1c38..83f757f13 100644
--- a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts
+++ b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts
@@ -175,6 +175,60 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
}
});
+ it("rotates when stream ends without sending chunks", async () => {
+ const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-"));
+ const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-"));
+ try {
+ await writeAuthStore(agentDir);
+
+ runEmbeddedAttemptMock
+ .mockResolvedValueOnce(
+ makeAttempt({
+ assistantTexts: [],
+ lastAssistant: buildAssistant({
+ stopReason: "error",
+ errorMessage: "request ended without sending any chunks",
+ }),
+ }),
+ )
+ .mockResolvedValueOnce(
+ makeAttempt({
+ assistantTexts: ["ok"],
+ lastAssistant: buildAssistant({
+ stopReason: "stop",
+ content: [{ type: "text", text: "ok" }],
+ }),
+ }),
+ );
+
+ await runEmbeddedPiAgent({
+ sessionId: "session:test",
+ sessionKey: "agent:test:empty-chunk-stream",
+ sessionFile: path.join(workspaceDir, "session.jsonl"),
+ workspaceDir,
+ agentDir,
+ config: makeConfig(),
+ prompt: "hello",
+ provider: "openai",
+ model: "mock-1",
+ authProfileId: "openai:p1",
+ authProfileIdSource: "auto",
+ timeoutMs: 5_000,
+ runId: "run:empty-chunk-stream",
+ });
+
+ expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(2);
+
+ const stored = JSON.parse(
+ await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"),
+ ) as { usageStats?: Record };
+ expect(typeof stored.usageStats?.["openai:p2"]?.lastUsed).toBe("number");
+ } finally {
+ await fs.rm(agentDir, { recursive: true, force: true });
+ await fs.rm(workspaceDir, { recursive: true, force: true });
+ }
+ });
+
it("does not rotate for compaction timeouts", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-"));
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-"));