Skip to content

feat(feishu): add tool/compaction status to streaming cards and prevent duplicate final cards#48384

Closed
Pengxiao-Wang wants to merge 1 commit intoopenclaw:mainfrom
Pengxiao-Wang:feat/feishu-tool-status-streaming
Closed

feat(feishu): add tool/compaction status to streaming cards and prevent duplicate final cards#48384
Pengxiao-Wang wants to merge 1 commit intoopenclaw:mainfrom
Pengxiao-Wang:feat/feishu-tool-status-streaming

Conversation

@Pengxiao-Wang
Copy link
Copy Markdown

Summary

  • Wire onToolStart, onAssistantMessageStart, onCompactionStart, and onCompactionEnd callbacks into the Feishu streaming reply dispatcher
  • When streaming is active, tool usage shows as 🔧 *Using: tool_name...* and context compaction shows as 📦 *Compacting context...* appended to the streaming card
  • onAssistantMessageStart and onCompactionEnd clear the status line; the final closed card never includes transient status text
  • Prevent duplicate streaming cards: final replies no longer immediately close the card — they append via queueStreamingUpdate in snapshot mode, and onIdle closes the card once when the turn completes

Why

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 final reply called closeStreaming() immediately. Now all finals merge into one card.

Test plan

  • Send a message that triggers tool calls — verify 🔧 *Using: tool_name...* appears during execution
  • Verify status line disappears from the final closed card
  • Send a message with multiple tool calls — verify only one streaming card is produced (not N duplicates)
  • Run pnpm test -- extensions/feishu/src/reply-dispatcher.test.ts

🤖 Generated with Claude Code

…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>
@openclaw-barnacle openclaw-barnacle Bot added channel: feishu Channel integration: feishu size: XS labels Mar 16, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 16, 2026

Greptile Summary

This 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 statusLine variable is appended to the combined stream text during tool execution and cleared when the assistant message starts, which integrates cleanly with the existing buildCombinedStreamText logic. The deferred-close strategy (moving closeStreaming() from deliver(kind: "final") to onIdle) is the right approach for preventing duplicate cards.

Key changes:

  • statusLine state variable + insertions in buildCombinedStreamText to display/clear status text
  • Four new replyOptions callbacks: onToolStart, onAssistantMessageStart, onCompactionStart, onCompactionEnd
  • deliver(kind: "final") now calls queueStreamingUpdate (snapshot mode) instead of closeStreaming() — card is only closed when onIdle fires

Issues found:

  • The deferred-close change breaks at least six existing unit tests in reply-dispatcher.test.ts that assert streaming.close() is called immediately after deliver(kind: "final"), or count streaming instances under the old "close-on-final" model. The test file was not updated in this PR. The affected tests are: "uses streaming session for auto mode markdown payloads", "delivers distinct final payloads after streaming close", "skips exact duplicate final text after streaming close", "sends attachments after streaming final markdown replies", "streams reasoning content as blockquote before answer", "ignores empty reasoning payloads", and "deduplicates final text by raw answer payload". Each needs await options.onIdle?.() added after the final deliver call, and the "distinct final payloads" test needs its session-count assertion reworked to reflect the new merged-card behavior.

Confidence Score: 2/5

  • The runtime logic is sound but the PR breaks the existing test suite — merging without fixing the tests would leave CI red.
  • The implementation approach (statusLine variable, callback wiring, deferred close) is architecturally correct and the buildCombinedStreamText logic handles all status/thinking/answer combinations properly. However, the behavioral change from "close on final" to "close on idle" invalidates at least six existing unit tests that were not updated in this PR. The PR description explicitly directs reviewers to run those tests, yet they would currently fail. This is a blocking issue for merging.
  • extensions/feishu/src/reply-dispatcher.test.ts — six tests need onIdle() calls added and the "distinct final payloads" test needs its assertion model updated to match the new merged-card behavior.
Prompt To Fix All 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.

---

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

Comment on lines 398 to 402
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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +513 to +517
onAssistantMessageStart: streamingEnabled
? () => {
statusLine = "";
flushStreamingCardUpdate(buildCombinedStreamText(reasoningText, streamText));
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

@vincentkoc
Copy link
Copy Markdown
Member

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.

@vincentkoc vincentkoc added dedupe:child Duplicate issue/PR child in dedupe cluster close:superseded PR close reason labels Apr 25, 2026
@vincentkoc
Copy link
Copy Markdown
Member

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: feishu Channel integration: feishu close:superseded PR close reason dedupe:child Duplicate issue/PR child in dedupe cluster size: XS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants