diff --git a/src/card/reply-dispatcher.ts b/src/card/reply-dispatcher.ts index 7e606673..18a66797 100644 --- a/src/card/reply-dispatcher.ts +++ b/src/card/reply-dispatcher.ts @@ -227,6 +227,8 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP return; } + let textHandledByController = false; + // ---- Streaming card mode ---- if (controller) { if (meta?.kind === 'tool' && shouldRouteToolPayloadToCard(payload, toolUseDisplay.showToolUse)) { @@ -242,10 +244,12 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP if (controller.cardMessageId) { if (payload.isReasoning === true) { await controller.onReasoningStream({ ...payload, text: controllerText }); - return; + textHandledByController = true; + if (payloadMediaUrls.length === 0) return; + } else { + await controller.onDeliver({ ...payload, text: controllerText }); + textHandledByController = true; } - await controller.onDeliver({ ...payload, text: controllerText }); - return; } // Card creation failed — fall through to static delivery log.warn('deliver: card creation failed, falling back to static delivery'); @@ -253,7 +257,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP } // ---- Static text delivery ---- - if (text.trim()) { + if (text.trim() && !textHandledByController) { if (shouldUseCard(text)) { const chunks = core.channel.text.chunkTextWithMode(text, textChunkLimit, chunkMode); log.info('deliver: sending card chunks', { @@ -356,6 +360,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP replyToMessageId, replyInThread, }); + controller?.markMediaDelivered(); } catch (mediaErr) { if (staticGuard?.terminate('deliver.media', mediaErr)) return; log.error('deliver: static media send failed', { diff --git a/src/card/streaming-card-controller.ts b/src/card/streaming-card-controller.ts index 779e1ec0..30e38ebc 100644 --- a/src/card/streaming-card-controller.ts +++ b/src/card/streaming-card-controller.ts @@ -120,6 +120,7 @@ export class StreamingCardController { private createEpoch = 0; private _terminalReason: TerminalReason | null = null; private dispatchFullyComplete = false; + private visibleMediaDelivered = false; private cardCreationPromise: Promise | null = null; private disposeShutdownHook: (() => void) | null = null; private readonly dispatchStartTime = Date.now(); @@ -690,8 +691,10 @@ export class StreamingCardController { const isNoReplyLeak = !this.text.completedText && SILENT_REPLY_TOKEN.startsWith(this.text.accumulatedText.trim()); const displayText = - this.text.completedText || (isNoReplyLeak ? '' : this.text.accumulatedText) || EMPTY_REPLY_FALLBACK_TEXT; - if (!this.text.completedText && !this.text.accumulatedText) { + this.text.completedText || + (isNoReplyLeak ? '' : this.text.accumulatedText) || + EMPTY_REPLY_FALLBACK_TEXT; + if (!this.text.completedText && !this.text.accumulatedText && !this.visibleMediaDelivered) { log.warn('reply completed without visible text, using empty-reply fallback'); } @@ -763,10 +766,16 @@ export class StreamingCardController { log.debug('markFullyComplete', { completedTextLen: this.text.completedText.length, accumulatedTextLen: this.text.accumulatedText.length, + visibleMediaDelivered: this.visibleMediaDelivered, }); this.dispatchFullyComplete = true; } + markMediaDelivered(): void { + this.captureToolUseElapsed(); + this.visibleMediaDelivered = true; + } + async abortCard(): Promise { try { this.captureToolUseElapsed(); diff --git a/tests/reply-dispatcher-media.test.ts b/tests/reply-dispatcher-media.test.ts index b2593fb6..099f65e4 100644 --- a/tests/reply-dispatcher-media.test.ts +++ b/tests/reply-dispatcher-media.test.ts @@ -11,6 +11,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; // Module mocks // --------------------------------------------------------------------------- +const replyModeState = vi.hoisted(() => ({ mode: 'static' as 'static' | 'streaming' })); +const streamingControllerInstances = vi.hoisted( + () => + [] as Array<{ + cardMessageId: string | null; + ensureCardCreated: ReturnType; + isAborted: boolean; + isTerminated: boolean; + markMediaDelivered: ReturnType; + onDeliver: ReturnType; + onIdle: ReturnType; + onReasoningStream: ReturnType; + onToolPayload: ReturnType; + shouldSkipForUnavailable: ReturnType; + }>, +); + vi.mock('openclaw/plugin-sdk/channel-runtime', () => ({ createReplyPrefixContext: () => ({ responsePrefix: '', @@ -76,24 +93,43 @@ vi.mock('../src/card/card-error', () => ({ isCardTableLimitError: () => false, })); vi.mock('../src/card/reply-mode', () => ({ - resolveReplyMode: () => 'static', + resolveReplyMode: () => replyModeState.mode, expandAutoMode: ({ mode }: { mode: string }) => mode, shouldUseCard: () => false, })); vi.mock('../src/card/streaming-card-controller', () => ({ - StreamingCardController: class {}, + StreamingCardController: class { + cardMessageId: string | null = 'om_stream_card'; + isAborted = false; + isTerminated = false; + ensureCardCreated = vi.fn().mockResolvedValue(undefined); + markMediaDelivered = vi.fn(); + onDeliver = vi.fn().mockResolvedValue(undefined); + onIdle = vi.fn().mockResolvedValue(undefined); + onReasoningStream = vi.fn().mockResolvedValue(undefined); + onToolPayload = vi.fn().mockResolvedValue(undefined); + shouldSkipForUnavailable = vi.fn().mockReturnValue(false); + + constructor() { + streamingControllerInstances.push(this); + } + }, })); let terminateReturn = true; const terminateCalls: Array<{ source: string; err: unknown }> = []; vi.mock('../src/card/unavailable-guard', () => ({ UnavailableGuard: class { - shouldSkip() { return false; } + shouldSkip() { + return false; + } terminate(source: string, err?: unknown) { terminateCalls.push({ source, err }); return terminateReturn; } - get isTerminated() { return false; } + get isTerminated() { + return false; + } }, })); @@ -115,22 +151,34 @@ interface TestContext { sentMedia: unknown[]; } -function createDispatcher(options: { - sendMediaImpl?: (payload: unknown) => Promise; - terminateReturn?: boolean; -} = {}): TestContext { +function createDispatcher( + options: { + replyMode?: 'static' | 'streaming'; + sendMediaImpl?: (payload: unknown) => Promise; + terminateReturn?: boolean; + } = {}, +): TestContext { const sentText: unknown[] = []; const sentCards: unknown[] = []; const sentMedia: unknown[] = []; // Reset module-level state + replyModeState.mode = options.replyMode ?? 'static'; terminateReturn = options.terminateReturn ?? true; terminateCalls.length = 0; - mockSendMessageFeishu.mockImplementation(async (payload: unknown) => { sentText.push(payload); }); - mockSendMarkdownCardFeishu.mockImplementation(async (payload: unknown) => { sentCards.push(payload); }); + mockSendMessageFeishu.mockImplementation(async (payload: unknown) => { + sentText.push(payload); + }); + mockSendMarkdownCardFeishu.mockImplementation(async (payload: unknown) => { + sentCards.push(payload); + }); - const sendMediaImpl = options.sendMediaImpl ?? (async (payload: unknown) => { sentMedia.push(payload); }); + const sendMediaImpl = + options.sendMediaImpl ?? + (async (payload: unknown) => { + sentMedia.push(payload); + }); mockSendMediaLark.mockImplementation(sendMediaImpl); const result = createFeishuReplyDispatcher({ @@ -166,6 +214,8 @@ function createDispatcher(options: { beforeEach(() => { vi.clearAllMocks(); terminateCalls.length = 0; + streamingControllerInstances.length = 0; + replyModeState.mode = 'static'; }); describe('reply-dispatcher media delivery', () => { @@ -196,10 +246,42 @@ describe('reply-dispatcher media delivery', () => { ]); }); + it('streaming mixed payload routes text to the active card and still sends media', async () => { + const ctx = createDispatcher({ replyMode: 'streaming' }); + + await ctx.dispatcher.deliver({ + text: 'created image', + mediaUrl: 'https://example.com/image.png', + }); + + const controller = streamingControllerInstances[0]; + expect(controller.ensureCardCreated).toHaveBeenCalledTimes(1); + expect(controller.onDeliver).toHaveBeenCalledWith(expect.objectContaining({ text: 'created image' })); + expect(ctx.sentText).toHaveLength(0); + expect(ctx.sentCards).toHaveLength(0); + expect(ctx.sentMedia).toHaveLength(1); + expect((ctx.sentMedia[0] as { mediaUrl: string }).mediaUrl).toBe('https://example.com/image.png'); + }); + + it('streaming media-only payload marks delivered media as a visible result', async () => { + const ctx = createDispatcher({ replyMode: 'streaming' }); + + await ctx.dispatcher.deliver({ + text: '', + mediaUrl: 'https://example.com/image.png', + }); + + const controller = streamingControllerInstances[0]; + expect(ctx.sentMedia).toHaveLength(1); + expect(controller.markMediaDelivered).toHaveBeenCalledTimes(1); + }); + it('failed media send triggers staticGuard terminate', async () => { const mediaError = new Error('bot removed from chat'); const ctx = createDispatcher({ - sendMediaImpl: async () => { throw mediaError; }, + sendMediaImpl: async () => { + throw mediaError; + }, terminateReturn: true, });