Skip to content

Commit fb9a21a

Browse files
committed
fix: centralize draft preview finalization
1 parent ffef84d commit fb9a21a

33 files changed

Lines changed: 826 additions & 197 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
1111

1212
### Fixes
1313

14+
- Channels/preview streaming: centralize draft-preview finalization so Slack, Discord, Mattermost, and Matrix no longer flush temporary preview messages for media/error finals, and preserve first-reply threading for normal fallback delivery.
1415
- Discord: keep slash command follow-up chunks ephemeral when the command is configured for ephemeral replies, so long `/status` output no longer leaks fallback model or runtime details into the public channel. (#69869) thanks @gumadeiras.
1516
- Plugins/discovery: reject package plugin source entries that escape the package directory before explicit runtime entries or inferred built JavaScript peers can be used. (#69868) thanks @gumadeiras.
1617
- CLI/channels: resolve channel presence through a shared policy that keeps ambient env vars and stale persisted auth from surfacing disabled bundled plugins in status, doctor, security audit, and cron delivery validation unless the channel or plugin is effectively enabled or explicitly configured. (#69862) Thanks @gumadeiras.
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
cfeee4630cb43ffc4d702f207d28d35962c6458aa8fd2b1671c35e0be158bb35 plugin-sdk-api-baseline.json
2-
af4fbf19861c6ec000b41ac5a3ded597700e45bb15f8b1d74bb2d1f550bd09b6 plugin-sdk-api-baseline.jsonl
1+
3a2cde4b15041b5456420b2052b572f9968a93690814d2cf924382fd2f54d1d3 plugin-sdk-api-baseline.json
2+
38cd9086be93fc9531a8036812c197118c7830d52d40424be08dc9c6d51092e2 plugin-sdk-api-baseline.jsonl

docs/channels/discord.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,7 @@ Default slash command settings:
593593
- `channels.discord.streamMode` is a legacy alias and is auto-migrated.
594594
- `partial` edits a single preview message as tokens arrive.
595595
- `block` emits draft-sized chunks (use `draftChunk` to tune size and breakpoints).
596+
- Media, error, and explicit-reply finals cancel pending preview edits without flushing a temporary draft before normal delivery.
596597
- `streaming.preview.toolProgress` controls whether tool/progress updates reuse the same draft preview message (default: `true`). Set `false` to keep separate tool/progress messages.
597598

598599
Example:

docs/channels/mattermost.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ Notes:
246246

247247
## Preview streaming
248248

249-
Mattermost streams thinking, tool activity, and partial reply text into a single **draft preview post** that finalizes in place when the final answer is safe to send. The preview updates on the same post id instead of spamming the channel with per-chunk messages.
249+
Mattermost streams thinking, tool activity, and partial reply text into a single **draft preview post** that finalizes in place when the final answer is safe to send. The preview updates on the same post id instead of spamming the channel with per-chunk messages. Media/error finals cancel pending preview edits and use normal delivery instead of flushing a throwaway preview post.
250250

251251
Enable via `channels.mattermost.streaming`:
252252

docs/channels/slack.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -742,6 +742,7 @@ Notes:
742742
- Channel and group-chat roots can still use the normal draft preview when native streaming is unavailable.
743743
- Top-level Slack DMs stay off-thread by default, so they do not show the thread-style preview; use thread replies or `typingReaction` if you want visible progress there.
744744
- Media and non-text payloads fall back to normal delivery.
745+
- Media/error finals cancel pending preview edits without flushing a temporary draft; eligible text/block finals flush only when they can edit the preview in place.
745746
- If streaming fails mid-reply, OpenClaw falls back to normal delivery for remaining payloads.
746747

747748
Use draft preview instead of Slack native text streaming:

docs/concepts/streaming.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,17 +149,25 @@ Discord:
149149
- Uses send + edit preview messages.
150150
- `block` mode uses draft chunking (`draftChunk`).
151151
- Preview streaming is skipped when Discord block streaming is explicitly enabled.
152+
- Final media, error, and explicit-reply payloads cancel pending previews without flushing a new draft, then use normal delivery.
152153

153154
Slack:
154155

155156
- `partial` can use Slack native streaming (`chat.startStream`/`append`/`stop`) when available.
156157
- `block` uses append-style draft previews.
157158
- `progress` uses status preview text, then final answer.
159+
- Final media/error payloads and progress finals do not create throwaway draft messages; only text/block finals that can edit the preview flush pending draft text.
158160

159161
Mattermost:
160162

161163
- Streams thinking, tool activity, and partial reply text into a single draft preview post that finalizes in place when the final answer is safe to send.
162164
- Falls back to sending a fresh final post if the preview post was deleted or is otherwise unavailable at finalize time.
165+
- Final media/error payloads cancel pending preview updates before normal delivery instead of flushing a temporary preview post.
166+
167+
Matrix:
168+
169+
- Draft previews finalize in place when the final text can reuse the preview event.
170+
- Media-only, error, and reply-target-mismatch finals cancel pending preview updates before normal delivery; an already-visible stale preview is redacted.
163171

164172
### Tool-progress preview updates
165173

docs/plugins/sdk-migration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ Current bundled provider examples:
202202
| `plugin-sdk/channel-config-schema` | Config schema builders | Channel config schema types |
203203
| `plugin-sdk/telegram-command-config` | Telegram command config helpers | Command-name normalization, description trimming, duplicate/conflict validation |
204204
| `plugin-sdk/channel-policy` | Group/DM policy resolution | `resolveChannelGroupRequireMention` |
205-
| `plugin-sdk/channel-lifecycle` | Account status tracking | `createAccountStatusSink` |
205+
| `plugin-sdk/channel-lifecycle` | Account status and draft stream lifecycle helpers | `createAccountStatusSink`, draft preview finalization helpers |
206206
| `plugin-sdk/inbound-envelope` | Inbound envelope helpers | Shared route + envelope builder helpers |
207207
| `plugin-sdk/inbound-reply-dispatch` | Inbound reply helpers | Shared record-and-dispatch helpers |
208208
| `plugin-sdk/messaging-targets` | Messaging target parsing | Target parsing/matching helpers |

docs/plugins/sdk-overview.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ explicitly promotes one as public.
9090
| `plugin-sdk/telegram-command-config` | Telegram custom-command normalization/validation helpers with bundled-contract fallback |
9191
| `plugin-sdk/command-gating` | Narrow command authorization gate helpers |
9292
| `plugin-sdk/channel-policy` | `resolveChannelGroupRequireMention` |
93-
| `plugin-sdk/channel-lifecycle` | `createAccountStatusSink` |
93+
| `plugin-sdk/channel-lifecycle` | `createAccountStatusSink`, draft stream lifecycle/finalization helpers |
9494
| `plugin-sdk/inbound-envelope` | Shared inbound route + envelope builder helpers |
9595
| `plugin-sdk/inbound-reply-dispatch` | Shared inbound record-and-dispatch helpers |
9696
| `plugin-sdk/messaging-targets` | Target parsing/matching helpers |

extensions/discord/src/draft-stream.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,50 @@ describe("createDiscordDraftStream", () => {
7878
expect(warn).toHaveBeenCalledWith(expect.stringContaining("discord stream preview stopped"));
7979
expect(stream.messageId()).toBeUndefined();
8080
});
81+
82+
it("discardPending keeps an existing preview but ignores later updates", async () => {
83+
const rest = {
84+
post: vi.fn(async () => ({ id: "m1" })),
85+
patch: vi.fn(async () => undefined),
86+
delete: vi.fn(async () => undefined),
87+
};
88+
const stream = createDiscordDraftStream({
89+
rest: rest as never,
90+
channelId: "c1",
91+
throttleMs: 250,
92+
});
93+
94+
stream.update("first draft");
95+
await stream.flush();
96+
await stream.discardPending();
97+
stream.update("late draft");
98+
await stream.flush();
99+
100+
expect(rest.post).toHaveBeenCalledTimes(1);
101+
expect(rest.patch).not.toHaveBeenCalled();
102+
expect(rest.delete).not.toHaveBeenCalled();
103+
expect(stream.messageId()).toBe("m1");
104+
});
105+
106+
it("seal keeps an existing preview and cancels pending final overwrites", async () => {
107+
const rest = {
108+
post: vi.fn(async () => ({ id: "m1" })),
109+
patch: vi.fn(async () => undefined),
110+
delete: vi.fn(async () => undefined),
111+
};
112+
const stream = createDiscordDraftStream({
113+
rest: rest as never,
114+
channelId: "c1",
115+
throttleMs: 250,
116+
});
117+
118+
stream.update("first draft");
119+
await stream.flush();
120+
stream.update("stale final draft");
121+
await stream.seal();
122+
123+
expect(rest.post).toHaveBeenCalledTimes(1);
124+
expect(rest.patch).not.toHaveBeenCalled();
125+
expect(stream.messageId()).toBe("m1");
126+
});
81127
});

extensions/discord/src/draft-stream.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export type DiscordDraftStream = {
1212
flush: () => Promise<void>;
1313
messageId: () => string | undefined;
1414
clear: () => Promise<void>;
15+
discardPending: () => Promise<void>;
16+
seal: () => Promise<void>;
1517
stop: () => Promise<void>;
1618
/** Reset internal state so the next update creates a new message instead of editing. */
1719
forceNewMessage: () => void;
@@ -113,7 +115,7 @@ export function createDiscordDraftStream(params: {
113115
await rest.delete(Routes.channelMessage(channelId, messageId));
114116
};
115117

116-
const { loop, update, stop, clear } = createFinalizableDraftLifecycle({
118+
const { loop, update, stop, clear, discardPending, seal } = createFinalizableDraftLifecycle({
117119
throttleMs,
118120
state: streamState,
119121
sendOrEditStreamMessage,
@@ -138,6 +140,8 @@ export function createDiscordDraftStream(params: {
138140
flush: loop.flush,
139141
messageId: () => streamMessageId,
140142
clear,
143+
discardPending,
144+
seal,
141145
stop,
142146
forceNewMessage,
143147
};

0 commit comments

Comments
 (0)