fix: mark card field as optional in message tool schema

The `createMessageToolCardSchema()` helper returned a bare `Type.Object()`
which TypeBox treats as required when merged into the parent tool schema via
`Type.Object({ card: ... })`. This caused schema validation to reject
media-only sends on Feishu and MSTeams with "must have required property
card", even though the implementation correctly treats card as optional.

Wrap the return value in `Type.Optional()` so the card field is excluded
from the JSON Schema `required` array. Fixes the catch-22 where omitting
card fails validation and including an empty card triggers the runtime
"does not support card with media" guard.

Closes #53697

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
grassylcao
2026-03-24 21:23:02 +08:00
committed by Peter Steinberger
parent eaad4ad1be
commit ca578a9183
2 changed files with 47 additions and 6 deletions

View File

@@ -576,6 +576,45 @@ describe("feishuPlugin actions", () => {
).rejects.toThrow("Feishu thread-reply requires messageId.");
});
it("declares card as optional in the tool schema", () => {
const discovery = feishuPlugin.actions?.describeMessageTool?.({ cfg });
const schema = Array.isArray(discovery?.schema) ? discovery.schema[0] : discovery?.schema;
const cardSchema = schema?.properties?.card;
expect(cardSchema).toBeDefined();
// TypeBox marks Optional schemas with Symbol(TypeBox.Optional) = "Optional".
expect(
(cardSchema as unknown as Record<symbol, unknown>)?.[Symbol.for("TypeBox.Optional")],
).toBe("Optional");
});
it("sends media-only messages without requiring card", async () => {
feishuOutboundSendMediaMock.mockResolvedValueOnce({
channel: "feishu",
messageId: "om_media_only",
details: { messageId: "om_media_only", chatId: "oc_group_1" },
});
const result = await feishuPlugin.actions?.handleAction?.({
action: "send",
params: {
to: "chat:oc_group_1",
media: "https://example.com/image.png",
},
cfg,
accountId: undefined,
toolContext: {},
mediaLocalRoots: [],
} as never);
expect(feishuOutboundSendMediaMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat:oc_group_1",
mediaUrl: "https://example.com/image.png",
}),
);
expect(result?.details).toMatchObject({ messageId: "om_media_only" });
});
it("fails for unsupported action names", async () => {
await expect(
feishuPlugin.actions?.handleAction?.({

View File

@@ -25,11 +25,13 @@ export function createMessageToolButtonsSchema(): TSchema {
/** Schema helper for channels that accept provider-native card payloads. */
export function createMessageToolCardSchema(): TSchema {
return Type.Object(
{},
{
additionalProperties: true,
description: "Structured card payload for channels that support card-style messages.",
},
return Type.Optional(
Type.Object(
{},
{
additionalProperties: true,
description: "Structured card payload for channels that support card-style messages.",
},
),
);
}