Skip to content

fix(feishu): treat block payloads as snapshots to avoid duplication with tool calls#39085

Closed
babycoder-dev wants to merge 2 commits intoopenclaw:mainfrom
babycoder-dev:fix/feishu-block-streaming-duplicate
Closed

fix(feishu): treat block payloads as snapshots to avoid duplication with tool calls#39085
babycoder-dev wants to merge 2 commits intoopenclaw:mainfrom
babycoder-dev:fix/feishu-block-streaming-duplicate

Conversation

@babycoder-dev
Copy link
Copy Markdown

@babycoder-dev babycoder-dev commented Mar 7, 2026

Summary

  • Fix duplicate content in Feishu streaming cards when blockStreaming is enabled with tool calls
  • Add dedupeWithLastPartial: true to block payload handling to prevent duplication

Test plan

  • Verify no duplicate content when tool calls are executed with blockStreaming enabled
  • Check that streaming card shows content correctly without "看起来看起来" duplication

@openclaw-barnacle openclaw-barnacle Bot added channel: feishu Channel integration: feishu size: XS labels Mar 7, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e979e5562f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@@ -280,7 +280,8 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
if (info?.kind === "block") {
// Some runtimes emit block payloads without onPartial/final callbacks.
// Mirror block text into streamText so onIdle close still sends content.
queueStreamingUpdate(text, { mode: "delta" });
// Use dedupeWithLastPartial to avoid duplicating content already sent via onPartialReply.
queueStreamingUpdate(text, { mode: "delta", dedupeWithLastPartial: true });
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve repeated block deltas in streaming cards

Applying dedupeWithLastPartial: true to block updates drops any consecutive block chunks that have identical text, but this path is explicitly using mode: "delta". In block-streaming mode, identical adjacent deltas are valid output (for example, repeated short chunks), so the second chunk is now discarded and the final card content is truncated whenever runtimes emit duplicate block deltas without an intervening different partial.

Useful? React with 👍 / 👎.

Comment thread src/plugins/manifest-registry.ts Outdated
Comment on lines +235 to +237
const isBundledAndConfig =
new Set([existing.candidate.origin, candidate.origin]).has("bundled") &&
new Set([existing.candidate.origin, candidate.origin]).has("config");
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep duplicate-id warnings for distinct config overrides

This condition suppresses duplicate plugin-id warnings purely on origin (bundled + config) and does not require both candidates to resolve to the same directory. That means a config-loaded plugin with the same id as a bundled plugin but from a different path is now silently accepted, hiding real id collisions/shadowing in plugins.load.paths where operators previously got a warning.

Useful? React with 👍 / 👎.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 7, 2026

Greptile Summary

This PR addresses duplicate content in Feishu streaming cards when blockStreaming is enabled with tool calls.

Key change in extensions/feishu/src/reply-dispatcher.ts:

  • Adds dedupeWithLastPartial: true to block payload streaming updates, preventing duplication when block payloads carry the same text as the most recently sent onPartialReply snapshot.

Issue identified in src/plugins/manifest-registry.ts (lines 238–251):
The isBundledAndConfig branch has a latent logic flaw: when alternating bundled/config candidates with the same plugin ID are processed, the condition can re-trigger repeatedly, pushing multiple records for the same ID instead of replacing in-place. This diverges from the established samePlugin pattern and could cause duplicate plugin records to accumulate in the registry.

Confidence Score: 2/5

  • PR has a logic flaw in plugin manifest deduplication that could cause unbounded record accumulation under specific conditions.
  • The Feishu streaming fix is safe and narrowly correct. However, manifest-registry.ts introduces a latent logic bug in the isBundledAndConfig branch: it pushes multiple records for the same plugin ID instead of replacing in-place, diverging from the established samePlugin pattern. While unlikely to manifest (requires alternating bundled/config candidates), the flaw could cause silent accumulation of duplicate plugin records in the registry, violating the single-record-per-ID invariant.
  • src/plugins/manifest-registry.ts — the isBundledAndConfig block should use in-place replacement instead of record accumulation to maintain consistency with samePlugin logic.

Last reviewed commit: e979e55

Comment thread src/plugins/manifest-registry.ts Outdated
Comment on lines +238 to +251
if (isBundledAndConfig) {
// Skip the duplicate warning - config entry is intentionally for the same bundled channel plugin
// Add both entries so loader can handle precedence
seenIds.set(manifest.id, { candidate, recordIndex: records.length });
records.push(
buildRecord({
manifest,
candidate,
manifestPath: manifestRes.manifestPath,
schemaCacheKey,
configSchema,
}),
);
continue;
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.

The isBundledAndConfig branch pushes a new record to records[] and updates seenIds to point to the current candidate. If a third candidate with the same manifest.id arrives with alternating origin (e.g., another bundled after seenIds was updated to config), the condition evaluates to true again and pushes another record. This can repeat unboundedly with each alternating candidate.

This diverges from the samePlugin path (line 221), which replaces the lower-precedence record in-place, maintaining exactly one record per manifest.id. Given the PLUGIN_ORIGIN_RANK precedence (config=0 vs bundled=3), consider replacing in-place:

Suggested change
if (isBundledAndConfig) {
// Skip the duplicate warning - config entry is intentionally for the same bundled channel plugin
// Add both entries so loader can handle precedence
seenIds.set(manifest.id, { candidate, recordIndex: records.length });
records.push(
buildRecord({
manifest,
candidate,
manifestPath: manifestRes.manifestPath,
schemaCacheKey,
configSchema,
}),
);
continue;
if (isBundledAndConfig) {
// Prefer config over bundled per PLUGIN_ORIGIN_RANK precedence.
if (PLUGIN_ORIGIN_RANK[candidate.origin] < PLUGIN_ORIGIN_RANK[existing.candidate.origin]) {
records[existing.recordIndex] = buildRecord({
manifest,
candidate,
manifestPath: manifestRes.manifestPath,
schemaCacheKey,
configSchema,
});
seenIds.set(manifest.id, { candidate, recordIndex: existing.recordIndex });
}
continue;
}

This ensures only one record per plugin ID, consistent with the samePlugin pattern.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/plugins/manifest-registry.ts
Line: 238-251

Comment:
The `isBundledAndConfig` branch pushes a new record to `records[]` and updates `seenIds` to point to the current candidate. If a third candidate with the same `manifest.id` arrives with alternating origin (e.g., another `bundled` after `seenIds` was updated to `config`), the condition evaluates to true again and pushes another record. This can repeat unboundedly with each alternating candidate.

This diverges from the `samePlugin` path (line 221), which replaces the lower-precedence record in-place, maintaining exactly one record per `manifest.id`. Given the `PLUGIN_ORIGIN_RANK` precedence (config=0 vs bundled=3), consider replacing in-place:

```suggestion
if (isBundledAndConfig) {
  // Prefer config over bundled per PLUGIN_ORIGIN_RANK precedence.
  if (PLUGIN_ORIGIN_RANK[candidate.origin] < PLUGIN_ORIGIN_RANK[existing.candidate.origin]) {
    records[existing.recordIndex] = buildRecord({
      manifest,
      candidate,
      manifestPath: manifestRes.manifestPath,
      schemaCacheKey,
      configSchema,
    });
    seenIds.set(manifest.id, { candidate, recordIndex: existing.recordIndex });
  }
  continue;
}
```

This ensures only one record per plugin ID, consistent with the `samePlugin` pattern.

How can I resolve this? If you propose a fix, please make it concise.

@babycoder-dev babycoder-dev force-pushed the fix/feishu-block-streaming-duplicate branch from e979e55 to 045e4b2 Compare March 8, 2026 02:53
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 045e4b2000

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

queueStreamingUpdate(text, { mode: "delta" });
// Skip if already in streamText to avoid duplicating content already sent
// via onPartialReply (which uses snapshot mode and updates lastPartial).
if (!streamText.includes(text)) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Stop deduping block deltas by substring match

This guard drops any block chunk whose text already appears anywhere in streamText, but this branch feeds queueStreamingUpdate(..., { mode: "delta" }), where repeated chunks are valid output. In streams that emit duplicate/recurring delta tokens (for example repeated short words or characters), later chunks are skipped and the fallback block-only path can close with truncated card content.

Useful? React with 👍 / 👎.

});
seenIds.set(manifest.id, { candidate, recordIndex: existing.recordIndex });
}
continue;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Retain bundled duplicates so loader can mark overrides

