feat(feishu): add tool/compaction status to streaming cards and prevent duplicate final cards#48384
Conversation
…nt duplicate final cards Two improvements to the Feishu streaming reply dispatcher: **Tool & compaction status callbacks:** Wire `onToolStart`, `onAssistantMessageStart`, `onCompactionStart`, and `onCompactionEnd` into the streaming card. When streaming is active, tool usage shows as `🔧 *Using: tool_name...*` and context compaction shows as `📦 *Compacting context...*` appended to the streaming card content. `onAssistantMessageStart` and `onCompactionEnd` clear the status line so the final closed card never includes transient status text. This matches the Telegram channel's status-reaction behavior but uses inline card text instead of emoji reactions, which is more natural for the Feishu card-based UI. **Prevent duplicate streaming cards on multi-final replies:** Previously each `final` reply immediately called `closeStreaming()`, causing turns with N tool calls to produce N duplicate cards. Now final text is appended to the current streaming card via `queueStreamingUpdate` in snapshot mode, and the card is closed once by `onIdle` when the entire turn completes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Greptile SummaryThis PR adds real-time tool/compaction status indicators to Feishu streaming cards and fixes duplicate streaming cards when a turn contains multiple tool calls. The Key changes:
Issues found:
Confidence Score: 2/5
Prompt To Fix All With AIThis is a comment left during a code review.
Path: extensions/feishu/src/reply-dispatcher.ts
Line: 398-402
Comment:
**Existing tests will break — test file not updated**
The behavioral change from "close on `final`" to "close only on `onIdle`" is correct for the stated goal, but the test file (`reply-dispatcher.test.ts`) was not updated to match. At least **six tests** assert that `streaming.close()` has been called (or that a session count reflects immediate closes) immediately after `deliver(kind: "final")` without a subsequent `onIdle()` call. These will all fail:
| Test | Breaking assertion |
|---|---|
| "uses streaming session for auto mode markdown payloads" (line 272) | `expect(streamingInstances[0].close).toHaveBeenCalledTimes(1)` — no `onIdle` called |
| "delivers distinct final payloads after streaming close" (line 299) | `expect(streamingInstances).toHaveLength(2)` — now only 1 session because first `final` no longer closes |
| "skips exact duplicate final text after streaming close" (line 323) | `expect(streamingInstances[0].close).toHaveBeenCalledTimes(1)` — no `onIdle` called |
| "sends attachments after streaming final markdown replies" (line 437) | `expect(streamingInstances[0].close).toHaveBeenCalledTimes(1)` — no `onIdle` called |
| "streams reasoning content as blockquote before answer" (line 516) | `expect(streamingInstances[0].close).toHaveBeenCalledTimes(1)` — no `onIdle` called |
| "ignores empty reasoning payloads" (~line 584) | `close.mock.calls[0][0]` will throw — `close` was never called |
| "deduplicates final text by raw answer payload" (line 598) | `expect(streamingInstances[0].close).toHaveBeenCalledTimes(1)` — no `onIdle` called |
Each of these tests needs to have `await options.onIdle?.()` inserted after the final `deliver` call to trigger the close. The "delivers distinct final payloads" test also needs its logic reworked to assert a single merged session instead of two independent ones.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: extensions/feishu/src/reply-dispatcher.ts
Line: 513-517
Comment:
**`onAssistantMessageStart` silently no-ops when streaming hasn't started**
`flushStreamingCardUpdate` only updates the card when `streaming?.isActive()` is true. If `onAssistantMessageStart` fires before any prior `onToolStart` (or `onReasoningStream`) has kicked off a streaming session — for example, the very first message of a turn — the call is silently dropped. The `statusLine = ""` mutation happens but the update is never pushed.
This is likely benign in practice (no card exists to update), but it differs from `onToolStart` and `onCompactionStart`, which both defensively call `startStreaming()` before flushing. Consider adding the same guard for consistency:
```ts
onAssistantMessageStart: streamingEnabled
? () => {
statusLine = "";
if (streaming?.isActive() || streamingStartPromise) {
flushStreamingCardUpdate(buildCombinedStreamText(reasoningText, streamText));
}
}
: undefined,
```
How can I resolve this? If you propose a fix, please make it concise.Last reviewed commit: 4818ed1 |
| if (info?.kind === "final") { | ||
| streamText = mergeStreamingText(streamText, text); | ||
| await closeStreaming(); | ||
| // Append final text but do NOT close yet — multiple finals in a | ||
| // single turn should merge into one card. onIdle closes the card. | ||
| queueStreamingUpdate(text, { mode: "snapshot" }); | ||
| deliveredFinalTexts.add(text); |
There was a problem hiding this comment.
Existing tests will break — test file not updated
The behavioral change from "close on final" to "close only on onIdle" is correct for the stated goal, but the test file (reply-dispatcher.test.ts) was not updated to match. At least six tests assert that streaming.close() has been called (or that a session count reflects immediate closes) immediately after deliver(kind: "final") without a subsequent onIdle() call. These will all fail:
| Test | Breaking assertion |
|---|---|
| "uses streaming session for auto mode markdown payloads" (line 272) | expect(streamingInstances[0].close).toHaveBeenCalledTimes(1) — no onIdle called |
| "delivers distinct final payloads after streaming close" (line 299) | expect(streamingInstances).toHaveLength(2) — now only 1 session because first final no longer closes |
| "skips exact duplicate final text after streaming close" (line 323) | expect(streamingInstances[0].close).toHaveBeenCalledTimes(1) — no onIdle called |
| "sends attachments after streaming final markdown replies" (line 437) | expect(streamingInstances[0].close).toHaveBeenCalledTimes(1) — no onIdle called |
| "streams reasoning content as blockquote before answer" (line 516) | expect(streamingInstances[0].close).toHaveBeenCalledTimes(1) — no onIdle called |
| "ignores empty reasoning payloads" (~line 584) | close.mock.calls[0][0] will throw — close was never called |
| "deduplicates final text by raw answer payload" (line 598) | expect(streamingInstances[0].close).toHaveBeenCalledTimes(1) — no onIdle called |
Each of these tests needs to have await options.onIdle?.() inserted after the final deliver call to trigger the close. The "delivers distinct final payloads" test also needs its logic reworked to assert a single merged session instead of two independent ones.
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/feishu/src/reply-dispatcher.ts
Line: 398-402
Comment:
**Existing tests will break — test file not updated**
The behavioral change from "close on `final`" to "close only on `onIdle`" is correct for the stated goal, but the test file (`reply-dispatcher.test.ts`) was not updated to match. At least **six tests** assert that `streaming.close()` has been called (or that a session count reflects immediate closes) immediately after `deliver(kind: "final")` without a subsequent `onIdle()` call. These will all fail:
| Test | Breaking assertion |
|---|---|
| "uses streaming session for auto mode markdown payloads" (line 272) | `expect(streamingInstances[0].close).toHaveBeenCalledTimes(1)` — no `onIdle` called |
| "delivers distinct final payloads after streaming close" (line 299) | `expect(streamingInstances).toHaveLength(2)` — now only 1 session because first `final` no longer closes |
| "skips exact duplicate final text after streaming close" (line 323) | `expect(streamingInstances[0].close).toHaveBeenCalledTimes(1)` — no `onIdle` called |
| "sends attachments after streaming final markdown replies" (line 437) | `expect(streamingInstances[0].close).toHaveBeenCalledTimes(1)` — no `onIdle` called |
| "streams reasoning content as blockquote before answer" (line 516) | `expect(streamingInstances[0].close).toHaveBeenCalledTimes(1)` — no `onIdle` called |
| "ignores empty reasoning payloads" (~line 584) | `close.mock.calls[0][0]` will throw — `close` was never called |
| "deduplicates final text by raw answer payload" (line 598) | `expect(streamingInstances[0].close).toHaveBeenCalledTimes(1)` — no `onIdle` called |
Each of these tests needs to have `await options.onIdle?.()` inserted after the final `deliver` call to trigger the close. The "delivers distinct final payloads" test also needs its logic reworked to assert a single merged session instead of two independent ones.
How can I resolve this? If you propose a fix, please make it concise.| onAssistantMessageStart: streamingEnabled | ||
| ? () => { | ||
| statusLine = ""; | ||
| flushStreamingCardUpdate(buildCombinedStreamText(reasoningText, streamText)); | ||
| } |
There was a problem hiding this comment.
onAssistantMessageStart silently no-ops when streaming hasn't started
flushStreamingCardUpdate only updates the card when streaming?.isActive() is true. If onAssistantMessageStart fires before any prior onToolStart (or onReasoningStream) has kicked off a streaming session — for example, the very first message of a turn — the call is silently dropped. The statusLine = "" mutation happens but the update is never pushed.
This is likely benign in practice (no card exists to update), but it differs from onToolStart and onCompactionStart, which both defensively call startStreaming() before flushing. Consider adding the same guard for consistency:
onAssistantMessageStart: streamingEnabled
? () => {
statusLine = "";
if (streaming?.isActive() || streamingStartPromise) {
flushStreamingCardUpdate(buildCombinedStreamText(reasoningText, streamText));
}
}
: undefined,Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/feishu/src/reply-dispatcher.ts
Line: 513-517
Comment:
**`onAssistantMessageStart` silently no-ops when streaming hasn't started**
`flushStreamingCardUpdate` only updates the card when `streaming?.isActive()` is true. If `onAssistantMessageStart` fires before any prior `onToolStart` (or `onReasoningStream`) has kicked off a streaming session — for example, the very first message of a turn — the call is silently dropped. The `statusLine = ""` mutation happens but the update is never pushed.
This is likely benign in practice (no card exists to update), but it differs from `onToolStart` and `onCompactionStart`, which both defensively call `startStreaming()` before flushing. Consider adding the same guard for consistency:
```ts
onAssistantMessageStart: streamingEnabled
? () => {
statusLine = "";
if (streaming?.isActive() || streamingStartPromise) {
flushStreamingCardUpdate(buildCombinedStreamText(reasoningText, streamText));
}
}
: undefined,
```
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
|
Closeout note from the Feishu streaming-card dedupe pass: #71523 now covers the duplicate-final-card part by deferring streaming close until idle. I’m keeping this PR open because the tool/compaction status UI is a separate feature surface and was not landed by the canonical fix. Future review can focus this branch on that status behavior without re-litigating the duplicate final-card root cause. |
|
The remaining status-line feature from this PR is now landed in #71542. Feishu streaming cards can show transient tool and compaction status, then clear that status before the final close so the completed card stays clean. Closing this branch as superseded. Thanks for pushing the UX angle; the duplicate-final-card portion was handled by #71523 and the status behavior is now in the follow-up canonical PR. |
Summary
onToolStart,onAssistantMessageStart,onCompactionStart, andonCompactionEndcallbacks into the Feishu streaming reply dispatcher🔧 *Using: tool_name...*and context compaction shows as📦 *Compacting context...*appended to the streaming cardonAssistantMessageStartandonCompactionEndclear the status line; the final closed card never includes transient status textfinalreplies no longer immediately close the card — they append viaqueueStreamingUpdatein snapshot mode, andonIdlecloses the card once when the turn completesWhy
Tool status: Users had no visibility into what the agent was doing between responses. The Telegram channel already shows tool status via emoji reactions; this brings equivalent feedback to Feishu using inline card text, which is more natural for the card-based UI.
Duplicate cards: Turns with N tool calls produced N duplicate streaming cards because each
finalreply calledcloseStreaming()immediately. Now all finals merge into one card.Test plan
🔧 *Using: tool_name...*appears during executionpnpm test -- extensions/feishu/src/reply-dispatcher.test.ts🤖 Generated with Claude Code