Skip to content

Commit 7c4601e

Browse files
feat(slack): render progress as native task cards
Render Slack progress-mode updates as native task-card progress blocks, with bounded Slack chunk text and stable fallback behavior. Also deep-merge Slack account streaming objects over top-level defaults while preserving legacy scalar account overrides, and keep the plugin SDK fetch runtime import path from evaluating guarded-fetch dispatcher code. Verification: - pnpm test extensions/slack/src/progress-blocks.test.ts extensions/slack/src/accounts.test.ts src/plugin-sdk/fetch-runtime.test.ts - pnpm lint --threads=8 - git diff --check - .agents/skills/autoreview/scripts/autoreview --mode local - GitHub PR checks green on #87748 at 4803e98 Refs #82258 Co-authored-by: Simon van Laak <[email protected]>
1 parent 5880782 commit 7c4601e

22 files changed

Lines changed: 1932 additions & 124 deletions

docs/channels/slack.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1120,6 +1120,8 @@ Hide raw command/exec text while keeping compact progress lines:
11201120

11211121
`channels.slack.streaming.nativeTransport` controls Slack native text streaming when `channels.slack.streaming.mode` is `partial` (default: `true`).
11221122

1123+
Slack native progress task cards are opt-in for progress mode. Set `channels.slack.streaming.progress.nativeTaskCards` to `true` with `channels.slack.streaming.mode="progress"` to send a Slack-native plan/task card while work is running, then update the same task card at completion. Without this flag, progress mode keeps the portable draft-preview behavior.
1124+
11231125
- A reply thread must be available for native text streaming and Slack assistant thread status to appear. Thread selection still follows `replyToMode`.
11241126
- Channel, group-chat, and top-level DM roots can still use the normal draft preview when native streaming is unavailable or no reply thread exists.
11251127
- Top-level Slack DMs stay off-thread by default, so they do not show Slack's thread-style native stream/status preview; OpenClaw posts and edits a draft preview in the DM instead.
@@ -1142,6 +1144,24 @@ Use draft preview instead of Slack native text streaming:
11421144
}
11431145
```
11441146

1147+
Opt in to Slack native progress task cards:
1148+
1149+
```json5
1150+
{
1151+
channels: {
1152+
slack: {
1153+
streaming: {
1154+
mode: "progress",
1155+
progress: {
1156+
nativeTaskCards: true,
1157+
render: "rich",
1158+
},
1159+
},
1160+
},
1161+
},
1162+
}
1163+
```
1164+
11451165
Legacy keys:
11461166

11471167
- `channels.slack.streamMode` (`replace | status_final | append`) is a legacy runtime alias for `channels.slack.streaming.mode`.

extensions/slack/src/accounts.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,65 @@ describe("resolveSlackAccount allowFrom precedence", () => {
194194
});
195195
});
196196

197+
it("merges canonical account streaming over top-level defaults field-by-field", () => {
198+
const resolved = resolveSlackAccount({
199+
cfg: {
200+
channels: {
201+
slack: {
202+
streaming: {
203+
mode: "progress",
204+
nativeTransport: true,
205+
preview: { toolProgress: true, commandText: "raw" },
206+
progress: { label: "Shelling", commandText: "status" },
207+
block: { enabled: true, coalesce: { minChars: 40, maxChars: 80, idleMs: 250 } },
208+
},
209+
accounts: {
210+
work: {
211+
botToken: "xoxb-work",
212+
appToken: "xapp-work",
213+
streaming: {
214+
progress: { nativeTaskCards: true },
215+
block: { coalesce: { idleMs: 500 } },
216+
},
217+
},
218+
},
219+
},
220+
},
221+
},
222+
accountId: "work",
223+
});
224+
225+
expect(resolved.config.streaming).toEqual({
226+
mode: "progress",
227+
nativeTransport: true,
228+
preview: { toolProgress: true, commandText: "raw" },
229+
progress: { label: "Shelling", commandText: "status", nativeTaskCards: true },
230+
block: { enabled: true, coalesce: { minChars: 40, maxChars: 80, idleMs: 500 } },
231+
});
232+
});
233+
234+
it("preserves account legacy scalar streaming overrides", () => {
235+
const resolved = resolveSlackAccount({
236+
cfg: {
237+
channels: {
238+
slack: {
239+
streaming: { mode: "progress", progress: { label: "Shelling" } },
240+
accounts: {
241+
work: {
242+
botToken: "xoxb-work",
243+
appToken: "xapp-work",
244+
streaming: "off",
245+
},
246+
},
247+
},
248+
},
249+
} as unknown as OpenClawConfig,
250+
accountId: "work",
251+
});
252+
253+
expect(resolved.config.streaming).toBe("off");
254+
});
255+
197256
it("does not inherit default account allowFrom for named account when top-level is absent", () => {
198257
const resolved = resolveSlackAccount({
199258
cfg: {

extensions/slack/src/accounts.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,16 +69,80 @@ function resolveSlackAccountConfig(
6969
return resolveAccountEntry(cfg.channels?.slack?.accounts, accountId);
7070
}
7171

72+
type SlackStreamingConfig = NonNullable<SlackAccountConfig["streaming"]>;
73+
type SlackStreamingConfigValue = SlackStreamingConfig | boolean | string;
74+
75+
function asStreamingConfigObject(value: unknown): SlackStreamingConfig | undefined {
76+
return value && typeof value === "object" && !Array.isArray(value)
77+
? (value as SlackStreamingConfig)
78+
: undefined;
79+
}
80+
81+
function asLegacyStreamingScalar(value: unknown): boolean | string | undefined {
82+
return typeof value === "boolean" || typeof value === "string" ? value : undefined;
83+
}
84+
85+
function mergeSlackStreamingConfig(
86+
base: unknown,
87+
account: unknown,
88+
): SlackStreamingConfigValue | undefined {
89+
const accountObject = asStreamingConfigObject(account);
90+
if (account !== undefined && !accountObject) {
91+
return asLegacyStreamingScalar(account);
92+
}
93+
const baseObject = asStreamingConfigObject(base);
94+
if (base !== undefined && !baseObject) {
95+
return accountObject ?? asLegacyStreamingScalar(base);
96+
}
97+
const baseConfig = baseObject;
98+
const accountConfig = accountObject;
99+
if (!baseConfig || !accountConfig) {
100+
return accountConfig ?? baseConfig;
101+
}
102+
return {
103+
...baseConfig,
104+
...accountConfig,
105+
...(baseConfig.preview || accountConfig.preview
106+
? { preview: { ...baseConfig.preview, ...accountConfig.preview } }
107+
: {}),
108+
...(baseConfig.progress || accountConfig.progress
109+
? { progress: { ...baseConfig.progress, ...accountConfig.progress } }
110+
: {}),
111+
...(baseConfig.block || accountConfig.block
112+
? {
113+
block: {
114+
...baseConfig.block,
115+
...accountConfig.block,
116+
...(baseConfig.block?.coalesce || accountConfig.block?.coalesce
117+
? {
118+
coalesce: {
119+
...baseConfig.block?.coalesce,
120+
...accountConfig.block?.coalesce,
121+
},
122+
}
123+
: {}),
124+
},
125+
}
126+
: {}),
127+
};
128+
}
129+
72130
export function mergeSlackAccountConfig(
73131
cfg: OpenClawConfig,
74132
accountId: string,
75133
): SlackAccountConfig {
76-
return resolveMergedAccountConfig<SlackAccountConfig>({
134+
const accountConfig = resolveSlackAccountConfig(cfg, accountId);
135+
const merged = resolveMergedAccountConfig<SlackAccountConfig>({
77136
channelConfig: cfg.channels?.slack as SlackAccountConfig,
78137
accounts: cfg.channels?.slack?.accounts as Record<string, Partial<SlackAccountConfig>>,
79138
accountId,
80139
nestedObjectKeys: ["botLoopProtection"],
81140
});
141+
const streaming = mergeSlackStreamingConfig(
142+
(cfg.channels?.slack as Record<string, unknown> | undefined)?.streaming,
143+
(accountConfig as Record<string, unknown> | undefined)?.streaming,
144+
);
145+
return streaming !== undefined ? ({ ...merged, streaming } as SlackAccountConfig) : merged;
82146
}
83147

84148
export function resolveSlackAccountAllowFrom(params: {

extensions/slack/src/config-ui-hints.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,10 @@ export const slackChannelConfigUiHints = {
161161
label: "Slack Progress Renderer",
162162
help: 'Progress draft renderer: "text" uses one portable text body; "rich" renders structured Slack Block Kit fields with the same text fallback.',
163163
},
164+
"streaming.progress.nativeTaskCards": {
165+
label: "Slack Native Progress Task Cards",
166+
help: 'Opt in to Slack native task-card progress updates when channels.slack.streaming.mode="progress" and streaming.nativeTransport is enabled. Default: false.',
167+
},
164168
"streaming.progress.toolProgress": {
165169
label: "Slack Progress Tool Lines",
166170
help: "Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery.",

0 commit comments

Comments
 (0)