Skip to content

Commit a230fcc

Browse files
committed
fix(gateway): broadcast agent-run error payloads
1 parent 1bafc23 commit a230fcc

3 files changed

Lines changed: 97 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai
3737

3838
- CLI/update: start managed Gateway update handoff helpers from a stable existing directory and tolerate deleted cwd/package roots during macOS LaunchAgent handoff. Fixes #83808. (#83875) Thanks @jason-allen-oneal.
3939
- Cron: honor `cron.retry.retryOn: ["network"]` for common network error codes such as `EAI_AGAIN`, `EHOSTUNREACH`, and `ENETUNREACH`.
40+
- Gateway chat: broadcast returned agent-run error payloads after an agent starts so ACP/WebChat clients receive terminal idle-timeout errors. Fixes #84945.
4041
- Agents/OpenAI: preserve structured provider error code, type, and redacted body metadata on boundary-aware transport failures.
4142
- CLI/agents: retry transient normal-close Gateway handshakes before falling back to embedded `openclaw agent` execution.
4243
- CLI/update: keep managed Gateway service stop/restart status lines out of `openclaw update --json` stdout so package-update automation can parse the JSON payload.

src/gateway/server-methods/chat.directive-tags.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const mockState = vi.hoisted(() => ({
3131
replyToId?: string;
3232
replyToCurrent?: boolean;
3333
isReasoning?: boolean;
34+
isError?: boolean;
3435
} | null,
3536
dispatchedReplies: [] as Array<{
3637
kind: "tool" | "block" | "final";
@@ -45,6 +46,7 @@ const mockState = vi.hoisted(() => ({
4546
replyToId?: string;
4647
replyToCurrent?: boolean;
4748
isReasoning?: boolean;
49+
isError?: boolean;
4850
};
4951
}>,
5052
dispatchError: null as Error | null,
@@ -151,6 +153,7 @@ vi.mock("../../auto-reply/dispatch.js", () => ({
151153
replyToId?: string;
152154
replyToCurrent?: boolean;
153155
isReasoning?: boolean;
156+
isError?: boolean;
154157
}) => boolean;
155158
sendBlockReply: (payload: {
156159
text?: string;
@@ -162,6 +165,7 @@ vi.mock("../../auto-reply/dispatch.js", () => ({
162165
replyToId?: string;
163166
replyToCurrent?: boolean;
164167
isReasoning?: boolean;
168+
isError?: boolean;
165169
}) => boolean;
166170
sendToolResult: (payload: {
167171
text?: string;
@@ -173,6 +177,7 @@ vi.mock("../../auto-reply/dispatch.js", () => ({
173177
replyToId?: string;
174178
replyToCurrent?: boolean;
175179
isReasoning?: boolean;
180+
isError?: boolean;
176181
}) => boolean;
177182
markComplete: () => void;
178183
waitForIdle: () => Promise<void>;
@@ -965,6 +970,52 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
965970
expect(assistantEntries).toStrictEqual([]);
966971
});
967972

973+
it("broadcasts returned agent-run error payloads after an agent starts", async () => {
974+
createTranscriptFixture("openclaw-chat-send-agent-returned-error-");
975+
const errorMessage = "LLM idle timeout (120s): no response from model";
976+
mockState.triggerAgentRunStart = true;
977+
mockState.dispatchedReplies = [
978+
{
979+
kind: "final",
980+
payload: {
981+
text: errorMessage,
982+
isError: true,
983+
},
984+
},
985+
];
986+
const respond = vi.fn();
987+
const context = createChatContext();
988+
989+
const broadcast = await runNonStreamingChatSend({
990+
context,
991+
respond,
992+
idempotencyKey: "idem-agent-returned-error",
993+
message: "please keep working",
994+
});
995+
996+
expect(broadcast).toMatchObject({
997+
runId: "idem-agent-returned-error",
998+
sessionKey: "main",
999+
state: "error",
1000+
errorMessage,
1001+
});
1002+
const dedupe = context.dedupe.get("chat:idem-agent-returned-error");
1003+
expect(dedupe?.ok).toBe(false);
1004+
expect(dedupe?.payload).toMatchObject({
1005+
runId: "idem-agent-returned-error",
1006+
status: "error",
1007+
summary: errorMessage,
1008+
});
1009+
expect(findUserUpdate()).toBeDefined();
1010+
const assistantUpdates = mockState.emittedTranscriptUpdates.filter(
1011+
(update) =>
1012+
typeof update.message === "object" &&
1013+
update.message !== null &&
1014+
(update.message as { role?: unknown }).role === "assistant",
1015+
);
1016+
expect(assistantUpdates).toStrictEqual([]);
1017+
});
1018+
9681019
it("keeps visible text on non-agent TTS final media because no model transcript exists", async () => {
9691020
const transcriptDir = createTranscriptFixture("openclaw-chat-send-command-tts-final-");
9701021
const audioPath = path.join(transcriptDir, "tts.mp3");

src/gateway/server-methods/chat.ts

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2691,6 +2691,13 @@ export const chatHandlers: GatewayRequestHandlers = {
26912691
})();
26922692
await userTranscriptUpdatePromise;
26932693
};
2694+
const emitUserTranscriptUpdateAfterAgentRun = async () => {
2695+
await emitUserTranscriptUpdate().catch((transcriptErr) => {
2696+
context.logGateway.warn(
2697+
`webchat user transcript update failed after agent run: ${formatForLog(transcriptErr)}`,
2698+
);
2699+
});
2700+
};
26942701
let transcriptMediaRewriteDone = false;
26952702
const rewriteUserTranscriptMedia = async () => {
26962703
if (transcriptMediaRewriteDone) {
@@ -2895,6 +2902,16 @@ export const chatHandlers: GatewayRequestHandlers = {
28952902
"gateway.chat_send.post_dispatch",
28962903
async () => {
28972904
await rewriteUserTranscriptMedia();
2905+
const returnedAgentErrorPayloads = agentRunStarted
2906+
? deliveredReplies
2907+
.map((entry) => entry.payload)
2908+
.filter((payload) => payload.isError)
2909+
: [];
2910+
const returnedAgentErrorMessage =
2911+
returnedAgentErrorPayloads
2912+
.map((payload) => payload.text?.trim())
2913+
.filter((text): text is string => Boolean(text))
2914+
.join(" | ") || undefined;
28982915
// WebChat persistence has two owners. Agent runs persist model-visible turns
28992916
// through Pi's SessionManager; this dispatcher only owns live delivery payloads.
29002917
// Do not blindly mirror agent-run final payloads into JSONL or chat.history can
@@ -3091,21 +3108,42 @@ export const chatHandlers: GatewayRequestHandlers = {
30913108
message,
30923109
});
30933110
}
3094-
} else if (!hasBeforeAgentRunGate) {
3095-
await emitUserTranscriptUpdate().catch((transcriptErr) => {
3096-
context.logGateway.warn(
3097-
`webchat user transcript update failed after agent run: ${formatForLog(transcriptErr)}`,
3098-
);
3111+
} else if (returnedAgentErrorPayloads.length > 0) {
3112+
if (!hasBeforeAgentRunGate) {
3113+
await emitUserTranscriptUpdateAfterAgentRun();
3114+
}
3115+
broadcastChatError({
3116+
context,
3117+
runId: clientRunId,
3118+
sessionKey,
3119+
errorMessage: returnedAgentErrorMessage,
30993120
});
3121+
} else if (!hasBeforeAgentRunGate) {
3122+
await emitUserTranscriptUpdateAfterAgentRun();
31003123
}
31013124
if (!context.chatAbortedRuns.has(clientRunId)) {
3125+
const returnedAgentError =
3126+
returnedAgentErrorPayloads.length > 0
3127+
? errorShape(
3128+
ErrorCodes.UNAVAILABLE,
3129+
returnedAgentErrorMessage ?? "agent returned an error payload",
3130+
)
3131+
: undefined;
31023132
setGatewayDedupeEntry({
31033133
dedupe: context.dedupe,
31043134
key: `chat:${clientRunId}`,
31053135
entry: {
31063136
ts: Date.now(),
3107-
ok: true,
3108-
payload: { runId: clientRunId, status: "ok" as const },
3137+
ok: returnedAgentErrorPayloads.length === 0,
3138+
payload:
3139+
returnedAgentErrorPayloads.length > 0
3140+
? {
3141+
runId: clientRunId,
3142+
status: "error" as const,
3143+
summary: returnedAgentErrorMessage ?? "agent returned an error payload",
3144+
}
3145+
: { runId: clientRunId, status: "ok" as const },
3146+
...(returnedAgentError ? { error: returnedAgentError } : {}),
31093147
},
31103148
});
31113149
}

0 commit comments

Comments
 (0)