Compare commits

...

1 Commits

Author SHA1 Message Date
Alex Knight
5fa840c26f fix: surface message-tool-only diagnostics 2026-05-22 15:14:46 +10:00
12 changed files with 335 additions and 7 deletions

View File

@@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai
- Install/update: reject OpenClaw GitHub source package targets early and point moving-main users at the dev/git install path instead of the broken npm source-install flow.
- Gateway: mirror successful same-source message-tool sends into session transcripts so delivered replies stay in later history/context. (#84837) Thanks @iFiras-Max1.
- Infra/json: retry transient `File changed during read` races while loading JSON state so config and state reads recover instead of failing the turn. (#84285)
- Gateway/chat: surface message-tool-only room-event failures in chat diagnostics and session transcripts so suppressed source replies stay debuggable. Thanks @amknight.
- Plugins/providers: fail closed for workspace provider plugins during setup-mode discovery unless explicitly trusted, preventing untrusted workspace plugin code from running during provider setup. (#81069) Thanks @mmaps.
- Providers/Ollama: resolve configured Ollama Cloud `OLLAMA_API_KEY` markers to the real discovery key so cloud provider entries keep authenticated model catalog access. (#85037)
- Discord: keep persistent component registry fallback warnings actionable by forwarding structured error and cause metadata through the runtime logger. Fixes #84185. (#84190) Thanks @100menotu001.

View File

@@ -242,9 +242,15 @@ vi.mock("../logging/subsystem.js", () => ({
}));
vi.mock("../routing/session-key.js", () => ({
classifySessionKeyShape: (key?: string | null) => (key?.trim() ? "legacy_or_alias" : "missing"),
isSubagentSessionKey: () => false,
isUnscopedSessionKeySentinel: (key?: string | null) =>
["global", "unknown"].includes(key?.trim().toLowerCase() ?? ""),
normalizeAgentId: (id: string) => id,
normalizeMainKey: (key?: string | null) => key?.trim() || "main",
resolveAgentIdFromSessionKey: () => "main",
scopeLegacySessionKeyToAgent: ({ sessionKey }: { sessionKey?: string | null }) =>
sessionKey?.trim() || undefined,
}));
vi.mock("../runtime.js", () => ({

View File

@@ -582,6 +582,8 @@ async function agentCommandInternal(
const startedAt = Date.now();
registerAgentRunContext(runId, {
sessionKey,
sourceReplyDeliveryMode: opts.sourceReplyDeliveryMode,
suppressPromptPersistence: opts.suppressPromptPersistence === true,
});
attemptExecutionRuntime.emitAcpLifecycleStart({ runId, startedAt });
@@ -726,6 +728,8 @@ async function agentCommandInternal(
registerAgentRunContext(runId, {
sessionKey,
verboseLevel: resolvedVerboseLevel,
sourceReplyDeliveryMode: opts.sourceReplyDeliveryMode,
suppressPromptPersistence: opts.suppressPromptPersistence === true,
});
}

View File

@@ -1309,6 +1309,9 @@ export async function runAgentTurnWithFallback(params: {
verboseLevel: params.resolvedVerboseLevel,
isHeartbeat: params.isHeartbeat,
isControlUiVisible: shouldSurfaceToControlUi,
sourceReplyDeliveryMode: params.followupRun.run.sourceReplyDeliveryMode,
suppressPromptPersistence: params.followupRun.run.suppressNextUserMessagePersistence === true,
inboundEventKind: params.followupRun.currentInboundEventKind,
});
}
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;

View File

@@ -5443,6 +5443,63 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
});
it("mirrors suppressed message-tool-only room event failures into the transcript", async () => {
setNoAbort();
sessionStoreMocks.currentEntry = {
sessionId: "s1",
updatedAt: 0,
sendPolicy: "allow",
};
const dispatcher = createDispatcher();
const failureReply = {
text: "agent failed before sending through message",
isError: true,
} satisfies ReplyPayload;
const replyResolver = vi.fn(async () => failureReply);
const ctx = buildTestCtx({
ChatType: "group",
InboundEventKind: "room_event",
MessageSid: "telegram-msg-1",
SessionKey: "test:session",
});
const result = await dispatchReplyFromConfig({
ctx,
cfg: emptyConfig,
dispatcher,
replyResolver,
replyOptions: {
runId: "run-1",
sourceReplyDeliveryMode: "message_tool_only",
},
});
expect(replyResolver).toHaveBeenCalledTimes(1);
expect(result.queuedFinal).toBe(false);
expect(result.sourceReplyDeliveryMode).toBe("message_tool_only");
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
expect(transcriptMocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith({
sessionKey: "test:session",
agentId: "main",
text: expect.stringContaining(
"Message-tool-only room event failed before a visible source reply was delivered.",
),
idempotencyKey: "run-1:message-tool-only-failure-diagnostic",
updateMode: "inline",
config: emptyConfig,
});
const transcriptText = (
transcriptMocks.appendAssistantMessageToSessionTranscript.mock.calls[0]?.[0] as {
text?: string;
}
)?.text;
expect(transcriptText).toContain("The inbound prompt was not saved to chat history by design");
expect(transcriptText).toContain(
"Suppressed error: agent failed before sending through message",
);
expect(transcriptText).toContain("Diagnostics: inbound=room_event, mode=message_tool_only.");
});
it("mirrors internal source reply payloads into the active transcript", async () => {
setNoAbort();
sessionStoreMocks.currentEntry = {

View File

@@ -2137,11 +2137,53 @@ export async function dispatchReplyFromConfig(
let routedFinalCount = 0;
let attemptedFinalDelivery = false;
let finalDeliveryFailed = false;
let mirroredSuppressedSourceReplyFailure = false;
const shouldDeliverDespiteSourceReplySuppression = (reply: ReplyPayload) =>
suppressAutomaticSourceDelivery &&
ctx.InboundEventKind !== "room_event" &&
!sendPolicyDenied &&
getReplyPayloadMetadata(reply)?.deliverDespiteSourceReplySuppression === true;
const mirrorSuppressedSourceReplyFailureToTranscript = async (reply: ReplyPayload) => {
const transcriptSessionKey = acpDispatchSessionKey ?? sessionKey;
if (
mirroredSuppressedSourceReplyFailure ||
!transcriptSessionKey ||
sourceReplyDeliveryMode !== "message_tool_only" ||
ctx.InboundEventKind !== "room_event" ||
reply.isError !== true ||
sendPolicyDenied
) {
return;
}
mirroredSuppressedSourceReplyFailure = true;
const replyText = reply.text?.trim();
const diagnosticText = [
"OpenClaw diagnostic: Message-tool-only room event failed before a visible source reply was delivered.",
"The inbound prompt was not saved to chat history by design, and automatic room fallback delivery was suppressed.",
"A visible reply for this turn must be sent through the `message` tool in the originating client.",
replyText ? `Suppressed error: ${replyText}` : "",
"Diagnostics: inbound=room_event, mode=message_tool_only.",
]
.filter(Boolean)
.join(" ");
const idempotencySource =
params.replyOptions?.runId ?? ctx.MessageSidFull ?? ctx.MessageSid ?? undefined;
const result = await appendAssistantMessageToSessionTranscript({
sessionKey: transcriptSessionKey,
agentId: sessionAgentId,
text: diagnosticText,
...(idempotencySource
? { idempotencyKey: `${idempotencySource}:message-tool-only-failure-diagnostic` }
: {}),
updateMode: "inline",
config: cfg,
});
if (!result.ok) {
logVerbose(
`dispatch-from-config: message-tool-only failure diagnostic mirror skipped: ${result.reason}`,
);
}
};
for (const reply of replies) {
throwIfDispatchOperationAborted();
// Suppress reasoning payloads from channel delivery — channels using this
@@ -2164,6 +2206,7 @@ export async function dispatchReplyFromConfig(
].join(" "),
);
}
await mirrorSuppressedSourceReplyFailureToTranscript(reply);
continue;
}
attemptedFinalDelivery = true;

View File

@@ -2143,6 +2143,62 @@ describe("agent event handler", () => {
expect(nodePayload.errorKind).toBe("rate_limit");
});
it("surfaces message-tool-only non-deliverable ends as chat diagnostics", () => {
const { broadcast, nodeSendToSession, clearAgentRunContext, handler } = createHarness({
lifecycleErrorRetryGraceMs: 0,
resolveSessionKeyForRun: () => "session-room-event-hidden",
});
registerAgentRunContext("run-room-event-hidden", {
sessionKey: "session-room-event-hidden",
sourceReplyDeliveryMode: "message_tool_only",
suppressPromptPersistence: true,
inboundEventKind: "room_event",
});
handler({
runId: "run-room-event-hidden",
seq: 1,
stream: "lifecycle",
ts: Date.now(),
data: {
phase: "end",
stopReason: "toolUse",
livenessState: "abandoned",
replayInvalid: true,
},
});
const payload = requireRecord(
chatBroadcastCalls(broadcast).at(-1)?.[1],
"message-tool-only chat payload",
);
expect(payload.state).toBe("error");
expect(payload.sessionKey).toBe("session-room-event-hidden");
expect(payload.errorMessage).toContain(
"Message-tool-only turn ended without a visible source reply.",
);
expect(payload.errorMessage).toContain("The inbound prompt was not saved to chat history");
expect(payload.errorMessage).toContain("inbound=room_event");
expect(payload.errorMessage).toContain("stopReason=toolUse");
const message = requireRecord(payload.message, "message-tool-only diagnostic message");
expect(message.role).toBe("assistant");
expect(message.stopReason).toBe("error");
expect(message.content).toEqual([
{
type: "text",
text: payload.errorMessage,
},
]);
const nodePayload = requireRecord(
sessionChatCalls(nodeSendToSession).at(-1)?.[2],
"message-tool-only node payload",
);
expect(nodePayload.errorMessage).toBe(payload.errorMessage);
expect(clearAgentRunContext).toHaveBeenCalledWith("run-room-event-hidden");
});
it("suppresses delayed lifecycle chat errors for active chat.send runs while still cleaning up", () => {
vi.useFakeTimers();
const { broadcast, clearAgentRunContext, agentRunSeq, handler } = createHarness({

View File

@@ -2,7 +2,11 @@ import { resolveToolSearchCodeDisplayTarget } from "../agents/tool-display-commo
import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, stripHeartbeatToken } from "../auto-reply/heartbeat.js";
import { normalizeVerboseLevel } from "../auto-reply/thinking.js";
import { getRuntimeConfig } from "../config/io.js";
import { type AgentEventPayload, getAgentRunContext } from "../infra/agent-events.js";
import {
type AgentEventPayload,
type AgentRunContext,
getAgentRunContext,
} from "../infra/agent-events.js";
import { detectErrorKind, type ErrorKind } from "../infra/errors.js";
import { resolveHeartbeatVisibility } from "../infra/heartbeat-visibility.js";
import { isAcpSessionKey, isSubagentSessionKey } from "../sessions/session-key-utils.js";
@@ -175,6 +179,79 @@ function readChatErrorKind(value: unknown): ErrorKind | undefined {
: undefined;
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
type MessageToolOnlyLifecycleDiagnostic = {
text: string;
treatAsError: boolean;
};
function buildDiagnosticChatMessage(text: string) {
return {
role: "assistant",
content: [{ type: "text", text }],
timestamp: Date.now(),
stopReason: "error",
};
}
function appendDiagnosticMessage(errorMessage: string | undefined, diagnostic: string): string {
const trimmedError = errorMessage?.trim();
if (!trimmedError || trimmedError === diagnostic) {
return diagnostic;
}
return `${trimmedError}\n\n${diagnostic}`;
}
function resolveMessageToolOnlyLifecycleDiagnostic(params: {
runContext?: AgentRunContext;
lifecyclePhase: "end" | "error";
evt: AgentEventPayload;
stopReason?: string;
}): MessageToolOnlyLifecycleDiagnostic | undefined {
if (params.runContext?.sourceReplyDeliveryMode !== "message_tool_only") {
return undefined;
}
const livenessState = readString(params.evt.data?.livenessState);
const inboundEventKind = readString(params.runContext.inboundEventKind);
const replayInvalid = params.evt.data?.replayInvalid === true;
const stopReason = params.stopReason ?? readString(params.evt.data?.stopReason);
const nonDeliverableEnd =
params.lifecyclePhase === "end" &&
(livenessState === "abandoned" || (replayInvalid && stopReason === "toolUse"));
if (params.lifecyclePhase !== "error" && !nonDeliverableEnd) {
return undefined;
}
const promptSuppressed =
params.runContext.suppressPromptPersistence === true || inboundEventKind === "room_event";
const outcome =
params.lifecyclePhase === "error"
? "Message-tool-only turn failed."
: "Message-tool-only turn ended without a visible source reply.";
const promptVisibility = promptSuppressed
? "The inbound prompt was not saved to chat history by design."
: "Automatic source delivery was suppressed by policy.";
const diagnostics = [
inboundEventKind ? `inbound=${inboundEventKind}` : null,
"mode=message_tool_only",
stopReason ? `stopReason=${stopReason}` : null,
livenessState ? `liveness=${livenessState}` : null,
replayInvalid ? "replayInvalid=true" : null,
].filter((value): value is string => Boolean(value));
const diagnosticsText = diagnostics.length ? ` Diagnostics: ${diagnostics.join(", ")}.` : "";
return {
treatAsError: true,
text:
`OpenClaw diagnostic: ${outcome} ${promptVisibility} ` +
"A visible reply for this turn must be sent through the `message` tool in the originating client." +
diagnosticsText,
};
}
type BroadcastDelta = { deltaText: string; replace?: true };
function resolveBroadcastDelta(params: {
@@ -377,7 +454,8 @@ export function createAgentEventHandler({
const chatLink = chatRunState.registry.peek(evt.runId);
const eventSessionKey =
typeof evt.sessionKey === "string" && evt.sessionKey.trim() ? evt.sessionKey : undefined;
const isControlUiVisible = getAgentRunContext(evt.runId)?.isControlUiVisible ?? true;
const runContext = getAgentRunContext(evt.runId);
const isControlUiVisible = runContext?.isControlUiVisible ?? true;
const sessionKey =
chatLink?.sessionKey ?? eventSessionKey ?? resolveSessionKeyForRun(evt.runId);
const clientRunId = chatLink?.clientRunId ?? evt.runId;
@@ -391,6 +469,16 @@ export function createAgentEventHandler({
typeof evt.data?.stopReason === "string" ? evt.data.stopReason : undefined;
const evtErrorKind =
readChatErrorKind(evt.data?.errorKind) ?? detectErrorKind(evt.data?.error);
const sourceReplyDiagnostic = resolveMessageToolOnlyLifecycleDiagnostic({
runContext,
lifecyclePhase,
evt,
stopReason: evtStopReason,
});
const chatJobState =
lifecyclePhase === "error" || sourceReplyDiagnostic?.treatAsError === true
? "error"
: "done";
if (chatLink) {
const finished = chatRunState.registry.shift(evt.runId);
if (!finished) {
@@ -403,10 +491,11 @@ export function createAgentEventHandler({
finished.clientRunId,
evt.runId,
evt.seq,
lifecyclePhase === "error" ? "error" : "done",
chatJobState,
evt.data?.error,
evtStopReason,
evtErrorKind,
sourceReplyDiagnostic?.text,
);
}
} else if (!(opts?.skipChatErrorFinal && lifecyclePhase === "error")) {
@@ -415,10 +504,11 @@ export function createAgentEventHandler({
eventRunId,
evt.runId,
evt.seq,
lifecyclePhase === "error" ? "error" : "done",
chatJobState,
evt.data?.error,
evtStopReason,
evtErrorKind,
sourceReplyDiagnostic?.text,
);
}
} else {
@@ -609,6 +699,7 @@ export function createAgentEventHandler({
error?: unknown,
stopReason?: string,
errorKind?: ErrorKind,
diagnosticMessage?: string,
) => {
const { text, shouldSuppressSilent } = resolveBufferedChatTextState(clientRunId, sourceRunId, {
suppressLeadFragments: false,
@@ -626,6 +717,8 @@ export function createAgentEventHandler({
clearAgentTextThrottleState(clientRunId);
const spawnedBy = resolveSpawnedBy(sessionKey);
if (jobState === "done") {
const finalText =
diagnosticMessage && text ? `${text}\n\n${diagnosticMessage}` : diagnosticMessage || text;
const payload = {
runId: clientRunId,
sessionKey,
@@ -634,11 +727,12 @@ export function createAgentEventHandler({
state: "final" as const,
...(stopReason && { stopReason }),
message:
text && !shouldSuppressSilent
finalText && !shouldSuppressSilent
? {
role: "assistant",
content: [{ type: "text", text }],
content: [{ type: "text", text: finalText }],
timestamp: Date.now(),
...(diagnosticMessage ? { stopReason: "error" } : {}),
}
: undefined,
};
@@ -646,14 +740,19 @@ export function createAgentEventHandler({
nodeSendToSession(sessionKey, "chat", payload);
return;
}
const formattedError = error ? formatForLog(error) : undefined;
const errorMessage = diagnosticMessage
? appendDiagnosticMessage(formattedError, diagnosticMessage)
: formattedError;
const payload = {
runId: clientRunId,
sessionKey,
...(spawnedBy && { spawnedBy }),
seq,
state: "error" as const,
errorMessage: error ? formatForLog(error) : undefined,
errorMessage,
...(errorKind && { errorKind }),
...(diagnosticMessage ? { message: buildDiagnosticChatMessage(diagnosticMessage) } : {}),
};
broadcast("chat", payload);
nodeSendToSession(sessionKey, "chat", payload);

View File

@@ -149,6 +149,9 @@ describe("agent-events sequencing", () => {
registerAgentRunContext("run-ctx", {
verboseLevel: "full",
isHeartbeat: true,
sourceReplyDeliveryMode: "message_tool_only",
suppressPromptPersistence: true,
inboundEventKind: "room_event",
lastActiveAt: 12_345,
});
@@ -157,6 +160,9 @@ describe("agent-events sequencing", () => {
expect(context?.verboseLevel).toBe("full");
expect(context?.isHeartbeat).toBe(true);
expect(context?.isControlUiVisible).toBe(true);
expect(context?.sourceReplyDeliveryMode).toBe("message_tool_only");
expect(context?.suppressPromptPersistence).toBe(true);
expect(context?.inboundEventKind).toBe("room_event");
expect(context?.lastActiveAt).toBe(12_345);
});

View File

@@ -112,6 +112,12 @@ export type AgentRunContext = {
sessionKey?: string;
verboseLevel?: VerboseLevel;
isHeartbeat?: boolean;
/** Source reply visibility contract active for this run. */
sourceReplyDeliveryMode?: "automatic" | "message_tool_only";
/** The current user prompt is runtime context only and should not appear in chat.history. */
suppressPromptPersistence?: boolean;
/** Channel inbound turn classification, when known. */
inboundEventKind?: "user_request" | "room_event" | (string & {});
/** Whether control UI clients should receive chat/agent updates for this run. */
isControlUiVisible?: boolean;
/** Timestamp when this context was first registered (for TTL-based cleanup). */
@@ -161,6 +167,15 @@ export function registerAgentRunContext(runId: string, context: AgentRunContext)
if (context.isHeartbeat !== undefined && existing.isHeartbeat !== context.isHeartbeat) {
existing.isHeartbeat = context.isHeartbeat;
}
if (context.sourceReplyDeliveryMode !== undefined) {
existing.sourceReplyDeliveryMode = context.sourceReplyDeliveryMode;
}
if (context.suppressPromptPersistence !== undefined) {
existing.suppressPromptPersistence = context.suppressPromptPersistence;
}
if (context.inboundEventKind !== undefined) {
existing.inboundEventKind = context.inboundEventKind;
}
if (context.registeredAt !== undefined) {
existing.registeredAt = context.registeredAt;
}

View File

@@ -456,6 +456,40 @@ describe("handleChatEvent", () => {
},
);
it("appends diagnostic messages carried by chat error events", () => {
const state = createState({
sessionKey: "main",
chatRunId: "run-1",
chatStream: "",
chatStreamStartedAt: 100,
});
const diagnosticMessage = {
role: "assistant",
content: [
{
type: "text",
text: "OpenClaw diagnostic: message-tool-only turn failed.",
},
],
stopReason: "error",
};
expect(
handleChatEvent(state, {
runId: "run-1",
sessionKey: "main",
state: "error",
errorMessage: "message-tool-only turn failed",
message: diagnosticMessage,
}),
).toBe("error");
expect(state.chatMessages).toEqual([diagnosticMessage]);
expect(state.lastError).toBe("message-tool-only turn failed");
expect(state.chatRunId).toBeNull();
expect(state.chatStream).toBeNull();
});
it("persists streamed text when final event carries no message", () => {
const existingMessage = {
role: "user",

View File

@@ -752,6 +752,10 @@ export function handleChatEvent(state: ChatState, payload?: ChatEventPayload) {
}
reconcileTerminalRun("interrupted", "killed");
} else if (payload.state === "error") {
const diagnosticMessage = normalizeFinalAssistantMessage(payload.message);
if (diagnosticMessage && !shouldHideAssistantChatMessage(diagnosticMessage)) {
state.chatMessages = [...state.chatMessages, diagnosticMessage];
}
reconcileTerminalRun("interrupted", "failed");
state.lastError = payload.errorMessage ?? "chat error";
}