mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-13 10:59:39 +00:00
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 head93146f9d13. - Required merge gates passed before the squash merge. Prepared head SHA:93146f9d13Review: 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:
parent
5af8fc0d52
commit
f9c0dc2d2b
29 changed files with 421 additions and 167 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => ({
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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]) {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue