mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
@@ -3080,6 +3080,104 @@ ${JSON.stringify({
|
||||
);
|
||||
});
|
||||
|
||||
it("marks quiet Claude live exit-zero turns as retryable empty responses", async () => {
|
||||
let resolveExit: ((exit: RunExit) => void) | undefined;
|
||||
const exited = new Promise<RunExit>((resolve) => {
|
||||
resolveExit = resolve;
|
||||
});
|
||||
const stdin = {
|
||||
write: vi.fn((_dataValue: string, cb?: (err?: Error | null) => void) => {
|
||||
cb?.();
|
||||
resolveExit?.({
|
||||
reason: "exit",
|
||||
exitCode: 0,
|
||||
exitSignal: null,
|
||||
durationMs: 1,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
timedOut: false,
|
||||
noOutputTimedOut: false,
|
||||
});
|
||||
}),
|
||||
end: vi.fn(),
|
||||
};
|
||||
supervisorSpawnMock.mockImplementationOnce(async () => ({
|
||||
runId: "live-empty-run",
|
||||
pid: 2345,
|
||||
startedAtMs: Date.now(),
|
||||
stdin,
|
||||
wait: vi.fn(() => exited),
|
||||
cancel: vi.fn(),
|
||||
}));
|
||||
|
||||
await expectRejectsWithFields(
|
||||
executePreparedCliRun(
|
||||
buildPreparedCliRunContext({
|
||||
provider: "claude-cli",
|
||||
model: "sonnet",
|
||||
runId: "run-live-empty",
|
||||
backend: {
|
||||
liveSession: "claude-stdio",
|
||||
},
|
||||
}),
|
||||
),
|
||||
{
|
||||
name: "FailoverError",
|
||||
reason: "empty_response",
|
||||
code: "cli_unknown_empty_failure",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves Claude live stderr classification on exit-zero failures", async () => {
|
||||
let resolveExit: ((exit: RunExit) => void) | undefined;
|
||||
const exited = new Promise<RunExit>((resolve) => {
|
||||
resolveExit = resolve;
|
||||
});
|
||||
const stdin = {
|
||||
write: vi.fn((_dataValue: string, cb?: (err?: Error | null) => void) => {
|
||||
cb?.();
|
||||
resolveExit?.({
|
||||
reason: "exit",
|
||||
exitCode: 0,
|
||||
exitSignal: null,
|
||||
durationMs: 1,
|
||||
stdout: "",
|
||||
stderr: "Prompt is too long",
|
||||
timedOut: false,
|
||||
noOutputTimedOut: false,
|
||||
});
|
||||
}),
|
||||
end: vi.fn(),
|
||||
};
|
||||
supervisorSpawnMock.mockImplementationOnce(async () => ({
|
||||
runId: "live-exit-zero-overflow-run",
|
||||
pid: 2345,
|
||||
startedAtMs: Date.now(),
|
||||
stdin,
|
||||
wait: vi.fn(() => exited),
|
||||
cancel: vi.fn(),
|
||||
}));
|
||||
|
||||
await expectRejectsWithFields(
|
||||
executePreparedCliRun(
|
||||
buildPreparedCliRunContext({
|
||||
provider: "claude-cli",
|
||||
model: "sonnet",
|
||||
runId: "run-live-exit-zero-overflow",
|
||||
backend: {
|
||||
liveSession: "claude-stdio",
|
||||
},
|
||||
}),
|
||||
),
|
||||
{
|
||||
name: "FailoverError",
|
||||
reason: "context_overflow",
|
||||
code: "cli_context_overflow",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("fails when Claude exits before a live turn starts", async () => {
|
||||
supervisorSpawnMock.mockImplementationOnce(async () => ({
|
||||
runId: "live-run",
|
||||
|
||||
@@ -98,6 +98,8 @@ function shouldRetryFreshCliSessionAfterFailover(params: {
|
||||
return true;
|
||||
case "unknown":
|
||||
return params.error.code === "cli_unknown_empty_failure";
|
||||
case "empty_response":
|
||||
return params.error.code === "cli_unknown_empty_failure";
|
||||
case "timeout":
|
||||
return params.error.code === "cli_no_output_timeout";
|
||||
case "context_overflow":
|
||||
|
||||
@@ -58,6 +58,7 @@ type ClaudeLiveTurn = {
|
||||
timeoutTimer: NodeJS.Timeout | null;
|
||||
activeToolTimer: NodeJS.Timeout | null;
|
||||
activeTools: Map<string, ClaudeLiveActiveTool>;
|
||||
observedStdout: boolean;
|
||||
streamingParser: ReturnType<typeof createCliJsonlStreamingParser>;
|
||||
execPermission: ClaudeLiveExecPermission;
|
||||
resolve: (output: CliOutput) => void;
|
||||
@@ -73,8 +74,6 @@ type ClaudeLiveSession = {
|
||||
stderr: string;
|
||||
stdoutBuffer: string;
|
||||
currentTurn: ClaudeLiveTurn | null;
|
||||
drainTimer: NodeJS.Timeout | null;
|
||||
drainingAbortedTurn: boolean;
|
||||
idleTimer: NodeJS.Timeout | null;
|
||||
cleanup: () => Promise<void>;
|
||||
cleanupPromise: Promise<void> | null;
|
||||
@@ -375,13 +374,6 @@ function clearTurnTimers(turn: ClaudeLiveTurn): void {
|
||||
}
|
||||
}
|
||||
|
||||
function clearDrainTimer(session: ClaudeLiveSession): void {
|
||||
if (session.drainTimer) {
|
||||
clearTimeout(session.drainTimer);
|
||||
session.drainTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function finishTurn(session: ClaudeLiveSession, output: CliOutput): void {
|
||||
const turn = session.currentTurn;
|
||||
if (!turn) {
|
||||
@@ -447,7 +439,6 @@ function closeLiveSession(
|
||||
clearTimeout(session.idleTimer);
|
||||
session.idleTimer = null;
|
||||
}
|
||||
clearDrainTimer(session);
|
||||
if (liveSessions.get(session.key) === session) {
|
||||
liveSessions.delete(session.key);
|
||||
}
|
||||
@@ -854,20 +845,10 @@ function handleClaudeLiveLine(session: ClaudeLiveSession, line: string): void {
|
||||
return;
|
||||
}
|
||||
const parsed = parseClaudeLiveJsonLine(session, trimmed);
|
||||
if (!parsed) {
|
||||
return;
|
||||
if (turn) {
|
||||
turn.observedStdout = true;
|
||||
}
|
||||
if (session.drainingAbortedTurn) {
|
||||
if (parsed.type === "result") {
|
||||
const turnToClear = session.currentTurn;
|
||||
if (turnToClear) {
|
||||
clearTurnTimers(turnToClear);
|
||||
session.currentTurn = null;
|
||||
}
|
||||
session.drainingAbortedTurn = false;
|
||||
clearDrainTimer(session);
|
||||
scheduleIdleClose(session);
|
||||
}
|
||||
if (!parsed) {
|
||||
return;
|
||||
}
|
||||
if (!turn) {
|
||||
@@ -940,7 +921,6 @@ function handleClaudeExit(session: ClaudeLiveSession, exitCode: number | null):
|
||||
clearTimeout(session.idleTimer);
|
||||
session.idleTimer = null;
|
||||
}
|
||||
clearDrainTimer(session);
|
||||
if (liveSessions.get(session.key) === session) {
|
||||
liveSessions.delete(session.key);
|
||||
}
|
||||
@@ -965,8 +945,22 @@ function handleClaudeExit(session: ClaudeLiveSession, exitCode: number | null):
|
||||
const fallbackMessage =
|
||||
exitCode === 0 ? "Claude CLI exited before completing the turn." : "Claude CLI failed.";
|
||||
const message = extractCliErrorMessage(stderr) ?? (stderr || fallbackMessage);
|
||||
if (exitCode === 0) {
|
||||
failTurn(session, new Error(message));
|
||||
if (exitCode === 0 && !stderr) {
|
||||
const turn = session.currentTurn;
|
||||
const retryCode =
|
||||
turn && !turn.observedStdout && turn.rawLines.length === 0
|
||||
? "cli_unknown_empty_failure"
|
||||
: undefined;
|
||||
failTurn(
|
||||
session,
|
||||
new FailoverError(message, {
|
||||
reason: "empty_response",
|
||||
provider: session.providerId,
|
||||
model: session.modelId,
|
||||
status: resolveFailoverStatus("empty_response"),
|
||||
code: retryCode,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const reason = classifyFailoverReason(message, { provider: session.providerId }) ?? "unknown";
|
||||
@@ -1076,8 +1070,6 @@ async function createClaudeLiveSession(params: {
|
||||
stderr: "",
|
||||
stdoutBuffer: "",
|
||||
currentTurn: null,
|
||||
drainTimer: null,
|
||||
drainingAbortedTurn: false,
|
||||
idleTimer: null,
|
||||
cleanup: async () => {
|
||||
await mcpCaptureAttempt.cleanup?.();
|
||||
@@ -1129,6 +1121,7 @@ function createTurn(params: {
|
||||
timeoutTimer: null,
|
||||
activeToolTimer: null,
|
||||
activeTools: new Map(),
|
||||
observedStdout: false,
|
||||
streamingParser: createCliJsonlStreamingParser({
|
||||
backend: params.context.preparedBackend.backend,
|
||||
providerId: params.context.backendResolved.id,
|
||||
@@ -1167,7 +1160,7 @@ function createTurn(params: {
|
||||
|
||||
function closeOldestIdleSession(): boolean {
|
||||
for (const session of liveSessions.values()) {
|
||||
if (!session.currentTurn && !session.drainingAbortedTurn) {
|
||||
if (!session.currentTurn) {
|
||||
closeLiveSession(session, "idle");
|
||||
return true;
|
||||
}
|
||||
@@ -1312,7 +1305,7 @@ export async function runClaudeLiveSessionTurn(params: {
|
||||
await cleanup();
|
||||
throw new Error("Claude CLI live session closed before handling the turn");
|
||||
}
|
||||
if (session.currentTurn || session.drainingAbortedTurn) {
|
||||
if (session.currentTurn) {
|
||||
throw new Error("Claude CLI live session is already handling a turn");
|
||||
}
|
||||
const liveSession = session;
|
||||
|
||||
Reference in New Issue
Block a user