Silently continuing on bundled+config ID collisions removes the bundled entry from manifestRegistry.plugins; downstream, loadOpenClawPlugins only processes candidates whose rootDir exists in that registry map (src/plugins/loader.ts:592-604), so the bundled plugin is skipped entirely instead of being surfaced as an overridden/disabled duplicate. This regresses duplicate-precedence visibility for users inspecting plugin status and diagnostics.

Useful? React with 👍 / 👎.

@babycoder-dev babycoder-dev force-pushed the fix/feishu-block-streaming-duplicate branch from 045e4b2 to b678a01 Compare March 8, 2026 03:05
…ith tool calls

When blockStreaming is enabled, tool call responses were being duplicated in
streaming cards (e.g. "看起来看起来 XX 的 Actions..."). This happened because
block payloads were being added via delta mode without checking if the text
was already present in streamText (sent via onPartialReply in snapshot mode).

Fixes openclaw#38938
@babycoder-dev babycoder-dev force-pushed the fix/feishu-block-streaming-duplicate branch from b678a01 to 898c743 Compare March 8, 2026 03:09
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b6d93365dc

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

// Use dedupeWithLastPartial to avoid duplicating content already sent via
// onPartialReply (which sets lastPartial). This does NOT dedupe between
// consecutive blocks - only blocks matching what onPartialReply last sent.
queueStreamingUpdate(text, { mode: "delta", dedupeWithLastPartial: true });
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve repeated block deltas in streaming updates

Passing dedupeWithLastPartial: true on the mode: "delta" block path causes legitimate repeated chunks to be dropped, because queueStreamingUpdate updates lastPartial on every deduped call and then suppresses the next identical chunk. In streams that emit identical adjacent deltas (for example repeated short tokens) without an intervening onPartialReply snapshot, the second chunk is discarded and the final card content is truncated.

Useful? React with 👍 / 👎.

@openclaw-barnacle
Copy link
Copy Markdown

This pull request has been automatically marked as stale due to inactivity.
Please add updates or it will be closed.

@openclaw-barnacle openclaw-barnacle Bot added the stale Marked as stale due to inactivity label Apr 23, 2026
@vincentkoc vincentkoc self-assigned this Apr 25, 2026
@vincentkoc vincentkoc added dedupe:child Duplicate issue/PR child in dedupe cluster close:duplicate Closed as duplicate labels Apr 25, 2026
@vincentkoc
Copy link
Copy Markdown
Member

Good catch on the block/partial duplication. I’m closing this as covered by #71523; the canonical PR now skips exact block payloads that repeat the latest partial snapshot and keeps the final card lifecycle on one stream.

If your repro involved non-exact block boundary resets, that is a narrower follow-up and I can reopen this for that boundary.

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:duplicate Closed as duplicate dedupe:child Duplicate issue/PR child in dedupe cluster size: XS stale Marked as stale due to inactivity

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants