fix(feishu): fall back from missing thread replies (#80306)

Summary:
- The branch adds an opt-in Feishu top-level group-send fallback for withdrawn or missing normal quoted thread replies, plus regression coverage, a changelog entry, and CI/lint typing and baseline refreshes.
- Reproducibility: yes. at source level. Current main hard-errors withdrawn/not-found Feishu reply targets when `replyInThread` is true, and the existing regression test asserts that no top-level create fallback occurs.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(feishu): fall back from missing thread replies
- PR branch already contained follow-up commit before automerge: fix(clawsweeper): address review for automerge-openclaw-openclaw-8030…
- PR branch already contained follow-up commit before automerge: fix(clawsweeper): reconcile automerge-openclaw-openclaw-80306 with ma…
- PR branch already contained follow-up commit before automerge: fix(ci): satisfy stricter lint and test types
- PR branch already contained follow-up commit before automerge: fix(ci): align Node 24 test typing

Validation:
- ClawSweeper review passed for head 93146f9d13.
- Required merge gates passed before the squash merge.

Prepared head SHA: 93146f9d13
Review: https://github.com/openclaw/openclaw/pull/80306#issuecomment-4415604729

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
This commit is contained in:
Peter Steinberger 2026-05-10 17:41:51 +01:00 committed by GitHub
parent 5af8fc0d52
commit f9c0dc2d2b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 421 additions and 167 deletions

View file

@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai
- Telegram: pass agent-scoped media roots through gateway message actions so workspace-local media from the active agent is not rejected as cross-agent access. Thanks @frankekn.
- CLI/gateway: keep `gateway status --deep` plugin-aware so configured plugin manifest warnings, including missing channel config metadata, stay visible during install and update smoke checks.
- Feishu: fall back to a top-level group send when normal group quoted replies target a withdrawn or missing message, preventing replies from disappearing silently while preserving native topic safety. Fixes #79349. Thanks @arlen8411.
- Doctor: stop flagging the live compatibility agent directory as orphaned when the configured default agent is not `main`. Fixes #74313. (#74438) Thanks @carlos4s.
- Auth/Claude CLI: persist fresher managed external CLI OAuth credentials back to `auth-profiles.json`, preventing stale `anthropic:claude-cli` profiles from repeatedly bootstrapping and flooding debug logs. Fixes #80129. Thanks @Caulderein.
- Context: render `/context map` only from actual run context and persist Codex app-server run reports without counting deferred tool-search schemas as prompt-loaded tool schemas.

View file

@ -206,12 +206,16 @@ function requireRecord(value: unknown, label: string): Record<string, unknown> {
return value as Record<string, unknown>;
}
function requireFirstCallArg<T>(mock: unknown, label: string, _type?: (value: T) => T): T {
function optionalString(value: unknown): string {
return typeof value === "string" ? value : "";
}
function requireFirstCallArg(mock: unknown, label: string): unknown {
const call = (mock as MockCallReader).mock.calls.at(0);
if (!call) {
throw new Error(`expected ${label} to be called`);
}
return call[0] as T;
return call[0];
}
function requireRequestParams(
@ -238,7 +242,7 @@ function expectRequestInputTextContains(
expect(
input.some((entry) => {
const item = requireRecord(entry, "turn/start input entry");
return item.type === "text" && typeof item.text === "string" && item.text.includes(expected);
return item.type === "text" && optionalString(item.text).includes(expected);
}),
).toBe(true);
}
@ -275,18 +279,17 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
throw new Error("expected bootstrap hook");
}
expect(contextEngine.bootstrap).toHaveBeenCalledTimes(1);
const bootstrapParams = requireFirstCallArg<
Parameters<NonNullable<ContextEngine["bootstrap"]>>[0]
>(contextEngine.bootstrap, "bootstrap");
const bootstrapParams = requireFirstCallArg(contextEngine.bootstrap, "bootstrap") as Parameters<
NonNullable<ContextEngine["bootstrap"]>
>[0];
expect(bootstrapParams.sessionId).toBe("session-1");
expect(bootstrapParams.sessionKey).toBe("agent:main:session-1");
expect(bootstrapParams.sessionFile).toBe(sessionFile);
expect(contextEngine.assemble).toHaveBeenCalledTimes(1);
const assembleParams = requireFirstCallArg<Parameters<ContextEngine["assemble"]>[0]>(
contextEngine.assemble,
"assemble",
);
const assembleParams = requireFirstCallArg(contextEngine.assemble, "assemble") as Parameters<
ContextEngine["assemble"]
>[0];
expect(assembleParams.sessionId).toBe("session-1");
expect(assembleParams.sessionKey).toBe("agent:main:session-1");
expect(assembleParams.tokenBudget).toBe(321);
@ -297,8 +300,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
expect(assembleParams.availableTools).toEqual(new Set());
const threadStartParams = requireRequestParams(harness, "thread/start");
const developerInstructions = threadStartParams.developerInstructions;
expect(typeof developerInstructions === "string" ? developerInstructions : "").toContain(
expect(optionalString(threadStartParams.developerInstructions)).toContain(
"context-engine system",
);
expectRequestInputTextContains(harness, "OpenClaw assembled context for this turn:");
@ -327,9 +329,9 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
await run;
expect(afterTurn).toHaveBeenCalledTimes(1);
const afterTurnCall = requireFirstCallArg<
Parameters<NonNullable<ContextEngine["afterTurn"]>>[0]
>(afterTurn, "afterTurn");
const afterTurnCall = requireFirstCallArg(afterTurn, "afterTurn") as Parameters<
NonNullable<ContextEngine["afterTurn"]>
>[0];
expect(afterTurnCall.sessionId).toBe("session-1");
expect(afterTurnCall.sessionKey).toBe("agent:main:session-1");
expect(afterTurnCall.prePromptMessageCount).toBe(0);
@ -370,17 +372,16 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
await harness.completeTurn();
await run;
const assembleParams = requireFirstCallArg<Parameters<ContextEngine["assemble"]>[0]>(
contextEngine.assemble,
"assemble",
);
const assembleParams = requireFirstCallArg(contextEngine.assemble, "assemble") as Parameters<
ContextEngine["assemble"]
>[0];
expect(assembleParams.messages.map((message) => message.role)).toEqual([
"assistant",
"assistant",
]);
const afterTurnParams = requireFirstCallArg<
Parameters<NonNullable<ContextEngine["afterTurn"]>>[0]
>(afterTurn, "afterTurn");
const afterTurnParams = requireFirstCallArg(afterTurn, "afterTurn") as Parameters<
NonNullable<ContextEngine["afterTurn"]>
>[0];
expect(afterTurnParams.prePromptMessageCount).toBe(2);
expectRequestInputTextContains(harness, "bootstrap context");
});

View file

@ -81,9 +81,9 @@ describe("PlaywrightDiffScreenshotter", () => {
expect(launchMock).toHaveBeenCalledTimes(1);
expect(browser.newPage).toHaveBeenCalledTimes(2);
const firstPageParams = browser.newPage.mock.calls[0]?.[0] as
| { deviceScaleFactor?: number }
| undefined;
const firstPageParams = (
browser.newPage.mock.calls as Array<[{ deviceScaleFactor?: number }?]>
)[0]?.[0];
expect(firstPageParams?.deviceScaleFactor).toBe(2);
expect(pages).toHaveLength(2);
expect(pages[0]?.close).toHaveBeenCalledTimes(1);

View file

@ -105,9 +105,10 @@ function createConfigWithDiscordAccount(overrides: Record<string, unknown> = {})
type MockCallReader = { mock: { calls: unknown[][] } };
function mockMessages(mock: unknown): string[] {
return (mock as MockCallReader).mock.calls.map((call) =>
typeof call[0] === "string" ? call[0] : "",
);
return (mock as MockCallReader).mock.calls.map((call) => {
const message = call[0];
return typeof message === "string" ? message : "";
});
}
function expectMockLogContains(mock: unknown, expected: string): void {

View file

@ -926,6 +926,38 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
});
});
it("allows top-level fallback for normal group quoted replies", async () => {
const { options } = createDispatcherHarness({
replyToMessageId: "om_quote_reply",
replyInThread: true,
threadReply: true,
rootId: "om_original_msg",
});
await options.deliver({ text: "plain text" }, { kind: "final" });
expectMockArgFields(sendMessageFeishuMock, "message send params", {
replyToMessageId: "om_quote_reply",
replyInThread: true,
allowTopLevelReplyFallback: true,
});
});
it("keeps native topic replies opted out of top-level fallback", async () => {
const { options } = createDispatcherHarness({
replyToMessageId: "om_topic_root",
replyInThread: true,
threadReply: true,
rootId: "om_topic_root",
});
await options.deliver({ text: "plain text" }, { kind: "final" });
expectMockArgFields(sendMessageFeishuMock, "message send params", {
replyToMessageId: "om_topic_root",
replyInThread: true,
allowTopLevelReplyFallback: false,
});
});
it("passes replyInThread to sendStructuredCardFeishu for card text", async () => {
resolveFeishuAccountMock.mockReturnValue({
accountId: "main",

View file

@ -147,6 +147,12 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
const sendReplyToMessageId = skipReplyToInMessages ? undefined : replyToMessageId;
const threadReplyMode = threadReply === true;
const effectiveReplyInThread = threadReplyMode ? true : replyInThread;
const allowTopLevelReplyFallback =
effectiveReplyInThread === true &&
threadReplyMode &&
rootId !== undefined &&
sendReplyToMessageId !== undefined &&
sendReplyToMessageId !== rootId;
const account = resolveFeishuRuntimeAccount({ cfg, accountId });
const prefixContext = createReplyPrefixContext({ cfg, agentId });
@ -465,6 +471,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
text: chunk,
replyToMessageId: sendReplyToMessageId,
replyInThread: effectiveReplyInThread,
allowTopLevelReplyFallback,
accountId,
});
},
@ -491,6 +498,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
text: chunk,
replyToMessageId: sendReplyToMessageId,
replyInThread: effectiveReplyInThread,
allowTopLevelReplyFallback,
accountId,
});
},
@ -605,6 +613,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
text: chunk,
replyToMessageId: sendReplyToMessageId,
replyInThread: effectiveReplyInThread,
allowTopLevelReplyFallback,
accountId,
header: cardHeader,
note: cardNote,
@ -623,6 +632,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
text: chunk,
replyToMessageId: sendReplyToMessageId,
replyInThread: effectiveReplyInThread,
allowTopLevelReplyFallback,
accountId,
});
},

