mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(acp): map error states to end_turn instead of unconditional refusal (#41187)
* fix(acp): map error states to end_turn instead of unconditional refusal * fix: map ACP error stop reason to end_turn (#41187) (thanks @pejmanjohn) --------- Co-authored-by: Pejman Pour-Moezzi <481729+pejmanjohn@users.noreply.github.com> Co-authored-by: Onur <onur@textcortex.com>
This commit is contained in:
committed by
GitHub
parent
4815dc0603
commit
eab39c721b
@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Context engine/tests: add bundled-registry regression coverage for cross-chunk resolution, plugin-sdk re-exports, and concurrent chunk registration. (#40460) thanks @dsantoreis.
|
||||
- Agents/embedded runner: bound compaction retry waiting and drain embedded runs during SIGUSR1 restart so session lanes recover instead of staying blocked behind compaction. (#40324) thanks @cgdusek.
|
||||
- ACP/sessions.patch: allow `spawnedBy` and `spawnDepth` lineage fields on ACP session keys so `sessions_spawn` with `runtime: "acp"` no longer fails during child-session setup. Fixes #40971. (#40995) thanks @xaeon2026.
|
||||
- ACP/stop reason mapping: resolve gateway chat `state: "error"` completions as ACP `end_turn` instead of `refusal` so transient backend failures are not surfaced as deliberate refusals. (#41187) thanks @pejmanjohn.
|
||||
|
||||
## 2026.3.8
|
||||
|
||||
|
||||
111
src/acp/translator.stop-reason.test.ts
Normal file
111
src/acp/translator.stop-reason.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { PromptRequest } from "@agentclientprotocol/sdk";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayClient } from "../gateway/client.js";
|
||||
import type { EventFrame } from "../gateway/protocol/index.js";
|
||||
import { createInMemorySessionStore } from "./session.js";
|
||||
import { AcpGatewayAgent } from "./translator.js";
|
||||
import { createAcpConnection, createAcpGateway } from "./translator.test-helpers.js";
|
||||
|
||||
type PendingPromptHarness = {
|
||||
agent: AcpGatewayAgent;
|
||||
promptPromise: ReturnType<AcpGatewayAgent["prompt"]>;
|
||||
runId: string;
|
||||
};
|
||||
|
||||
async function createPendingPromptHarness(): Promise<PendingPromptHarness> {
|
||||
const sessionId = "session-1";
|
||||
const sessionKey = "agent:main:main";
|
||||
|
||||
let runId: string | undefined;
|
||||
const request = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
if (method === "chat.send") {
|
||||
runId = params?.idempotencyKey as string | undefined;
|
||||
return new Promise<never>(() => {});
|
||||
}
|
||||
return {};
|
||||
}) as GatewayClient["request"];
|
||||
|
||||
const sessionStore = createInMemorySessionStore();
|
||||
sessionStore.createSession({
|
||||
sessionId,
|
||||
sessionKey,
|
||||
cwd: "/tmp",
|
||||
});
|
||||
|
||||
const agent = new AcpGatewayAgent(
|
||||
createAcpConnection(),
|
||||
createAcpGateway(request as unknown as GatewayClient["request"]),
|
||||
{ sessionStore },
|
||||
);
|
||||
const promptPromise = agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: "text", text: "hello" }],
|
||||
_meta: {},
|
||||
} as unknown as PromptRequest);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(runId).toBeDefined();
|
||||
});
|
||||
|
||||
return {
|
||||
agent,
|
||||
promptPromise,
|
||||
runId: runId!,
|
||||
};
|
||||
}
|
||||
|
||||
function createChatEvent(payload: Record<string, unknown>): EventFrame {
|
||||
return {
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload,
|
||||
} as EventFrame;
|
||||
}
|
||||
|
||||
describe("acp translator stop reason mapping", () => {
|
||||
it("error state resolves as end_turn, not refusal", async () => {
|
||||
const { agent, promptPromise, runId } = await createPendingPromptHarness();
|
||||
|
||||
await agent.handleGatewayEvent(
|
||||
createChatEvent({
|
||||
runId,
|
||||
sessionKey: "agent:main:main",
|
||||
seq: 1,
|
||||
state: "error",
|
||||
errorMessage: "gateway timeout",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(promptPromise).resolves.toEqual({ stopReason: "end_turn" });
|
||||
});
|
||||
|
||||
it("error state with no errorMessage resolves as end_turn", async () => {
|
||||
const { agent, promptPromise, runId } = await createPendingPromptHarness();
|
||||
|
||||
await agent.handleGatewayEvent(
|
||||
createChatEvent({
|
||||
runId,
|
||||
sessionKey: "agent:main:main",
|
||||
seq: 1,
|
||||
state: "error",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(promptPromise).resolves.toEqual({ stopReason: "end_turn" });
|
||||
});
|
||||
|
||||
it("aborted state resolves as cancelled", async () => {
|
||||
const { agent, promptPromise, runId } = await createPendingPromptHarness();
|
||||
|
||||
await agent.handleGatewayEvent(
|
||||
createChatEvent({
|
||||
runId,
|
||||
sessionKey: "agent:main:main",
|
||||
seq: 1,
|
||||
state: "aborted",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(promptPromise).resolves.toEqual({ stopReason: "cancelled" });
|
||||
});
|
||||
});
|
||||
@@ -473,7 +473,11 @@ export class AcpGatewayAgent implements Agent {
|
||||
return;
|
||||
}
|
||||
if (state === "error") {
|
||||
this.finishPrompt(pending.sessionId, pending, "refusal");
|
||||
// ACP has no explicit "server_error" stop reason. Use "end_turn" so clients
|
||||
// do not treat transient backend errors (timeouts, rate-limits) as deliberate
|
||||
// refusals. TODO: when ChatEventSchema gains a structured errorKind field
|
||||
// (e.g. "refusal" | "timeout" | "rate_limit"), use it to distinguish here.
|
||||
this.finishPrompt(pending.sessionId, pending, "end_turn");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user