View file

@ -211,7 +211,91 @@ describe("Feishu reply fallback for withdrawn/deleted targets", () => {
expect(createMock).not.toHaveBeenCalled();
});
it("fails thread replies instead of falling back to a top-level send", async () => {
it("falls back to a top-level group send when normal quoted replies target withdrawn messages", async () => {
resolveFeishuSendTargetMock.mockReturnValue({
client: {
im: {
message: {
reply: replyMock,
create: createMock,
},
},
},
receiveId: "oc_group_1",
receiveIdType: "chat_id",
});
replyMock.mockResolvedValue({
code: 230011,
msg: "The message was withdrawn.",
});
createMock.mockResolvedValue({
code: 0,
data: { message_id: "om_group_fallback" },
});
await expectFallbackResult(
() =>
sendMessageFeishu({
cfg: {} as never,
to: "chat:oc_group_1",
text: "hello",
replyToMessageId: "om_parent",
replyInThread: true,
allowTopLevelReplyFallback: true,
}),
"om_group_fallback",
);
expect(replyMock).toHaveBeenCalledWith({
path: { message_id: "om_parent" },
data: expect.objectContaining({
reply_in_thread: true,
}),
});
expect(createMock).toHaveBeenCalledWith({
params: { receive_id_type: "chat_id" },
data: expect.objectContaining({
receive_id: "oc_group_1",
msg_type: "post",
}),
});
});
it("falls back to create when normal quoted replies throw withdrawn errors", async () => {
resolveFeishuSendTargetMock.mockReturnValue({
client: {
im: {
message: {
reply: replyMock,
create: createMock,
},
},
},
receiveId: "oc_group_1",
receiveIdType: "chat_id",
});
const sdkError = Object.assign(new Error("request failed"), { code: 230011 });
replyMock.mockRejectedValue(sdkError);
createMock.mockResolvedValue({
code: 0,
data: { message_id: "om_thrown_thread_fallback" },
});
await expectFallbackResult(
() =>
sendMessageFeishu({
cfg: {} as never,
to: "chat:oc_group_1",
text: "hello",
replyToMessageId: "om_parent",
replyInThread: true,
allowTopLevelReplyFallback: true,
}),
"om_thrown_thread_fallback",
);
});
it("fails native thread replies instead of falling back to a top-level send", async () => {
replyMock.mockResolvedValue({
code: 230011,
msg: "The message was withdrawn.",
@ -230,15 +314,9 @@ describe("Feishu reply fallback for withdrawn/deleted targets", () => {
);
expect(createMock).not.toHaveBeenCalled();
expect(replyMock).toHaveBeenCalledWith({
path: { message_id: "om_parent" },
data: expect.objectContaining({
reply_in_thread: true,
}),
});
});
it("fails thrown withdrawn thread replies instead of falling back to create", async () => {
it("fails thrown withdrawn native thread replies instead of falling back to create", async () => {
const sdkError = Object.assign(new Error("request failed"), { code: 230011 });
replyMock.mockRejectedValue(sdkError);
@ -257,6 +335,44 @@ describe("Feishu reply fallback for withdrawn/deleted targets", () => {
expect(createMock).not.toHaveBeenCalled();
});
it("preserves non-withdrawn thread reply failures", async () => {
replyMock.mockResolvedValue({
code: 999999,
msg: "unknown failure",
});
await expect(
sendMessageFeishu({
cfg: {} as never,
to: "chat:oc_group_1",
text: "hello",
replyToMessageId: "om_parent",
replyInThread: true,
allowTopLevelReplyFallback: true,
}),
).rejects.toThrow("Feishu reply failed");
expect(createMock).not.toHaveBeenCalled();
});
it("preserves thrown non-withdrawn thread reply failures", async () => {
const sdkError = Object.assign(new Error("rate limited"), { code: 99991400 });
replyMock.mockRejectedValue(sdkError);
await expect(
sendMessageFeishu({
cfg: {} as never,
to: "chat:oc_group_1",
text: "hello",
replyToMessageId: "om_parent",
replyInThread: true,
allowTopLevelReplyFallback: true,
}),
).rejects.toThrow("rate limited");
expect(createMock).not.toHaveBeenCalled();
});
it("still falls back for non-thread replies to withdrawn targets", async () => {
replyMock.mockResolvedValue({
code: 230011,

View file

@ -144,6 +144,7 @@ async function sendReplyOrFallbackDirect(
params: {
replyToMessageId?: string;
replyInThread?: boolean;
allowTopLevelReplyFallback?: boolean;
content: string;
msgType: string;
directParams: {
@ -160,11 +161,12 @@ async function sendReplyOrFallbackDirect(
return sendFallbackDirect(client, params.directParams, params.directErrorPrefix);
}
const threadReplyFallbackError = params.replyInThread
? new Error(
"Feishu thread reply failed: reply target is unavailable and cannot safely fall back to a top-level send.",
)
: null;
const replyTargetFallbackError =
params.replyInThread && params.allowTopLevelReplyFallback !== true
? new Error(
"Feishu thread reply failed: reply target is unavailable and cannot safely fall back to a top-level send.",
)
: null;
let response: { code?: number; msg?: string; data?: { message_id?: string } };
try {
@ -180,14 +182,14 @@ async function sendReplyOrFallbackDirect(
if (!isWithdrawnReplyError(err)) {
throw createFeishuApiError(err, params.replyErrorPrefix, { includeNestedErrorLogId: true });
}
if (threadReplyFallbackError) {
throw threadReplyFallbackError;
if (replyTargetFallbackError) {
throw replyTargetFallbackError;
}
return sendFallbackDirect(client, params.directParams, params.directErrorPrefix);
}
if (shouldFallbackFromReplyTarget(response)) {
if (threadReplyFallbackError) {
throw threadReplyFallbackError;
if (replyTargetFallbackError) {
throw replyTargetFallbackError;
}
return sendFallbackDirect(client, params.directParams, params.directErrorPrefix);
}
@ -526,6 +528,7 @@ export type SendFeishuMessageParams = {
replyToMessageId?: string;
/** When true, reply creates a Feishu topic thread instead of an inline reply */
replyInThread?: boolean;
allowTopLevelReplyFallback?: boolean;
/** Mention target users */
mentions?: MentionTarget[];
/** Account ID (optional, uses default if not specified) */
@ -557,7 +560,16 @@ export function buildFeishuPostMessagePayload(params: { messageText: string }):
export async function sendMessageFeishu(
params: SendFeishuMessageParams,
): Promise<FeishuSendResult> {
const { cfg, to, text, replyToMessageId, replyInThread, mentions, accountId } = params;
const {
cfg,
to,
text,
replyToMessageId,
replyInThread,
allowTopLevelReplyFallback,
mentions,
accountId,
} = params;
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId });
const tableMode = resolveMarkdownTableMode({
cfg,
@ -577,6 +589,7 @@ export async function sendMessageFeishu(
return sendReplyOrFallbackDirect(client, {
replyToMessageId,
replyInThread,
allowTopLevelReplyFallback,
content,
msgType,
directParams,
@ -592,11 +605,13 @@ export type SendFeishuCardParams = {
replyToMessageId?: string;
/** When true, reply creates a Feishu topic thread instead of an inline reply */
replyInThread?: boolean;
allowTopLevelReplyFallback?: boolean;
accountId?: string;
};
export async function sendCardFeishu(params: SendFeishuCardParams): Promise<FeishuSendResult> {
const { cfg, to, card, replyToMessageId, replyInThread, accountId } = params;
const { cfg, to, card, replyToMessageId, replyInThread, allowTopLevelReplyFallback, accountId } =
params;
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId });
const content = JSON.stringify(card);
@ -604,6 +619,7 @@ export async function sendCardFeishu(params: SendFeishuCardParams): Promise<Feis
return sendReplyOrFallbackDirect(client, {
replyToMessageId,
replyInThread,
allowTopLevelReplyFallback,
content,
msgType: "interactive",
directParams,
@ -768,19 +784,38 @@ export async function sendStructuredCardFeishu(params: {
replyToMessageId?: string;
/** When true, reply creates a Feishu topic thread instead of an inline reply */
replyInThread?: boolean;
allowTopLevelReplyFallback?: boolean;
mentions?: MentionTarget[];
accountId?: string;
header?: CardHeaderConfig;
note?: string;
}): Promise<FeishuSendResult> {
const { cfg, to, text, replyToMessageId, replyInThread, mentions, accountId, header, note } =
params;
const {
cfg,
to,
text,
replyToMessageId,
replyInThread,
allowTopLevelReplyFallback,
mentions,
accountId,
header,
note,
} = params;
let cardText = text;
if (mentions && mentions.length > 0) {
cardText = buildMentionedCardContent(mentions, text);
}
const card = buildStructuredCard(cardText, { header, note });
return sendCardFeishu({ cfg, to, card, replyToMessageId, replyInThread, accountId });
return sendCardFeishu({
cfg,
to,
card,
replyToMessageId,
replyInThread,
allowTopLevelReplyFallback,
accountId,
});
}
/**
@ -794,15 +829,33 @@ export async function sendMarkdownCardFeishu(params: {
replyToMessageId?: string;
/** When true, reply creates a Feishu topic thread instead of an inline reply */
replyInThread?: boolean;
allowTopLevelReplyFallback?: boolean;
/** Mention target users */
mentions?: MentionTarget[];
accountId?: string;
}): Promise<FeishuSendResult> {
const { cfg, to, text, replyToMessageId, replyInThread, mentions, accountId } = params;
const {
cfg,
to,
text,
replyToMessageId,
replyInThread,
allowTopLevelReplyFallback,
mentions,
accountId,
} = params;
let cardText = text;
if (mentions && mentions.length > 0) {
cardText = buildMentionedCardContent(mentions, text);
}
const card = buildMarkdownCard(cardText);
return sendCardFeishu({ cfg, to, card, replyToMessageId, replyInThread, accountId });
return sendCardFeishu({
cfg,
to,
card,
replyToMessageId,
replyInThread,
allowTopLevelReplyFallback,
accountId,
});
}

View file

@ -215,29 +215,20 @@ function setupRuntimeMediaMocks(params: { loadFileName: string; loadBytes: strin
return { loadOutboundMediaFromUrl, fetchRemoteMedia };
}
function requireMockArg<T>(
mock: ReturnType<typeof vi.fn>,
callIndex = 0,
argIndex = 0,
_type?: (value: T) => T,
): T {
function requireMockArg(mock: ReturnType<typeof vi.fn>, callIndex = 0, argIndex = 0): unknown {
const call = mock.mock.calls[callIndex];
if (!call) {
throw new Error(`expected mock call ${callIndex}`);
}
return call[argIndex] as T;
return call[argIndex];
}
function requireMockArgs<T extends unknown[]>(
mock: ReturnType<typeof vi.fn>,
callIndex = 0,
_type?: (value: T) => T,
): T {
function requireMockArgs(mock: ReturnType<typeof vi.fn>, callIndex = 0): unknown[] {
const call = mock.mock.calls[callIndex];
if (!call) {
throw new Error(`expected mock call ${callIndex}`);
}
return call as T;
return call;
}
describe("googlechatPlugin outbound sendMedia", () => {
@ -282,9 +273,10 @@ describe("googlechatPlugin outbound sendMedia", () => {
text: "threaded",
threadId: "thread-1",
});
const request = requireMockArg<{ space?: string; thread?: string }>(
sendGoogleChatMessageMock,
);
const request = requireMockArg(sendGoogleChatMessageMock) as {
space?: string;
thread?: string;
};
expect(request.space).toBe("spaces/AAA");
expect(request.thread).toBe("thread-1");
},
@ -329,22 +321,25 @@ describe("googlechatPlugin outbound sendMedia", () => {
accountId: "default",
});
const [mediaUrl, mediaOptions] =
requireMockArgs<[string, { mediaLocalRoots?: string[] }]>(loadOutboundMediaFromUrl);
const [mediaUrl, mediaOptions] = requireMockArgs(loadOutboundMediaFromUrl) as [
string,
{ mediaLocalRoots?: string[] },
];
expect(mediaUrl).toBe("/tmp/workspace/image.png");
expect(mediaOptions.mediaLocalRoots).toEqual(["/tmp/workspace"]);
expect(fetchRemoteMedia).not.toHaveBeenCalled();
const uploadRequest = requireMockArg<{
const uploadRequest = requireMockArg(uploadGoogleChatAttachmentMock) as {
space?: string;
filename?: string;
contentType?: string;
}>(uploadGoogleChatAttachmentMock);
};
expect(uploadRequest.space).toBe("spaces/AAA");
expect(uploadRequest.filename).toBe("image.png");
expect(uploadRequest.contentType).toBe("image/png");
const sendRequest = requireMockArg<{ space?: string; text?: string }>(
sendGoogleChatMessageMock,
);
const sendRequest = requireMockArg(sendGoogleChatMessageMock) as {
space?: string;
text?: string;
};
expect(sendRequest.space).toBe("spaces/AAA");
expect(sendRequest.text).toBe("caption");
expect(result.messageId).toBe("spaces/AAA/messages/msg-1");
@ -375,21 +370,25 @@ describe("googlechatPlugin outbound sendMedia", () => {
accountId: "default",
});
const remoteRequest = requireMockArg<{ url?: string; maxBytes?: number }>(fetchRemoteMedia);
const remoteRequest = requireMockArg(fetchRemoteMedia) as {
url?: string;
maxBytes?: number;
};
expect(remoteRequest.url).toBe("https://example.com/image.png");
expect(remoteRequest.maxBytes).toBe(20 * 1024 * 1024);
expect(loadOutboundMediaFromUrl).not.toHaveBeenCalled();
const uploadRequest = requireMockArg<{
const uploadRequest = requireMockArg(uploadGoogleChatAttachmentMock) as {
space?: string;
filename?: string;
contentType?: string;
}>(uploadGoogleChatAttachmentMock);
};
expect(uploadRequest.space).toBe("spaces/AAA");
expect(uploadRequest.filename).toBe("remote.png");
expect(uploadRequest.contentType).toBe("image/png");
const sendRequest = requireMockArg<{ space?: string; text?: string }>(
sendGoogleChatMessageMock,
);
const sendRequest = requireMockArg(sendGoogleChatMessageMock) as {
space?: string;
text?: string;
};
expect(sendRequest.space).toBe("spaces/AAA");
expect(sendRequest.text).toBe("caption");
expect(result.messageId).toBe("spaces/AAA/messages/msg-2");
@ -525,11 +524,11 @@ describe("googlechatPlugin outbound cfg threading", () => {
cfg,
accountId: "work",
});
const request = requireMockArg<{
const request = requireMockArg(sendGoogleChatMessageMock) as {
account?: unknown;
space?: string;
text?: string;
}>(sendGoogleChatMessageMock);
};
expect(request.account).toBe(account);
expect(request.space).toBe("spaces/WORK");
expect(request.text).toBe(googlechatPairingTextAdapter.message);
@ -567,11 +566,11 @@ describe("googlechatPlugin outbound cfg threading", () => {
cfg,
accountId: "default",
});
const request = requireMockArg<{
const request = requireMockArg(sendGoogleChatMessageMock) as {
account?: unknown;
space?: string;
text?: string;
}>(sendGoogleChatMessageMock);
};
expect(request.account).toBe(account);
expect(request.space).toBe("spaces/AAA");
expect(request.text).toBe("hello");
@ -619,21 +618,24 @@ describe("googlechatPlugin outbound cfg threading", () => {
cfg,
accountId: "default",
});
const remoteRequest = requireMockArg<{ url?: string; maxBytes?: number }>(fetchRemoteMedia);
const remoteRequest = requireMockArg(fetchRemoteMedia) as {
url?: string;
maxBytes?: number;
};
expect(remoteRequest.url).toBe("https://example.com/file.png");
expect(remoteRequest.maxBytes).toBe(8 * 1024 * 1024);
const uploadRequest = requireMockArg<{
const uploadRequest = requireMockArg(uploadGoogleChatAttachmentMock) as {
account?: unknown;
space?: string;
filename?: string;
}>(uploadGoogleChatAttachmentMock);
};
expect(uploadRequest.account).toBe(account);
expect(uploadRequest.space).toBe("spaces/AAA");
expect(uploadRequest.filename).toBe("remote.png");
const sendRequest = requireMockArg<{
const sendRequest = requireMockArg(sendGoogleChatMessageMock) as {
account?: unknown;
attachments?: Array<{ attachmentUploadToken: string; contentName: string }>;
}>(sendGoogleChatMessageMock);
};
expect(sendRequest.account).toBe(account);
expect(sendRequest.attachments).toEqual([
{ attachmentUploadToken: "token-1", contentName: "remote.png" },
@ -666,8 +668,10 @@ describe("googlechatPlugin outbound cfg threading", () => {
expect(result.messageId).toBe("spaces/AAA/messages/msg-cold");
expect(result.chatId).toBe("spaces/AAA");
const [mediaUrl, mediaOptions] =
requireMockArgs<[string, { mediaLocalRoots?: string[] }]>(loadOutboundMediaFromUrl);
const [mediaUrl, mediaOptions] = requireMockArgs(loadOutboundMediaFromUrl) as [
string,
{ mediaLocalRoots?: string[] },
];
expect(mediaUrl).toBe("/tmp/workspace/image.png");
expect(mediaOptions.mediaLocalRoots).toEqual(["/tmp/workspace"]);
});

View file

@ -102,7 +102,9 @@ describe("irc inbound behavior", () => {
});
it("issues a DM pairing challenge and sends the reply to the sender nick", async () => {
const sendReply = vi.fn(async () => {});
const sendReply = vi.fn<(target: string, text: string, replyToId?: string) => Promise<void>>(
async () => {},
);
await handleIrcInbound({
message: createMessage(),
@ -129,7 +131,7 @@ describe("irc inbound behavior", () => {
expect.stringContaining("Your IRC id: alice!ident@example.com"),
undefined,
);
const replyMessages = (sendReply.mock.calls as unknown[][]).map((call) => String(call[1]));
const replyMessages = sendReply.mock.calls.map((call) => call[1]);
expect(replyMessages.some((message) => message.includes("CODE"))).toBe(true);
});

View file

@ -84,7 +84,7 @@ vi.mock("openclaw/plugin-sdk/routing", () => ({
const { readAllowFromStoreMock, upsertPairingRequestMock } = vi.hoisted(() => ({
readAllowFromStoreMock: vi.fn(async () => [] as string[]),
upsertPairingRequestMock: vi.fn(async () => ({ code: "CODE", created: true })),
upsertPairingRequestMock: vi.fn(async (_args: unknown) => ({ code: "CODE", created: true })),
}));
vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({

View file

@ -388,11 +388,13 @@ describe("line runtime api", () => {
});
function createRuntime() {
const monitorLineProvider = vi.fn(async () => ({
account: { accountId: "default" },
handleWebhook: async () => {},
stop: () => {},
}));
const monitorLineProvider = vi.fn(
async (_opts: { accountId?: string; channelAccessToken: string; channelSecret: string }) => ({
account: { accountId: "default" },
handleWebhook: async () => {},
stop: () => {},
}),
);
const runtime = {
channel: {

View file

@ -77,7 +77,10 @@ function requireCandidateKeyByPath(
}
function mockStringMessages(mock: { mock: { calls: unknown[][] } }): string[] {
return mock.mock.calls.map((call) => (typeof call[0] === "string" ? call[0] : ""));
return mock.mock.calls.map((call) => {
const message = call[0];
return typeof message === "string" ? message : "";
});
}
function expectIncludesSubstring(values: readonly string[], expected: string): void {

View file

@ -149,7 +149,10 @@ function createCronHarness(
}
function mockStringMessages(mock: { mock: { calls: unknown[][] } }): string[] {
return mock.mock.calls.map((call) => (typeof call[0] === "string" ? call[0] : ""));
return mock.mock.calls.map((call) => {
const message = call[0];
return typeof message === "string" ? message : "";
});
}
function expectLogContains(mock: { mock: { calls: unknown[][] } }, expected: string): void {

View file

@ -141,7 +141,9 @@ function expectSaveMediaBufferCall(mock: unknown, contentType: string, maxBytes:
}
function expectVerboseLogContains(expected: string): void {
const messages = vi.mocked(logVerbose).mock.calls.map((call) => call[0]);
const messages = vi.mocked(logVerbose).mock.calls.map((call) =>
typeof call[0] === "string" ? call[0] : "",
);
expect(messages.some((message) => message.includes(expected))).toBe(true);
}

View file

@ -31,7 +31,10 @@ function expectIncludesSubstring(values: readonly string[], expected: string): v
}
function mockStringMessages(mock: { mock: { calls: unknown[][] } }): string[] {
return mock.mock.calls.map((call) => (typeof call[0] === "string" ? call[0] : ""));
return mock.mock.calls.map((call) => {
const message = call[0];
return typeof message === "string" ? message : "";
});
}
const clientModule = await import("./client.js");

View file

@ -136,9 +136,10 @@ function requireMockCall(mock: unknown, index: number, label: string): unknown[]
}
function mockMessages(mock: unknown): string[] {
return (mock as MockCallReader).mock.calls.map((call) =>
typeof call[0] === "string" ? call[0] : "",
);
return (mock as MockCallReader).mock.calls.map((call) => {
const message = call[0];
return typeof message === "string" ? message : "";
});
}
function expectMockMessageContains(mock: unknown, expected: string): void {

View file

@ -116,7 +116,7 @@ function toPortablePath(filePath, repoRoot = REPO_ROOT) {
function rewriteRule(rule, params) {
const originalId = String(rule.id ?? "rule");
const metadata = { ...(rule.metadata ?? {}) };
const metadata = { ...rule.metadata };
const sourceId = sourceIdFromMetadata(metadata);
if (!sourceId) {
throw new Error(
@ -183,7 +183,7 @@ async function listYamlFiles(dir) {
}
}
await walk(dir);
return out.toSorted();
return out.toSorted((a, b) => a.localeCompare(b));
}
async function compile(opts) {

View file

@ -54,10 +54,10 @@ function makeGatewayService(
} as unknown as GatewayService;
}
function firstCallArg<T>(mock: { mock: { calls: unknown[][] } }, _type?: (value: T) => T): T {
function firstCallArg(mock: { mock: { calls: unknown[][] } }): unknown {
const call = mock.mock.calls[0];
expect(call).toBeDefined();
return call?.[0] as T;
return call?.[0];
}
async function inspectGatewayRestartWithSnapshot(params: {
@ -241,7 +241,7 @@ describe("inspectGatewayRestart", () => {
});
expect(snapshot.healthy).toBe(true);
expect(firstCallArg<{ url?: string }>(probeGateway).url).toBe("ws://127.0.0.1:18789");
expect((firstCallArg(probeGateway) as { url?: string }).url).toBe("ws://127.0.0.1:18789");
});
it("treats a busy port as healthy when runtime status lags but the probe succeeds", async () => {
@ -433,17 +433,17 @@ describe("inspectGatewayRestart", () => {
expect(snapshot.healthy).toBe(true);
expect(snapshot.gatewayVersion).toBe("2026.4.24");
expect(snapshot.expectedVersion).toBe("2026.4.24");
const authResolveInput = firstCallArg<{
const authResolveInput = firstCallArg(resolveGatewayProbeAuthSafeWithSecretInputs) as {
cfg?: { gateway?: { auth?: { mode?: string; token?: string } } };
mode?: string;
}>(resolveGatewayProbeAuthSafeWithSecretInputs);
};
expect(authResolveInput.cfg?.gateway?.auth?.mode).toBe("token");
expect(authResolveInput.cfg?.gateway?.auth?.token).toBe("probe-token");
expect(authResolveInput.mode).toBe("local");
const probeInput = firstCallArg<{
const probeInput = firstCallArg(probeGateway) as {
auth?: { token?: string; password?: string };
env?: NodeJS.ProcessEnv;
}>(probeGateway);
};
expect(probeInput.auth?.token).toBe("probe-token");
expect(probeInput.auth?.password).toBeUndefined();
expect(probeInput.env).toBe(serviceEnv);
@ -526,7 +526,7 @@ describe("inspectGatewayRestart", () => {
},
]);
expect(snapshot.versionMismatch).toBeUndefined();
expect(firstCallArg<{ includeDetails?: boolean }>(probeGateway).includeDetails).toBe(true);
expect((firstCallArg(probeGateway) as { includeDetails?: boolean }).includeDetails).toBe(true);
const { renderRestartDiagnostics } = await import("./restart-health.js");
expect(renderRestartDiagnostics(snapshot).join("\n")).toContain(

View file

@ -638,10 +638,10 @@ describe("applyAuthChoice", () => {
function expectPromptMessage(mock: { mock: { calls: unknown[][] } }, expected: string) {
expect(promptMessages(mock)).toContain(expected);
}
function firstCallArg<T>(mock: { mock: { calls: unknown[][] } }, _type?: (value: T) => T): T {
function firstCallArg(mock: { mock: { calls: unknown[][] } }): unknown {
const call = mock.mock.calls[0];
expect(call).toBeDefined();
return call?.[0] as T;
return call?.[0];
}
let defaultProviderPlugins: ProviderPlugin[] = [];
@ -1160,9 +1160,10 @@ describe("applyAuthChoice", () => {
setDefaultModel: false,
});
const providerResolveInput = firstCallArg<{ env?: NodeJS.ProcessEnv; mode?: string }>(
resolvePluginProviders,
);
const providerResolveInput = firstCallArg(resolvePluginProviders) as {
env?: NodeJS.ProcessEnv;
mode?: string;
};
expect(providerResolveInput.env).toBe(env);
expect(providerResolveInput.mode).toBe("setup");
expectPromptMessageContaining(confirm, "OPENAI_API_KEY");

View file

@ -108,10 +108,12 @@ const getChannelSetupPlugin = vi.hoisted(() => vi.fn((_channel?: unknown) => und
const listChannelSetupPlugins = vi.hoisted(() => vi.fn((): unknown[] => []));
const listActiveChannelSetupPlugins = vi.hoisted(() => vi.fn((): unknown[] => []));
const loadChannelSetupPluginRegistrySnapshotForChannel = vi.hoisted(() =>
vi.fn<LoadChannelSetupPluginRegistrySnapshotForChannel>((_params) => makePluginRegistry()),
vi.fn((_params: Parameters<LoadChannelSetupPluginRegistrySnapshotForChannel>[0]) =>
makePluginRegistry(),
),
);
const ensureChannelSetupPluginInstalled = vi.hoisted(() =>
vi.fn<EnsureChannelSetupPluginInstalled>(async ({ cfg, entry }) => ({
vi.fn(async ({ cfg, entry }: Parameters<EnsureChannelSetupPluginInstalled>[0]) => ({
cfg,
installed: true,
pluginId: entry?.pluginId,
@ -119,16 +121,20 @@ const ensureChannelSetupPluginInstalled = vi.hoisted(() =>
})),
);
const resolveChannelSetupEntries = vi.hoisted(() =>
vi.fn<ResolveChannelSetupEntries>((_params) => ({
entries: [],
installedCatalogEntries: [],
installableCatalogEntries: [],
installedCatalogById: new Map(),
installableCatalogById: new Map(),
})),
vi.fn(
(
_params: Parameters<ResolveChannelSetupEntries>[0],
): ReturnType<ResolveChannelSetupEntries> => ({
entries: [],
installedCatalogEntries: [],
installableCatalogEntries: [],
installedCatalogById: new Map(),
installableCatalogById: new Map(),
}),
),
);
const collectChannelStatus = vi.hoisted(() =>
vi.fn<CollectChannelStatus>(async (_params) => ({
vi.fn(async (_params: Parameters<CollectChannelStatus>[0]) => ({
installedPlugins: [],
catalogEntries: [],
installedCatalogEntries: [],

View file

@ -22,14 +22,10 @@ vi.mock("./channel-resolution.js", () => ({
resolveOutboundChannelMessageAdapter: resolveOutboundChannelMessageAdapterMock,
}));
function mockCallArg<T>(
mock: { mock: { calls: unknown[][] } },
index = 0,
_type?: (value: T) => T,
): T {
function mockCallArg(mock: { mock: { calls: unknown[][] } }, index = 0): unknown {
const call = mock.mock.calls[index];
expect(call).toBeDefined();
return call[0] as T;
return call[0];
}
function expectMockMessageContaining(mock: { mock: { calls: unknown[][] } }, expected: string) {
@ -204,9 +200,11 @@ describe("delivery-queue recovery", () => {
cfg: baseCfg,
allowBootstrap: true,
});
const deliverInput = mockCallArg<{ channel?: string; to?: string; skipQueue?: boolean }>(
deliver,
);
const deliverInput = mockCallArg(deliver) as {
channel?: string;
to?: string;
skipQueue?: boolean;
};
expect(deliverInput.channel).toBe("demo-channel-a");
expect(deliverInput.to).toBe("+1");
expect(deliverInput.skipQueue).toBe(true);
@ -280,7 +278,7 @@ describe("delivery-queue recovery", () => {
skippedMaxRetries: 0,
deferredBackoff: 0,
});
const reconcileInput = mockCallArg<{
const reconcileInput = mockCallArg(reconcileUnknownSend) as {
cfg?: unknown;
queueId?: string;
channel?: string;
@ -291,7 +289,7 @@ describe("delivery-queue recovery", () => {
threadId?: string;
silent?: boolean;
retryCount?: number;
}>(reconcileUnknownSend);
};
expect(reconcileInput.cfg).toBe(baseCfg);
expect(reconcileInput.queueId).toBe(id);
expect(reconcileInput.channel).toBe("demo-channel-a");
@ -303,7 +301,7 @@ describe("delivery-queue recovery", () => {
expect(reconcileInput.silent).toBe(true);
expect(reconcileInput.retryCount).toBe(0);
const afterCommitInput = mockCallArg<{
const afterCommitInput = mockCallArg(afterCommit) as {
kind?: string;
to?: string;
accountId?: string;
@ -311,7 +309,7 @@ describe("delivery-queue recovery", () => {
threadId?: string;
silent?: boolean;
result?: { messageId?: string };
}>(afterCommit);
};
expect(afterCommitInput.kind).toBe("text");
expect(afterCommitInput.to).toBe("+1");
expect(afterCommitInput.accountId).toBe("acct-1");
@ -400,9 +398,11 @@ describe("delivery-queue recovery", () => {
const { result } = await runRecovery({ deliver });
expect(deliver).toHaveBeenCalledTimes(1);
const deliverInput = mockCallArg<{ channel?: string; to?: string; skipQueue?: boolean }>(
deliver,
);
const deliverInput = mockCallArg(deliver) as {
channel?: string;
to?: string;
skipQueue?: boolean;
};
expect(deliverInput.channel).toBe("demo-channel-a");
expect(deliverInput.to).toBe("+1");
expect(deliverInput.skipQueue).toBe(true);
@ -527,11 +527,11 @@ describe("delivery-queue recovery", () => {
const deliver = vi.fn().mockResolvedValue([]);
await runRecovery({ deliver });
const deliverInput = mockCallArg<{
const deliverInput = mockCallArg(deliver) as {
deliveryQueueId?: string;
deliveryQueueStateDir?: string;
skipQueue?: boolean;
}>(deliver);
};
expect(deliverInput.deliveryQueueId).toBe(id);
expect(deliverInput.deliveryQueueStateDir).toBe(tmpDir());
expect(deliverInput.skipQueue).toBe(true);
@ -628,7 +628,7 @@ describe("delivery-queue recovery", () => {
const deliver = vi.fn().mockResolvedValue([]);
await runRecovery({ deliver });
const deliverInput = mockCallArg<{
const deliverInput = mockCallArg(deliver) as {
bestEffort?: boolean;
gifPlayback?: boolean;
silent?: boolean;
@ -638,7 +638,7 @@ describe("delivery-queue recovery", () => {
gatewayClientScopes?: string[];
mirror?: unknown;
session?: unknown;
}>(deliver);
};
expect(deliverInput.bestEffort).toBe(true);
expect(deliverInput.gifPlayback).toBe(true);
expect(deliverInput.silent).toBe(true);
@ -747,9 +747,11 @@ describe("delivery-queue recovery", () => {
deferredBackoff: 1,
});
expect(deliver).toHaveBeenCalledTimes(1);
const deliverInput = mockCallArg<{ channel?: string; to?: string; skipQueue?: boolean }>(
deliver,
);
const deliverInput = mockCallArg(deliver) as {
channel?: string;
to?: string;
skipQueue?: boolean;
};
expect(deliverInput.channel).toBe("demo-channel-b");
expect(deliverInput.to).toBe("2");
expect(deliverInput.skipQueue).toBe(true);

View file

@ -37,7 +37,11 @@ describe("cli json stdout contract", () => {
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`Expected JSON object stdout, got: ${stdout}`);
}
expect(Object.keys(parsed).sort()).toEqual(["availability", "channel", "update"]);
expect(Object.keys(parsed).toSorted((a, b) => a.localeCompare(b))).toEqual([
"availability",
"channel",
"update",
]);
expect(stdout).not.toContain("Doctor warnings");
expect(stdout).not.toContain("Doctor changes");
expect(stdout).not.toContain("Config invalid");

View file

@ -13,13 +13,13 @@ type RootPackageJson = {
type WorkspaceConfig = PnpmBuildConfig;
function readJson<T>(filePath: string): T {
return JSON.parse(fs.readFileSync(filePath, "utf8")) as T;
function readJson(filePath: string): unknown {
return JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown;
}
describe("package manager build policy", () => {
it("keeps optional native Discord opus builds disabled by default", () => {
const packageJson = readJson<RootPackageJson>("package.json");
const packageJson = readJson("package.json") as RootPackageJson;
const workspace = parse(fs.readFileSync("pnpm-workspace.yaml", "utf8")) as WorkspaceConfig;
for (const config of [packageJson.pnpm, workspace]) {

View file

@ -379,7 +379,7 @@ describe("collectClawHubOpenClawOwnerErrors", () => {
],
registryBaseUrl: "https://clawhub.ai",
fetchImpl: async (url) => {
const pathname = new URL(String(url)).pathname;
const pathname = new URL(url instanceof Request ? url.url : url).pathname;
if (pathname.includes("%40openclaw%2Fmissing-plugin")) {
return new Response("not found", { status: 404 });
}

View file

@ -816,8 +816,8 @@ describe("scripts/lib/docker-e2e-plan", () => {
expect(plan.lanes).toHaveLength(BUNDLED_PLUGIN_INSTALL_UNINSTALL_SHARDS);
expect(plan.lanes.at(0)).toBeDefined();
expect(plan.lanes.at(23)).toBeDefined();
expect(summarizeLane(plan.lanes[0]!)).toEqual(bundledPluginSweepLane(0));
expect(summarizeLane(plan.lanes[23]!)).toEqual(bundledPluginSweepLane(23));
expect(summarizeLane(plan.lanes[0])).toEqual(bundledPluginSweepLane(0));
expect(summarizeLane(plan.lanes[23])).toEqual(bundledPluginSweepLane(23));
expect(plan.needs).toEqual({
bareImage: false,
e2eImage: true,

View file

@ -50,8 +50,8 @@ describe("run-additional-boundary-checks", () => {
const shardedLabels = [1, 2, 3, 4].flatMap((index) =>
selectChecksForShard(BOUNDARY_CHECKS, `${index}/4`).map((check) => check.label),
);
expect(shardedLabels.toSorted()).toEqual(
BOUNDARY_CHECKS.map((check) => check.label).toSorted(),
expect(shardedLabels.toSorted((a, b) => a.localeCompare(b))).toEqual(
BOUNDARY_CHECKS.map((check) => check.label).toSorted((a, b) => a.localeCompare(b)),
);
expect(new Set(shardedLabels).size).toBe(BOUNDARY_CHECKS.length);
expect(() => parseShardSpec("5/4")).toThrow("Invalid shard spec");

View file

@ -24,7 +24,7 @@ describe("scripts/test-live-shard", () => {
.toSorted();
expect(allFiles.length).toBeGreaterThan(0);
expect([...new Set(selectedFiles)].toSorted()).toEqual(allFiles);
expect([...new Set(selectedFiles)].toSorted((a, b) => a.localeCompare(b))).toEqual(allFiles);
expect(duplicateFiles).toEqual(["extensions/music-generation-providers.live.test.ts"]);
expect(musicProviderFanout).toEqual([
"native-live-extensions-media-music-google",
@ -45,7 +45,7 @@ describe("scripts/test-live-shard", () => {
[
...selectLiveShardFiles("native-live-extensions-o-z-other", allFiles),
...selectLiveShardFiles("native-live-extensions-xai", allFiles),
].toSorted(),
].toSorted((a, b) => a.localeCompare(b)),
);
const mediaAlias = selectLiveShardFiles("native-live-extensions-media", allFiles);
@ -54,7 +54,7 @@ describe("scripts/test-live-shard", () => {
...selectLiveShardFiles("native-live-extensions-media-audio", allFiles),
...selectLiveShardFiles("native-live-extensions-media-music", allFiles),
...selectLiveShardFiles("native-live-extensions-media-video", allFiles),
].toSorted(),
].toSorted((a, b) => a.localeCompare(b)),
);
});

View file

@ -232,6 +232,13 @@
"path": "ui/src/ui/chat/grouped-render.ts",
"text": "Voice note"
},
{
"count": 1,
"kind": "object-property",
"name": "label",
"path": "ui/src/ui/chat/grouped-render.ts",
"text": "Tool output"
},
{
"count": 1,
"kind": "object-property",