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 4803e98820

Refs #82258

Co-authored-by: Simon van Laak <32648751+simonvanlaak@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-05-28 20:08:51 +01:00
committed by GitHub
parent 588078224b
commit 7c4601ec73
22 changed files with 1932 additions and 124 deletions
+20
View File
@@ -1120,6 +1120,8 @@ Hide raw command/exec text while keeping compact progress lines:
`channels.slack.streaming.nativeTransport` controls Slack native text streaming when `channels.slack.streaming.mode` is `partial` (default: `true`).
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.
- A reply thread must be available for native text streaming and Slack assistant thread status to appear. Thread selection still follows `replyToMode`.
- 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.
- 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:
}
```
Opt in to Slack native progress task cards:
```json5
{
channels: {
slack: {
streaming: {
mode: "progress",
progress: {
nativeTaskCards: true,
render: "rich",
},
},
},
},
}
```
Legacy keys:
- `channels.slack.streamMode` (`replace | status_final | append`) is a legacy runtime alias for `channels.slack.streaming.mode`.
+59
View File
@@ -194,6 +194,65 @@ describe("resolveSlackAccount allowFrom precedence", () => {
});
});
it("merges canonical account streaming over top-level defaults field-by-field", () => {
const resolved = resolveSlackAccount({
cfg: {
channels: {
slack: {
streaming: {
mode: "progress",
nativeTransport: true,
preview: { toolProgress: true, commandText: "raw" },
progress: { label: "Shelling", commandText: "status" },
block: { enabled: true, coalesce: { minChars: 40, maxChars: 80, idleMs: 250 } },
},
accounts: {
work: {
botToken: "xoxb-work",
appToken: "xapp-work",
streaming: {
progress: { nativeTaskCards: true },
block: { coalesce: { idleMs: 500 } },
},
},
},
},
},
},
accountId: "work",
});
expect(resolved.config.streaming).toEqual({
mode: "progress",
nativeTransport: true,
preview: { toolProgress: true, commandText: "raw" },
progress: { label: "Shelling", commandText: "status", nativeTaskCards: true },
block: { enabled: true, coalesce: { minChars: 40, maxChars: 80, idleMs: 500 } },
});
});
it("preserves account legacy scalar streaming overrides", () => {
const resolved = resolveSlackAccount({
cfg: {
channels: {
slack: {
streaming: { mode: "progress", progress: { label: "Shelling" } },
accounts: {
work: {
botToken: "xoxb-work",
appToken: "xapp-work",
streaming: "off",
},
},
},
},
} as unknown as OpenClawConfig,
accountId: "work",
});
expect(resolved.config.streaming).toBe("off");
});
it("does not inherit default account allowFrom for named account when top-level is absent", () => {
const resolved = resolveSlackAccount({
cfg: {
+65 -1
View File
@@ -69,16 +69,80 @@ function resolveSlackAccountConfig(
return resolveAccountEntry(cfg.channels?.slack?.accounts, accountId);
}
type SlackStreamingConfig = NonNullable<SlackAccountConfig["streaming"]>;
type SlackStreamingConfigValue = SlackStreamingConfig | boolean | string;
function asStreamingConfigObject(value: unknown): SlackStreamingConfig | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as SlackStreamingConfig)
: undefined;
}
function asLegacyStreamingScalar(value: unknown): boolean | string | undefined {
return typeof value === "boolean" || typeof value === "string" ? value : undefined;
}
function mergeSlackStreamingConfig(
base: unknown,
account: unknown,
): SlackStreamingConfigValue | undefined {
const accountObject = asStreamingConfigObject(account);
if (account !== undefined && !accountObject) {
return asLegacyStreamingScalar(account);
}
const baseObject = asStreamingConfigObject(base);
if (base !== undefined && !baseObject) {
return accountObject ?? asLegacyStreamingScalar(base);
}
const baseConfig = baseObject;
const accountConfig = accountObject;
if (!baseConfig || !accountConfig) {
return accountConfig ?? baseConfig;
}
return {
...baseConfig,
...accountConfig,
...(baseConfig.preview || accountConfig.preview
? { preview: { ...baseConfig.preview, ...accountConfig.preview } }
: {}),
...(baseConfig.progress || accountConfig.progress
? { progress: { ...baseConfig.progress, ...accountConfig.progress } }
: {}),
...(baseConfig.block || accountConfig.block
? {
block: {
...baseConfig.block,
...accountConfig.block,
...(baseConfig.block?.coalesce || accountConfig.block?.coalesce
? {
coalesce: {
...baseConfig.block?.coalesce,
...accountConfig.block?.coalesce,
},
}
: {}),
},
}
: {}),
};
}
export function mergeSlackAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): SlackAccountConfig {
return resolveMergedAccountConfig<SlackAccountConfig>({
const accountConfig = resolveSlackAccountConfig(cfg, accountId);
const merged = resolveMergedAccountConfig<SlackAccountConfig>({
channelConfig: cfg.channels?.slack as SlackAccountConfig,
accounts: cfg.channels?.slack?.accounts as Record<string, Partial<SlackAccountConfig>>,
accountId,
nestedObjectKeys: ["botLoopProtection"],
});
const streaming = mergeSlackStreamingConfig(
(cfg.channels?.slack as Record<string, unknown> | undefined)?.streaming,
(accountConfig as Record<string, unknown> | undefined)?.streaming,
);
return streaming !== undefined ? ({ ...merged, streaming } as SlackAccountConfig) : merged;
}
export function resolveSlackAccountAllowFrom(params: {
+4
View File
@@ -161,6 +161,10 @@ export const slackChannelConfigUiHints = {
label: "Slack Progress Renderer",
help: 'Progress draft renderer: "text" uses one portable text body; "rich" renders structured Slack Block Kit fields with the same text fallback.',
},
"streaming.progress.nativeTaskCards": {
label: "Slack Native Progress Task Cards",
help: 'Opt in to Slack native task-card progress updates when channels.slack.streaming.mode="progress" and streaming.nativeTransport is enabled. Default: false.',
},
"streaming.progress.toolProgress": {
label: "Slack Progress Tool Lines",
help: "Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery.",
@@ -8,6 +8,7 @@ const createSlackDraftStreamMock = vi.fn();
const deliverRepliesMock = vi.fn(async () => {});
const finalizeSlackPreviewEditMock = vi.fn(async () => {});
const postMessageMock = vi.fn(async () => ({ ok: true, ts: "171234.999" }));
const chatUpdateMock = vi.fn(async () => ({ ok: true, ts: "171234.999" }));
const recordInboundSessionMock = vi.fn(async () => undefined);
const updateLastRouteMock = vi.fn(async () => {});
const appendSlackStreamMock = vi.fn(async () => {});
@@ -40,8 +41,11 @@ let capturedReplyOptions:
| {
disableBlockStreaming?: boolean;
suppressDefaultToolProgressMessages?: boolean;
onAssistantMessageStart?: () => Promise<void> | void;
onReasoningEnd?: () => Promise<void> | void;
onItemEvent?: (payload: {
kind?: string;
itemId?: string;
progressText?: string;
summary?: string;
title?: string;
@@ -50,6 +54,25 @@ let capturedReplyOptions:
status?: string;
meta?: string;
}) => Promise<void> | void;
onToolStart?: (payload: {
itemId?: string;
toolCallId?: string;
name: string;
phase?: string;
args?: Record<string, unknown>;
detailMode?: "explain" | "raw";
}) => Promise<void> | void;
onPatchSummary?: (payload: {
itemId?: string;
toolCallId?: string;
phase?: string;
title?: string;
name?: string;
added?: string[];
modified?: string[];
deleted?: string[];
summary?: string;
}) => Promise<void> | void;
onPartialReply?: (payload: { text: string }) => Promise<void> | void;
}
| undefined;
@@ -96,6 +119,7 @@ let mockedProgressEvents: string[] = [];
let mockedReplyOptionEvents: Array<
| {
kind: "item";
itemId?: string;
itemKind?: string;
progressText?: string;
summary?: string;
@@ -105,7 +129,31 @@ let mockedReplyOptionEvents: Array<
status?: string;
meta?: string;
}
| {
kind: "tool_start";
itemId?: string;
toolCallId?: string;
name: string;
phase?: string;
args?: Record<string, unknown>;
detailMode?: "explain" | "raw";
}
| {
kind: "patch";
itemId?: string;
toolCallId?: string;
phase?: string;
title?: string;
name?: string;
added?: string[];
modified?: string[];
deleted?: string[];
summary?: string;
}
| { kind: "concurrent_items"; progressTexts: string[] }
| { kind: "partial"; text: string }
| { kind: "assistant_start" }
| { kind: "reasoning_end" }
> = [];
function requireCapturedTyping() {
@@ -153,6 +201,31 @@ function expectMockCallArgFields(
expectRecordFields(requireRecord(requireMockCall(mock, index, label)[0], label), fields);
}
function expectNativeProgressStart(chunks: unknown[]) {
expect(postMessageMock).not.toHaveBeenCalled();
expect(chatUpdateMock).not.toHaveBeenCalled();
expectMockCallArgFields(startSlackStreamMock, 0, "native progress stream start", {
channel: "C123",
threadTs: THREAD_TS,
taskDisplayMode: "plan",
chunks,
});
}
function expectNativeProgressAppend(index: number, chunks: unknown[]) {
expectMockCallArgFields(appendSlackStreamMock, index, "native progress stream append", {
chunks,
});
}
function planUpdate(title: string) {
return { type: "plan_update", title };
}
function taskUpdate(id: unknown, title: string, status: "in_progress" | "complete" | "error") {
return { type: "task_update", id, title, status };
}
function expectDeliverReplyCall(index: number, text: string, fields?: Record<string, unknown>) {
const params = requireRecord(
requireMockCall(deliverRepliesMock, index, "deliver replies")[0],
@@ -165,6 +238,16 @@ function expectDeliverReplyCall(index: number, text: string, fields?: Record<str
const noop = () => {};
const noopAsync = async () => {};
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
function createDraftStreamStub() {
return {
update: vi.fn(),
@@ -173,7 +256,7 @@ function createDraftStreamStub() {
discardPending: noopAsync,
seal: noopAsync,
stop: noop,
forceNewMessage: noop,
forceNewMessage: vi.fn(),
messageId: () => "171234.567",
channelId: () => "C123",
};
@@ -220,7 +303,7 @@ function createPreparedSlackMessage(params?: {
cfg: params?.cfg ?? {},
runtime: {},
botToken: "xoxb-test",
app: { client: { chat: { postMessage: postMessageMock } } },
app: { client: { chat: { postMessage: postMessageMock, update: chatUpdateMock } } },
teamId: "T1",
botUserId: "U_OPENCLAW",
botId: "B_OPENCLAW",
@@ -272,6 +355,32 @@ function createPreparedSlackMessage(params?: {
} as never;
}
async function dispatchNativeProgressScenario(params: {
events: typeof mockedReplyOptionEvents;
finalPayload?: { text: string; isError?: boolean };
progress?: { label?: string; maxLineChars?: number; nativeTaskCards?: true; render?: "rich" };
replyToMode?: "off" | "first" | "all" | "batched";
}) {
mockedNativeStreaming = true;
mockedSlackStreamingMode = "progress";
mockedSlackDraftMode = "status_final";
mockedDispatchSequence =
params.finalPayload === undefined ? [] : [{ kind: "final", payload: params.finalPayload }];
mockedReplyOptionEvents = params.events;
await dispatchPreparedSlackMessage(
createPreparedSlackMessage({
replyToMode: params.replyToMode,
accountConfig: {
streaming: {
mode: "progress",
progress: params.progress ?? { nativeTaskCards: true, render: "rich" },
},
},
}),
);
}
vi.mock("openclaw/plugin-sdk/agent-runtime", () => ({
resolveHumanDelayConfig: () => undefined,
}));
@@ -343,15 +452,37 @@ vi.mock("openclaw/plugin-sdk/channel-outbound", async (importOriginal) => {
},
resolveAgentOutboundIdentity: () => undefined,
buildChannelProgressDraftLine: (params: {
event?: string;
itemId?: string;
toolCallId?: string;
progressText?: string;
summary?: string;
title?: string;
name?: string;
status?: string;
exitCode?: number | null;
}) => {
if (params.event === "command-output") {
const status =
params.exitCode === 0
? "completed"
: params.exitCode != null
? `exit ${params.exitCode}`
: params.status;
return {
kind: "command-output",
...((params.itemId ?? params.toolCallId) ? { id: params.itemId ?? params.toolCallId } : {}),
text: status ?? params.title ?? params.name ?? "exec",
label: params.name ?? "exec",
...(status ? { status } : {}),
toolName: params.name ?? "exec",
};
}
const text = params.progressText ?? params.summary ?? params.title ?? params.name;
return text
? {
kind: "item",
...((params.itemId ?? params.toolCallId) ? { id: params.itemId ?? params.toolCallId } : {}),
text,
label: params.title ?? params.name ?? "Update",
}
@@ -365,13 +496,32 @@ vi.mock("openclaw/plugin-sdk/channel-outbound", async (importOriginal) => {
};
},
params: {
event?: string;
itemId?: string;
toolCallId?: string;
itemKind?: string;
args?: Record<string, unknown>;
progressText?: string;
summary?: string;
title?: string;
name?: string;
},
) => {
if (params.event === "tool") {
const text = params.name;
return text
? {
kind: "tool",
...((params.itemId ?? params.toolCallId)
? { id: params.itemId ?? params.toolCallId }
: {}),
text,
label: params.name ?? "Tool",
...(typeof params.args?.command === "string" ? { detail: params.args.command } : {}),
toolName: params.name,
}
: undefined;
}
if (
(entry.streaming?.progress?.commandText ?? entry.streaming?.preview?.commandText) ===
"status" &&
@@ -463,6 +613,9 @@ vi.mock("openclaw/plugin-sdk/channel-outbound", async (importOriginal) => {
resolveChannelProgressDraftMaxLines: (entry?: {
streaming?: { progress?: { maxLines?: number } };
}) => entry?.streaming?.progress?.maxLines ?? 8,
resolveChannelProgressDraftMaxLineChars: (entry?: {
streaming?: { progress?: { maxLineChars?: number } };
}) => entry?.streaming?.progress?.maxLineChars,
mergeChannelProgressDraftLine: <TLine extends string | { id?: string; text: string }>(
lines: TLine[],
line: TLine,
@@ -722,6 +875,7 @@ vi.mock("../reply.runtime.js", () => ({
suppressDefaultToolProgressMessages?: boolean;
onItemEvent?: (payload: {
kind?: string;
itemId?: string;
progressText?: string;
summary?: string;
title?: string;
@@ -730,6 +884,27 @@ vi.mock("../reply.runtime.js", () => ({
status?: string;
meta?: string;
}) => Promise<void> | void;
onToolStart?: (payload: {
itemId?: string;
toolCallId?: string;
name: string;
phase?: string;
args?: Record<string, unknown>;
detailMode?: "explain" | "raw";
}) => Promise<void> | void;
onPatchSummary?: (payload: {
itemId?: string;
toolCallId?: string;
phase?: string;
title?: string;
name?: string;
added?: string[];
modified?: string[];
deleted?: string[];
summary?: string;
}) => Promise<void> | void;
onAssistantMessageStart?: () => Promise<void> | void;
onReasoningEnd?: () => Promise<void> | void;
onPartialReply?: (payload: { text: string }) => Promise<void> | void;
};
}) => {
@@ -739,6 +914,7 @@ vi.mock("../reply.runtime.js", () => ({
if (entry.kind === "item") {
await params.replyOptions?.onItemEvent?.({
kind: entry.itemKind,
itemId: entry.itemId,
progressText: entry.progressText,
summary: entry.summary,
title: entry.title,
@@ -747,6 +923,37 @@ vi.mock("../reply.runtime.js", () => ({
status: entry.status,
meta: entry.meta,
});
} else if (entry.kind === "tool_start") {
await params.replyOptions?.onToolStart?.({
itemId: entry.itemId,
toolCallId: entry.toolCallId,
name: entry.name,
phase: entry.phase,
args: entry.args,
detailMode: entry.detailMode,
});
} else if (entry.kind === "patch") {
await params.replyOptions?.onPatchSummary?.({
itemId: entry.itemId,
toolCallId: entry.toolCallId,
phase: entry.phase,
title: entry.title,
name: entry.name,
added: entry.added,
modified: entry.modified,
deleted: entry.deleted,
summary: entry.summary,
});
} else if (entry.kind === "concurrent_items") {
await Promise.all(
entry.progressTexts.map((progressText) =>
Promise.resolve(params.replyOptions?.onItemEvent?.({ progressText })),
),
);
} else if (entry.kind === "assistant_start") {
await params.replyOptions?.onAssistantMessageStart?.();
} else if (entry.kind === "reasoning_end") {
await params.replyOptions?.onReasoningEnd?.();
} else {
await params.replyOptions?.onPartialReply?.({ text: entry.text });
}
@@ -781,8 +988,11 @@ vi.mock("../reply.runtime.js", () => ({
replyOptions?: {
disableBlockStreaming?: boolean;
suppressDefaultToolProgressMessages?: boolean;
onAssistantMessageStart?: () => Promise<void> | void;
onReasoningEnd?: () => Promise<void> | void;
onItemEvent?: (payload: {
kind?: string;
itemId?: string;
progressText?: string;
summary?: string;
title?: string;
@@ -791,6 +1001,25 @@ vi.mock("../reply.runtime.js", () => ({
status?: string;
meta?: string;
}) => Promise<void> | void;
onToolStart?: (payload: {
itemId?: string;
toolCallId?: string;
name: string;
phase?: string;
args?: Record<string, unknown>;
detailMode?: "explain" | "raw";
}) => Promise<void> | void;
onPatchSummary?: (payload: {
itemId?: string;
toolCallId?: string;
phase?: string;
title?: string;
name?: string;
added?: string[];
modified?: string[];
deleted?: string[];
summary?: string;
}) => Promise<void> | void;
onPartialReply?: (payload: { text: string }) => Promise<void> | void;
};
dispatcher: {
@@ -803,6 +1032,7 @@ vi.mock("../reply.runtime.js", () => ({
if (entry.kind === "item") {
await params.replyOptions?.onItemEvent?.({
kind: entry.itemKind,
itemId: entry.itemId,
progressText: entry.progressText,
summary: entry.summary,
title: entry.title,
@@ -811,8 +1041,39 @@ vi.mock("../reply.runtime.js", () => ({
status: entry.status,
meta: entry.meta,
});
} else {
} else if (entry.kind === "tool_start") {
await params.replyOptions?.onToolStart?.({
itemId: entry.itemId,
toolCallId: entry.toolCallId,
name: entry.name,
phase: entry.phase,
args: entry.args,
detailMode: entry.detailMode,
});
} else if (entry.kind === "patch") {
await params.replyOptions?.onPatchSummary?.({
itemId: entry.itemId,
toolCallId: entry.toolCallId,
phase: entry.phase,
title: entry.title,
name: entry.name,
added: entry.added,
modified: entry.modified,
deleted: entry.deleted,
summary: entry.summary,
});
} else if (entry.kind === "concurrent_items") {
await Promise.all(
entry.progressTexts.map((progressText) =>
Promise.resolve(params.replyOptions?.onItemEvent?.({ progressText })),
),
);
} else if (entry.kind === "partial") {
await params.replyOptions?.onPartialReply?.({ text: entry.text });
} else if (entry.kind === "assistant_start") {
await params.replyOptions?.onAssistantMessageStart?.();
} else {
await params.replyOptions?.onReasoningEnd?.();
}
}
} else {
@@ -846,6 +1107,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
deliverRepliesMock.mockReset();
finalizeSlackPreviewEditMock.mockReset();
postMessageMock.mockClear();
chatUpdateMock.mockClear();
recordInboundSessionMock.mockReset();
updateLastRouteMock.mockReset();
appendSlackStreamMock.mockReset();
@@ -1416,6 +1678,402 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
);
});
it("renders rich status-final progress drafts as legacy Slack section blocks and finalizes once", async () => {
const draftStream = {
...createDraftStreamStub(),
flush: vi.fn(noopAsync),
clear: vi.fn(noopAsync),
discardPending: vi.fn(noopAsync),
seal: vi.fn(noopAsync),
};
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
finalizeSlackPreviewEditMock.mockResolvedValueOnce(undefined);
mockedSlackStreamingMode = "progress";
mockedSlackDraftMode = "status_final";
mockedDispatchSequence = [{ kind: "final", payload: { text: FINAL_REPLY_TEXT } }];
mockedReplyOptionEvents = [
{ kind: "item", progressText: "tool one" },
{ kind: "partial", text: "partial answer" },
{ kind: "item", progressText: "tool two" },
];
await dispatchPreparedSlackMessage(
createPreparedSlackMessage({
accountConfig: { streaming: { progress: { label: "Shelling", render: "rich" } } },
}),
);
expect(draftStream.update).toHaveBeenLastCalledWith({
text: ["Shelling", "• tool one", "• tool two"].join("\n"),
blocks: [
{
type: "section",
text: { type: "mrkdwn", text: "*Shelling*" },
},
{
type: "section",
fields: [
{ type: "mrkdwn", text: "• *Update*" },
{ type: "mrkdwn", text: "—" },
],
},
{
type: "section",
fields: [
{ type: "mrkdwn", text: "• *Update*" },
{ type: "mrkdwn", text: "—" },
],
},
],
});
expectMockCallArgFields(finalizeSlackPreviewEditMock, 0, "preview edit params", {
channelId: "C123",
messageId: "171234.567",
text: FINAL_REPLY_TEXT,
});
expect(deliverRepliesMock).not.toHaveBeenCalled();
expect(draftStream.clear).not.toHaveBeenCalled();
});
it("keeps unlabeled rich Slack progress drafts as legacy section blocks", async () => {
const draftStream = {
...createDraftStreamStub(),
flush: vi.fn(noopAsync),
clear: vi.fn(noopAsync),
discardPending: vi.fn(noopAsync),
seal: vi.fn(noopAsync),
};
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
finalizeSlackPreviewEditMock.mockResolvedValueOnce(undefined);
mockedSlackStreamingMode = "progress";
mockedSlackDraftMode = "status_final";
mockedDispatchSequence = [{ kind: "final", payload: { text: FINAL_REPLY_TEXT } }];
mockedReplyOptionEvents = [
{ kind: "item", progressText: "tool one" },
{ kind: "partial", text: "partial answer" },
{ kind: "item", progressText: "tool two" },
];
await dispatchPreparedSlackMessage(
createPreparedSlackMessage({
accountConfig: { streaming: { progress: { render: "rich" } } },
}),
);
expect(draftStream.update).toHaveBeenLastCalledWith({
text: ["Thinking", "• tool one", "• tool two"].join("\n"),
blocks: [
{
type: "section",
fields: [
{ type: "mrkdwn", text: "• *Update*" },
{ type: "mrkdwn", text: "—" },
],
},
{
type: "section",
fields: [
{ type: "mrkdwn", text: "• *Update*" },
{ type: "mrkdwn", text: "—" },
],
},
],
});
expect(finalizeSlackPreviewEditMock).toHaveBeenCalledTimes(1);
expect(deliverRepliesMock).not.toHaveBeenCalled();
});
it("mandatory E2E: streams native Slack progress with the newest meaningful plan title when no explicit label exists", async () => {
await dispatchNativeProgressScenario({
finalPayload: { text: FINAL_REPLY_TEXT },
events: [
{ kind: "item", progressText: "tool one" },
{ kind: "item", progressText: "tool two" },
{ kind: "item", progressText: "tool three" },
],
});
expect(createSlackDraftStreamMock).not.toHaveBeenCalled();
expectNativeProgressStart([
planUpdate("tool one"),
taskUpdate("item_1", "tool one", "in_progress"),
]);
expectNativeProgressAppend(0, [
planUpdate("tool two"),
taskUpdate("item_1", "tool one", "in_progress"),
taskUpdate("item_2", "tool two", "in_progress"),
]);
expectNativeProgressAppend(2, [
planUpdate("tool three"),
taskUpdate("item_1", "tool one", "complete"),
taskUpdate("item_2", "tool two", "complete"),
taskUpdate("item_3", "tool three", "complete"),
]);
expect(stopSlackStreamMock).toHaveBeenCalledTimes(1);
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
expectDeliverReplyCall(0, FINAL_REPLY_TEXT);
});
it("starts native Slack progress on a single tool item before final text and completes it once", async () => {
await dispatchNativeProgressScenario({
finalPayload: { text: FINAL_REPLY_TEXT },
events: [{ kind: "item", progressText: "slow tool" }],
});
expect(createSlackDraftStreamMock).not.toHaveBeenCalled();
expectNativeProgressStart([
planUpdate("slow tool"),
taskUpdate("item_1", "slow tool", "in_progress"),
]);
expectNativeProgressAppend(0, [
planUpdate("slow tool"),
taskUpdate("item_1", "slow tool", "complete"),
]);
expect(startSlackStreamMock.mock.invocationCallOrder[0]).toBeLessThan(
appendSlackStreamMock.mock.invocationCallOrder[0] ?? 0,
);
expect(stopSlackStreamMock).toHaveBeenCalledTimes(1);
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
expectDeliverReplyCall(0, FINAL_REPLY_TEXT);
});
it("does not start a text stream for native progress mode when no progress card exists", async () => {
await dispatchNativeProgressScenario({
finalPayload: { text: FINAL_REPLY_TEXT },
events: [],
});
expect(startSlackStreamMock).not.toHaveBeenCalled();
expect(appendSlackStreamMock).not.toHaveBeenCalled();
expect(stopSlackStreamMock).not.toHaveBeenCalled();
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
expectDeliverReplyCall(0, FINAL_REPLY_TEXT);
});
it("starts native Slack progress from the first running tool callback before final text", async () => {
const taskId = expect.stringMatching(/^exec_call_1_[a-f0-9]{8}$/);
await dispatchNativeProgressScenario({
finalPayload: { text: FINAL_REPLY_TEXT },
events: [
{
kind: "tool_start",
itemId: "exec-call-1",
toolCallId: "tool-call-1",
name: "bash",
phase: "start",
},
],
});
expect(createSlackDraftStreamMock).not.toHaveBeenCalled();
expectNativeProgressStart([planUpdate("bash"), taskUpdate(taskId, "bash", "in_progress")]);
expectNativeProgressAppend(0, [planUpdate("bash"), taskUpdate(taskId, "bash", "complete")]);
expect(startSlackStreamMock.mock.invocationCallOrder[0]).toBeLessThan(
appendSlackStreamMock.mock.invocationCallOrder[0] ?? 0,
);
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
expectDeliverReplyCall(0, FINAL_REPLY_TEXT);
});
it("keeps final fallback in the planned thread when native Slack progress start fails", async () => {
startSlackStreamMock.mockRejectedValueOnce(new Error("start stream failed"));
mockedReplyThreadTsSequence = [THREAD_TS, undefined];
await dispatchNativeProgressScenario({
replyToMode: "first",
finalPayload: { text: FINAL_REPLY_TEXT },
events: [{ kind: "item", progressText: "slow tool" }],
});
expect(startSlackStreamMock).toHaveBeenCalledTimes(1);
expect(appendSlackStreamMock).not.toHaveBeenCalled();
expect(stopSlackStreamMock).not.toHaveBeenCalled();
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
expectDeliverReplyCall(0, FINAL_REPLY_TEXT);
});
it("marks native Slack progress tasks as error when final text is an error", async () => {
await dispatchNativeProgressScenario({
finalPayload: { text: "tool failed", isError: true },
events: [{ kind: "item", progressText: "failing tool" }],
});
expectNativeProgressStart([
planUpdate("failing tool"),
taskUpdate("item_1", "failing tool", "in_progress"),
]);
expectNativeProgressAppend(0, [
planUpdate("failing tool"),
taskUpdate("item_1", "failing tool", "error"),
]);
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
const deliverParams = requireRecord(
requireMockCall(deliverRepliesMock, 0, "deliver replies")[0],
"deliver replies params",
);
expectRecordFields(deliverParams, { replyThreadTs: THREAD_TS });
expect(deliverParams.replies).toEqual([{ text: "tool failed", isError: true }]);
});
it("completes a native Slack progress plan even when no final text is sent", async () => {
await dispatchNativeProgressScenario({
events: [{ kind: "concurrent_items", progressTexts: ["tool one", "tool two", "tool three"] }],
});
expectNativeProgressStart([
planUpdate("tool three"),
taskUpdate("item_1", "tool one", "in_progress"),
taskUpdate("item_2", "tool two", "in_progress"),
taskUpdate("item_3", "tool three", "in_progress"),
]);
expect(appendSlackStreamMock).not.toHaveBeenCalled();
expect(deliverRepliesMock).not.toHaveBeenCalled();
expectMockCallArgFields(stopSlackStreamMock, 0, "native progress stream stop", {
chunks: [
planUpdate("tool three"),
taskUpdate("item_1", "tool one", "complete"),
taskUpdate("item_2", "tool two", "complete"),
taskUpdate("item_3", "tool three", "complete"),
],
});
});
it("mandatory E2E: preserves an explicit configured native Slack progress plan title", async () => {
await dispatchNativeProgressScenario({
finalPayload: { text: FINAL_REPLY_TEXT },
progress: { label: "Shelling", nativeTaskCards: true, render: "rich" },
events: [
{ kind: "item", progressText: "tool one" },
{ kind: "item", progressText: "tool two" },
{ kind: "item", progressText: "tool three" },
],
});
expect(createSlackDraftStreamMock).not.toHaveBeenCalled();
expectNativeProgressStart([
planUpdate("Shelling"),
taskUpdate("item_1", "tool one", "in_progress"),
]);
expectNativeProgressAppend(2, [
planUpdate("Shelling"),
taskUpdate("item_1", "tool one", "complete"),
taskUpdate("item_2", "tool two", "complete"),
taskUpdate("item_3", "tool three", "complete"),
]);
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
expectDeliverReplyCall(0, FINAL_REPLY_TEXT);
});
it("passes configured native progress max line chars into stream chunks", async () => {
const taskId = expect.stringMatching(/^exec_call_1_[a-f0-9]{8}$/);
await dispatchNativeProgressScenario({
finalPayload: { text: FINAL_REPLY_TEXT },
progress: { label: "Shelling", maxLineChars: 12, nativeTaskCards: true, render: "rich" },
events: [
{
kind: "tool_start",
itemId: "exec-call-1",
toolCallId: "tool-call-1",
name: "bash",
phase: "start",
args: { command: "1234567890abcdefghijklmnopqrstuvwxyz" },
},
],
});
expectNativeProgressStart([
planUpdate("Shelling"),
taskUpdate(taskId, "bash — 12345…uvwxyz", "in_progress"),
]);
expectNativeProgressAppend(0, [
planUpdate("Shelling"),
taskUpdate(taskId, "bash — 12345…uvwxyz", "complete"),
]);
});
it("preserves patch item identity in native Slack progress task updates", async () => {
const taskId = expect.stringMatching(/^patch_item_1_[a-f0-9]{8}$/);
await dispatchNativeProgressScenario({
finalPayload: { text: FINAL_REPLY_TEXT },
events: [
{
kind: "patch",
itemId: "patch:item-1",
toolCallId: "patch-call-1",
name: "apply_patch",
phase: "end",
summary: "updated Slack progress tests",
},
],
});
expectNativeProgressStart([
planUpdate("updated Slack progress tests"),
taskUpdate(taskId, "updated Slack progress tests", "in_progress"),
]);
expectNativeProgressAppend(0, [
planUpdate("updated Slack progress tests"),
taskUpdate(taskId, "updated Slack progress tests", "complete"),
]);
});
it("preserves the last rich Slack progress lines after a draft boundary status update", async () => {
const draftStream = {
...createDraftStreamStub(),
flush: vi.fn(noopAsync),
clear: vi.fn(noopAsync),
discardPending: vi.fn(noopAsync),
seal: vi.fn(noopAsync),
};
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
finalizeSlackPreviewEditMock.mockResolvedValueOnce(undefined);
mockedSlackStreamingMode = "progress";
mockedSlackDraftMode = "status_final";
mockedDispatchSequence = [{ kind: "final", payload: { text: FINAL_REPLY_TEXT } }];
mockedReplyOptionEvents = [
{ kind: "item", progressText: "tool one" },
{ kind: "item", progressText: "tool two" },
{ kind: "assistant_start" },
{ kind: "partial", text: "partial answer" },
];
await dispatchPreparedSlackMessage(
createPreparedSlackMessage({
accountConfig: { streaming: { progress: { label: "Shelling", render: "rich" } } },
}),
);
expect(draftStream.forceNewMessage).toHaveBeenCalledTimes(1);
expect(draftStream.update).toHaveBeenLastCalledWith({
text: ["Shelling", "• tool one", "• tool two"].join("\n"),
blocks: [
{
type: "section",
text: { type: "mrkdwn", text: "*Shelling*" },
},
{
type: "section",
fields: [
{ type: "mrkdwn", text: "• *Update*" },
{ type: "mrkdwn", text: "—" },
],
},
{
type: "section",
fields: [
{ type: "mrkdwn", text: "• *Update*" },
{ type: "mrkdwn", text: "—" },
],
},
],
});
expect(finalizeSlackPreviewEditMock).toHaveBeenCalledTimes(1);
expect(deliverRepliesMock).not.toHaveBeenCalled();
});
it("can hide raw Slack command progress text by config", async () => {
const draftStream = createDraftStreamStub();
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
@@ -1483,6 +2141,54 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
expect(draftStream.update).not.toHaveBeenCalled();
});
it("preserves hidden-title rich Slack progress drafts when the label is hidden", async () => {
const draftStream = {
...createDraftStreamStub(),
flush: vi.fn(noopAsync),
clear: vi.fn(noopAsync),
discardPending: vi.fn(noopAsync),
seal: vi.fn(noopAsync),
};
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
finalizeSlackPreviewEditMock.mockResolvedValueOnce(undefined);
mockedSlackStreamingMode = "progress";
mockedSlackDraftMode = "status_final";
mockedDispatchSequence = [{ kind: "final", payload: { text: FINAL_REPLY_TEXT } }];
mockedReplyOptionEvents = [
{ kind: "item", progressText: "tool one" },
{ kind: "partial", text: "partial answer" },
{ kind: "item", progressText: "tool two" },
];
await dispatchPreparedSlackMessage(
createPreparedSlackMessage({
accountConfig: { streaming: { progress: { label: false, render: "rich" } } },
}),
);
expect(draftStream.update).toHaveBeenLastCalledWith({
text: ["• tool one", "• tool two"].join("\n"),
blocks: [
{
type: "section",
fields: [
{ type: "mrkdwn", text: "• *Update*" },
{ type: "mrkdwn", text: "—" },
],
},
{
type: "section",
fields: [
{ type: "mrkdwn", text: "• *Update*" },
{ type: "mrkdwn", text: "—" },
],
},
],
});
expect(finalizeSlackPreviewEditMock).toHaveBeenCalledTimes(1);
expect(deliverRepliesMock).not.toHaveBeenCalled();
});
it("suppresses standalone Slack tool progress when partial preview lines are disabled", async () => {
mockedSlackStreamingMode = "partial";
mockedSlackDraftMode = "replace";
@@ -19,10 +19,18 @@ describe("slack native streaming defaults", () => {
expect(isSlackStreamingEnabled({ mode: "partial", nativeStreaming: true })).toBe(true);
});
it("is disabled outside partial mode or when native streaming is off", () => {
it("keeps native progress task cards opt-in while preserving partial native streaming", () => {
expect(isSlackStreamingEnabled({ mode: "partial", nativeStreaming: false })).toBe(false);
expect(isSlackStreamingEnabled({ mode: "block", nativeStreaming: true })).toBe(false);
expect(isSlackStreamingEnabled({ mode: "progress", nativeStreaming: true })).toBe(false);
expect(
isSlackStreamingEnabled({
mode: "progress",
nativeStreaming: true,
nativeProgressTaskCards: true,
}),
).toBe(true);
expect(isSlackStreamingEnabled({ mode: "progress", nativeStreaming: false })).toBe(false);
expect(isSlackStreamingEnabled({ mode: "off", nativeStreaming: true })).toBe(false);
});
});
@@ -29,9 +29,9 @@ import {
formatChannelProgressDraftText,
isChannelProgressDraftWorkToolName,
mergeChannelProgressDraftLine,
resolveChannelProgressDraftConfig,
resolveChannelProgressDraftMaxLines,
resolveChannelProgressDraftMaxLineChars,
resolveChannelProgressDraftLabel,
resolveChannelProgressDraftRender,
resolveChannelStreamingBlockEnabled,
resolveChannelStreamingNativeTransport,
@@ -60,7 +60,12 @@ import {
isSlackInteractiveRepliesEnabled,
} from "../../interactive-replies.js";
import { SLACK_TEXT_LIMIT } from "../../limits.js";
import { buildSlackProgressDraftBlocks } from "../../progress-blocks.js";
import {
buildSlackProgressDraftBlocks,
buildSlackProgressStreamCompletionChunks,
buildSlackProgressStreamStartChunks,
buildSlackProgressStreamUpdateChunks,
} from "../../progress-blocks.js";
import { recordSlackThreadParticipation } from "../../sent-thread-cache.js";
import { applyAppendOnlyStreamUpdate, resolveSlackStreamingConfig } from "../../stream-mode.js";
import type { SlackStreamSession } from "../../streaming.js";
@@ -167,11 +172,15 @@ function toSlackEmojiName(emoji: string): string {
export function isSlackStreamingEnabled(params: {
mode: "off" | "partial" | "block" | "progress";
nativeStreaming: boolean;
nativeProgressTaskCards?: boolean;
}): boolean {
if (params.mode !== "partial") {
return false;
if (params.mode === "partial") {
return params.nativeStreaming;
}
return params.nativeStreaming;
if (params.mode === "progress") {
return params.nativeStreaming && params.nativeProgressTaskCards === true;
}
return false;
}
export function shouldEnableSlackPreviewStreaming(params: {
@@ -200,6 +209,33 @@ export function resolveSlackDisableBlockStreaming(params: {
: undefined;
}
function resolveExplicitSlackProgressTitle(
entry: Parameters<typeof resolveChannelProgressDraftConfig>[0],
): string | undefined {
const label = resolveChannelProgressDraftConfig(entry).label;
if (typeof label !== "string") {
return undefined;
}
const trimmed = label.trim();
return trimmed && trimmed.toLowerCase() !== "auto" ? trimmed : undefined;
}
function resolveSlackNativeProgressTaskCards(
entry: Parameters<typeof resolveChannelProgressDraftConfig>[0],
): boolean {
const streaming = entry?.streaming;
if (!streaming || typeof streaming !== "object" || Array.isArray(streaming)) {
return false;
}
const progressConfig = (streaming as Record<string, unknown>).progress;
return (
Boolean(progressConfig) &&
typeof progressConfig === "object" &&
!Array.isArray(progressConfig) &&
(progressConfig as { nativeTaskCards?: unknown }).nativeTaskCards === true
);
}
export function resolveSlackStreamingThreadHint(params: {
replyToMode: "off" | "first" | "all" | "batched";
incomingThreadTs: string | undefined;
@@ -569,6 +605,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
isSlackStreamingEnabled({
mode: slackStreaming.mode,
nativeStreaming: slackStreaming.nativeStreaming,
nativeProgressTaskCards: resolveSlackNativeProgressTaskCards(account.config),
});
const useStreaming = shouldUseStreaming({
streamingEnabled,
@@ -587,6 +624,8 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
blockStreamingEnabled,
});
let streamSession: SlackStreamSession | null = null;
let nativeProgressStreamStartPromise: Promise<SlackStreamSession | null> | null = null;
let nativeProgressStreamThreadTs: string | undefined;
let streamFailed = false;
let usedReplyThreadTs: string | undefined;
let usedBlockReplyThreadTs: string | undefined;
@@ -754,7 +793,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
await deliverNormally({
payload: params.payload,
kind: params.kind,
forcedThreadTs: streamSession?.threadTs,
forcedThreadTs: streamSession?.threadTs ?? nativeProgressStreamThreadTs,
});
return;
}
@@ -762,6 +801,24 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
const text = reply.trimmedText;
let plannedThreadTs: string | undefined;
try {
if (!streamSession && nativeProgressStreamStartPromise) {
await nativeProgressStreamStartPromise;
}
if (streamFailed) {
await deliverNormally({
payload: params.payload,
kind: params.kind,
forcedThreadTs: streamSession?.threadTs ?? nativeProgressStreamThreadTs,
});
return;
}
if (useNativeProgressStreaming && !streamSession) {
await deliverNormally({
payload: params.payload,
kind: params.kind,
});
return;
}
if (!streamSession) {
const streamThreadTs = replyPlan.nextThreadTs();
plannedThreadTs = streamThreadTs;
@@ -831,10 +888,43 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
return;
}
const completionChunks =
useNativeProgressStreaming &&
!nativeProgressCompletionSent &&
previewToolProgressLines.length > 0
? buildSlackProgressStreamCompletionChunks({
title: explicitProgressTitle,
lines: previewToolProgressLines,
maxLineChars: progressDraftMaxLineChars,
finalInProgressStatus: params.payload.isError ? "error" : "complete",
})
: undefined;
if (useNativeProgressStreaming) {
if (completionChunks?.length) {
await appendSlackStream({
session: streamSession,
chunks: completionChunks,
});
nativeProgressCompletionSent = true;
if (streamSession.delivered) {
observedReplyDelivery = true;
}
}
await deliverNormally({
payload: params.payload,
kind: params.kind,
forcedThreadTs: streamSession.threadTs,
});
return;
}
await appendSlackStream({
session: streamSession,
text: "\n" + text,
chunks: completionChunks,
});
if (completionChunks?.length) {
nativeProgressCompletionSent = true;
}
// appendSlackStream also buffers locally below the SDK threshold; avoid
// optimistic "done" status until Slack acknowledges a flush.
if (streamSession.delivered) {
@@ -1116,62 +1206,209 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
: undefined;
let hasStreamedMessage = false;
const streamMode = slackStreaming.draftMode;
const useNativeProgressStreaming = useStreaming && slackStreaming.mode === "progress";
const previewToolProgressEnabled =
Boolean(draftStream) && resolveChannelStreamingPreviewToolProgress(account.config);
(Boolean(draftStream) || useNativeProgressStreaming) &&
resolveChannelStreamingPreviewToolProgress(account.config);
const suppressDefaultToolProgressMessages =
resolveChannelStreamingSuppressDefaultToolProgressMessages(account.config, {
draftStreamActive: Boolean(draftStream),
draftStreamActive: Boolean(draftStream) || useNativeProgressStreaming,
previewToolProgressEnabled,
previewStreamingEnabled,
});
let previewToolProgressSuppressed = false;
let previewToolProgressLines: ChannelProgressDraftLine[] = [];
let lastNonEmptyPreviewToolProgressLines: ChannelProgressDraftLine[] = [];
let appendRenderedText = "";
let appendSourceText = "";
let statusUpdateCount = 0;
let nativeProgressCompletionSent = false;
let nativeProgressChunkKey: string | undefined;
const progressSeed = `${account.accountId}:${message.channel}`;
const useRichProgressDraft =
streamMode === "status_final" && resolveChannelProgressDraftRender(account.config) === "rich";
const explicitProgressTitle = resolveExplicitSlackProgressTitle(account.config);
const progressDraftMaxLineChars = resolveChannelProgressDraftMaxLineChars(account.config);
const renderProgressDraft = () => {
if (!draftStream || streamMode !== "status_final") {
return;
}
const progressLines =
useRichProgressDraft && previewToolProgressLines.length === 0
? lastNonEmptyPreviewToolProgressLines
: previewToolProgressLines;
const previewText = formatChannelProgressDraftText({
entry: account.config,
lines: previewToolProgressLines,
lines: progressLines,
seed: progressSeed,
formatLine: escapeSlackMrkdwn,
});
if (!previewText) {
return;
}
const richProgressBlocks = useRichProgressDraft
? buildSlackProgressDraftBlocks({
title: explicitProgressTitle,
lines: progressLines,
maxLineChars: resolveChannelProgressDraftMaxLineChars(account.config),
})
: undefined;
draftStream.update(
useRichProgressDraft
useRichProgressDraft && richProgressBlocks
? {
text: previewText,
blocks: buildSlackProgressDraftBlocks({
label: resolveChannelProgressDraftLabel({
entry: account.config,
seed: progressSeed,
}),
lines: previewToolProgressLines,
maxLineChars: resolveChannelProgressDraftMaxLineChars(account.config),
}),
blocks: richProgressBlocks,
}
: previewText,
);
hasStreamedMessage = true;
};
const waitForNativeProgressStreamStart = async (): Promise<boolean> => {
if (streamSession || !nativeProgressStreamStartPromise) {
return true;
}
try {
await nativeProgressStreamStartPromise;
} catch {
streamFailed = true;
return false;
}
return !streamFailed;
};
const buildNativeProgressChunks = () =>
streamSession
? buildSlackProgressStreamUpdateChunks({
title: explicitProgressTitle,
lines: previewToolProgressLines,
maxLineChars: progressDraftMaxLineChars,
})
: buildSlackProgressStreamStartChunks({
title: explicitProgressTitle,
lines: previewToolProgressLines,
maxLineChars: progressDraftMaxLineChars,
});
const markNativeProgressDelivered = (session: SlackStreamSession, threadTs?: string) => {
if (session.delivered) {
observedReplyDelivery = true;
}
if (threadTs) {
usedReplyThreadTs ??= threadTs;
rememberDeliveredThreadTs("block", threadTs);
}
};
const startNativeProgressStream = async (
chunks: NonNullable<ReturnType<typeof buildSlackProgressStreamStartChunks>>,
chunkKey: string,
) => {
const streamThreadTs = replyPlan.nextThreadTs();
if (!streamThreadTs) {
logVerbose(
"slack-stream: no reply thread target for native progress stream start, falling back",
);
streamFailed = true;
return;
}
nativeProgressStreamThreadTs = streamThreadTs;
const startPromise = (async () => {
const session = await startSlackStream({
client: ctx.app.client,
channel: message.channel,
threadTs: streamThreadTs,
chunks,
taskDisplayMode: "plan",
teamId: await resolveSlackStreamRecipientTeamId({
client: ctx.app.client,
token: ctx.botToken,
userId: message.user,
fallbackTeamId: ctx.teamId,
}),
userId: message.user,
});
streamSession = session;
return session;
})();
nativeProgressStreamStartPromise = startPromise;
let startedSession: SlackStreamSession | null = null;
try {
startedSession = await startPromise;
} finally {
if (nativeProgressStreamStartPromise === startPromise) {
nativeProgressStreamStartPromise = null;
}
}
if (startedSession) {
markNativeProgressDelivered(startedSession, streamThreadTs);
}
nativeProgressChunkKey = chunkKey;
replyPlan.markSent();
};
const appendNativeProgressStream = async (
chunks: NonNullable<ReturnType<typeof buildSlackProgressStreamUpdateChunks>>,
chunkKey: string,
) => {
if (!streamSession) {
return;
}
await appendSlackStream({ session: streamSession, chunks });
markNativeProgressDelivered(streamSession);
nativeProgressChunkKey = chunkKey;
};
const updateNativeProgressStream = async () => {
if (!useNativeProgressStreaming || streamFailed || previewToolProgressLines.length === 0) {
return;
}
const canContinue = await waitForNativeProgressStreamStart();
if (!canContinue) {
return;
}
const chunks = buildNativeProgressChunks();
if (!chunks?.length) {
return;
}
const chunkKey = JSON.stringify(chunks);
if (chunkKey === nativeProgressChunkKey) {
return;
}
try {
if (!streamSession) {
await startNativeProgressStream(chunks, chunkKey);
return;
}
await appendNativeProgressStream(chunks, chunkKey);
} catch (err) {
runtime.error?.(
danger(
`slack-stream: native progress stream failed: ${formatSlackError(err)}, falling back`,
),
);
streamFailed = true;
}
};
const progressDraftGate = createChannelProgressDraftGate({
onStart: renderProgressDraft,
onStart: useNativeProgressStreaming ? updateNativeProgressStream : renderProgressDraft,
});
const refreshStartedProgressDraft = async () => {
if (useNativeProgressStreaming) {
await updateNativeProgressStream();
} else {
renderProgressDraft();
}
};
const pushPreviewToolProgress = async (
line?: ChannelProgressDraftLine,
options?: { toolName?: string },
) => {
if (!draftStream) {
if (!draftStream && !useNativeProgressStreaming) {
return;
}
if (options?.toolName !== undefined && !isChannelProgressDraftWorkToolName(options.toolName)) {
@@ -1185,7 +1422,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
const alreadyStarted = progressDraftGate.hasStarted;
await progressDraftGate.noteWork();
if (alreadyStarted && progressDraftGate.hasStarted) {
renderProgressDraft();
await refreshStartedProgressDraft();
}
return;
}
@@ -1200,7 +1437,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
return;
}
previewToolProgressLines = nextLines;
draftStream.update(
draftStream?.update(
formatChannelProgressDraftText({
entry: account.config,
lines: previewToolProgressLines,
@@ -1215,11 +1452,22 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
previewToolProgressLines = mergeChannelProgressDraftLine(previewToolProgressLines, line, {
maxLines: resolveChannelProgressDraftMaxLines(account.config),
});
if (previewToolProgressLines.length > 0) {
lastNonEmptyPreviewToolProgressLines = previewToolProgressLines;
}
}
if (useNativeProgressStreaming) {
if (progressDraftGate.hasStarted) {
await updateNativeProgressStream();
} else {
await progressDraftGate.startNow();
}
return;
}
const alreadyStarted = progressDraftGate.hasStarted;
await progressDraftGate.noteWork();
if (alreadyStarted && progressDraftGate.hasStarted) {
renderProgressDraft();
await refreshStartedProgressDraft();
}
};
@@ -1333,6 +1581,8 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
account.config,
{
event: "tool",
itemId: payload.itemId,
toolCallId: payload.toolCallId,
name: payload.name,
phase: payload.phase,
args: payload.args,
@@ -1394,6 +1644,8 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
await pushPreviewToolProgress(
buildChannelProgressDraftLine({
event: "command-output",
itemId: payload.itemId,
toolCallId: payload.toolCallId,
phase: payload.phase,
title: payload.title,
name: payload.name,
@@ -1409,6 +1661,8 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
await pushPreviewToolProgress(
buildChannelProgressDraftLine({
event: "patch",
itemId: payload.itemId,
toolCallId: payload.toolCallId,
phase: payload.phase,
title: payload.title,
name: payload.name,
@@ -1440,8 +1694,23 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
const finalStream = streamSession as SlackStreamSession | null;
if (finalStream && !finalStream.stopped) {
try {
const completionChunks =
useNativeProgressStreaming &&
!nativeProgressCompletionSent &&
previewToolProgressLines.length > 0
? buildSlackProgressStreamCompletionChunks({
title: explicitProgressTitle,
lines: previewToolProgressLines,
maxLineChars: progressDraftMaxLineChars,
finalInProgressStatus: dispatchError ? "error" : "complete",
})
: undefined;
if (completionChunks?.length) {
nativeProgressCompletionSent = true;
}
await stopSlackStream({
session: finalStream,
...(completionChunks?.length ? { chunks: completionChunks } : {}),
...(slackMessageMetadata ? { metadata: slackMessageMetadata } : {}),
});
} catch (err) {
+382 -43
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { buildSlackProgressDraftBlocks } from "./progress-blocks.js";
import {
buildSlackProgressDraftBlocks,
buildSlackProgressStreamCompletionChunks,
buildSlackProgressStreamStartChunks,
buildSlackProgressStreamUpdateChunks,
} from "./progress-blocks.js";
function progressLine(index: number) {
return {
@@ -11,49 +16,81 @@ function progressLine(index: number) {
};
}
function expectProgressBlock(block: unknown, label: string, detail: string) {
expect(block).toEqual({
function itemLine(text: string, label = text) {
return { kind: "item" as const, label, text };
}
function toolLine(detail: string, label = "Exec") {
return {
kind: "tool" as const,
icon: "🛠️",
label,
detail,
text: `🛠️ ${label}: ${detail}`,
toolName: label.toLowerCase(),
};
}
function planUpdate(title: string) {
return { type: "plan_update", title };
}
function taskUpdate(id: unknown, title: string, status: "in_progress" | "complete" | "error") {
return { type: "task_update", id, title, status };
}
function legacyHeadingBlock(text: string) {
return {
type: "section",
text: { type: "mrkdwn", text },
};
}
function legacyLineBlock(title: string, detail: string) {
return {
type: "section",
fields: [
{ type: "mrkdwn", text: `🛠️ *${label}*` },
{ type: "mrkdwn", text: title },
{ type: "mrkdwn", text: detail },
],
};
}
function expectLegacyLineBlock(block: unknown, title: string, detail: string) {
expect(block).toEqual(legacyLineBlock(title, detail));
}
function expectTaskUpdate(task: unknown, fields: { id: string; title: string; status: string }) {
expect(task).toEqual({
type: "task_update",
id: fields.id,
title: fields.title,
status: fields.status,
});
}
describe("buildSlackProgressDraftBlocks", () => {
it("renders structured progress lines as compact Block Kit fields", () => {
it("keeps legacy rich draft rendering as section field blocks", () => {
expect(
buildSlackProgressDraftBlocks({
label: "Shelling...",
lines: [
{
kind: "tool",
icon: "🛠️",
label: "Exec",
detail: "run tests",
text: "🛠️ Exec: run tests",
toolName: "exec",
},
],
lines: [toolLine("run tests")],
}),
).toEqual([
{
type: "section",
text: { type: "mrkdwn", text: "*Shelling...*" },
},
{
type: "section",
fields: [
{ type: "mrkdwn", text: "🛠️ *Exec*" },
{ type: "mrkdwn", text: "run tests" },
],
},
]);
).toEqual([legacyHeadingBlock("*Shelling...*"), legacyLineBlock("🛠️ *Exec*", "run tests")]);
});
it("uses the configured max line chars for rich progress details", () => {
it("uses title as the legacy rich draft heading when label is absent", () => {
expect(
buildSlackProgressDraftBlocks({
title: "Shelling...",
lines: [toolLine("run tests")],
}),
).toEqual([legacyHeadingBlock("*Shelling...*"), legacyLineBlock("🛠️ *Exec*", "run tests")]);
});
it("uses configured max line chars for legacy rich draft details", () => {
const blocks = buildSlackProgressDraftBlocks({
title: "Shelling...",
maxLineChars: 64,
lines: [
{
@@ -66,32 +103,334 @@ describe("buildSlackProgressDraftBlocks", () => {
],
});
expect(blocks?.[0]).toEqual({
type: "section",
fields: [
{ type: "mrkdwn", text: "🛠️ *Exec*" },
expectLegacyLineBlock(
blocks?.[1],
"🛠️ *Exec*",
"run tests in /Users/example/P…aw/packages/very/deep/path/example",
);
});
it("keeps completed and failed statuses in legacy rich draft details", () => {
const blocks = buildSlackProgressDraftBlocks({
title: "Shelling...",
lines: [
{
type: "mrkdwn",
text: "run tests in /Users/example/P…aw/packages/very/deep/path/example",
kind: "command-output",
label: "Exec",
detail: "command finished",
status: "completed",
text: "🛠️ Exec: completed",
toolName: "exec",
},
{
kind: "command-output",
label: "Exec",
detail: "command failed",
status: "exit 1",
text: "🛠️ Exec: exit 1",
toolName: "exec",
},
],
});
expectLegacyLineBlock(blocks?.[1], "• *Exec*", "command finished · completed");
expectLegacyLineBlock(blocks?.[2], "• *Exec*", "command failed · exit 1");
});
it("keeps newest rich progress lines when capping Slack blocks", () => {
it("keeps newest rich progress lines when capping legacy draft blocks", () => {
const blocksWithLabel = buildSlackProgressDraftBlocks({
label: "Shelling...",
title: "Shelling...",
lines: Array.from({ length: 60 }, (_value, index) => progressLine(index)),
});
expect(blocksWithLabel).toHaveLength(50);
expectProgressBlock(blocksWithLabel?.[0], "Exec 10", "run 10");
expectProgressBlock(blocksWithLabel?.at(-1), "Exec 59", "run 59");
expectLegacyLineBlock(blocksWithLabel?.[0], "🛠️ *Exec 10*", "run 10");
expectLegacyLineBlock(blocksWithLabel?.at(-1), "🛠️ *Exec 59*", "run 59");
const blocksWithoutLabel = buildSlackProgressDraftBlocks({
const blocksWithoutTitle = buildSlackProgressDraftBlocks({
lines: Array.from({ length: 60 }, (_value, index) => progressLine(index)),
});
expect(blocksWithoutLabel).toHaveLength(50);
expectProgressBlock(blocksWithoutLabel?.[0], "Exec 10", "run 10");
expectProgressBlock(blocksWithoutLabel?.at(-1), "Exec 59", "run 59");
expect(blocksWithoutTitle).toHaveLength(50);
expectLegacyLineBlock(blocksWithoutTitle?.[0], "🛠️ *Exec 10*", "run 10");
expectLegacyLineBlock(blocksWithoutTitle?.at(-1), "🛠️ *Exec 59*", "run 59");
});
it("renders legacy rich draft lines without a heading when no label or title is provided", () => {
expect(
buildSlackProgressDraftBlocks({
lines: [toolLine("run tests")],
}),
).toEqual([legacyLineBlock("🛠️ *Exec*", "run tests")]);
});
it("uses a blank legacy rich draft detail when structured detail is absent", () => {
expect(
buildSlackProgressDraftBlocks({
lines: [itemLine("prepare the workspace", "Preamble"), toolLine("run tests")],
}),
).toEqual([legacyLineBlock("• *Preamble*", "—"), legacyLineBlock("🛠️ *Exec*", "run tests")]);
});
it("does not emit legacy rich draft blocks when there are no lines or heading", () => {
expect(
buildSlackProgressDraftBlocks({
lines: [],
}),
).toBeUndefined();
});
});
describe("native Slack progress stream chunks", () => {
it("starts native Slack progress with plan/task chunks instead of a static blocks plan", () => {
expect(
buildSlackProgressStreamStartChunks({
lines: [itemLine("tool one", "Tool one"), itemLine("tool two", "Tool two")],
}),
).toEqual([
planUpdate("tool two"),
taskUpdate("item_1", "tool one", "in_progress"),
taskUpdate("item_2", "tool two", "in_progress"),
]);
});
it("uses configured max line chars for native task details", () => {
expect(
buildSlackProgressStreamStartChunks({
title: "Shelling...",
maxLineChars: 64,
lines: [
{
kind: "tool",
icon: "🛠️",
label: "Exec",
detail: "run tests in /Users/example/Projects/openclaw/packages/very/deep/path/example",
text: "🛠️ Exec: run tests in /Users/example/Projects/openclaw/packages/very/deep/path/example",
},
],
}),
).toEqual([
planUpdate("Shelling..."),
taskUpdate(
"tool_1",
"Exec — run tests in /Users/example/P…aw/packages/very/deep/path/example",
"in_progress",
),
]);
});
it("maps completed and failed progress statuses onto native task states", () => {
expect(
buildSlackProgressStreamStartChunks({
title: "Shelling...",
lines: [
{
kind: "command-output",
label: "Exec",
detail: "command finished",
status: "completed",
text: "🛠️ Exec: completed",
toolName: "exec",
},
{
kind: "command-output",
label: "Exec",
detail: "command failed",
status: "exit 1",
text: "🛠️ Exec: exit 1",
toolName: "exec",
},
],
}),
).toEqual([
planUpdate("Shelling..."),
taskUpdate("exec_1", "Exec — command finished · completed", "complete"),
taskUpdate("exec_2", "Exec — command failed · exit 1", "error"),
]);
});
it("keeps newest native task chunks when capping progress lines", () => {
const chunksWithTitle = buildSlackProgressStreamStartChunks({
title: "Shelling...",
lines: Array.from({ length: 60 }, (_value, index) => progressLine(index)),
});
expect(chunksWithTitle).toHaveLength(51);
expect(chunksWithTitle?.[0]).toEqual(planUpdate("Shelling..."));
expectTaskUpdate(chunksWithTitle?.[1], {
id: "tool_1",
title: "Exec 10 — run 10",
status: "in_progress",
});
expectTaskUpdate(chunksWithTitle?.at(-1), {
id: "tool_50",
title: "Exec 59 — run 59",
status: "in_progress",
});
const chunksWithoutTitle = buildSlackProgressStreamStartChunks({
lines: Array.from({ length: 60 }, (_value, index) => progressLine(index)),
});
expect(chunksWithoutTitle).toHaveLength(51);
expect(chunksWithoutTitle?.[0]).toEqual(planUpdate("Exec 59 — run 59"));
expectTaskUpdate(chunksWithoutTitle?.[1], {
id: "tool_1",
title: "Exec 10 — run 10",
status: "in_progress",
});
expectTaskUpdate(chunksWithoutTitle?.at(-1), {
id: "tool_50",
title: "Exec 59 — run 59",
status: "in_progress",
});
});
it("uses the newest meaningful progress step as the native plan title when no title is provided", () => {
expect(
buildSlackProgressStreamStartChunks({
lines: [toolLine("run tests")],
}),
).toEqual([
planUpdate("Exec — run tests"),
taskUpdate("exec_1", "Exec — run tests", "in_progress"),
]);
});
it("caps explicit native plan titles to Slack chunk limits", () => {
const chunks = buildSlackProgressStreamStartChunks({
title: `Shelling ${"x".repeat(300)}`,
lines: [toolLine("run tests")],
});
const title =
chunks?.[0] && typeof chunks[0] === "object" && "title" in chunks[0]
? chunks[0].title
: undefined;
expect(title).toHaveLength(256);
expect(title?.endsWith("…")).toBe(true);
});
it("preserves visible text in native tasks without structured detail", () => {
expect(
buildSlackProgressStreamStartChunks({
lines: [itemLine("prepare the workspace", "Preamble"), toolLine("run tests")],
}),
).toEqual([
planUpdate("Exec — run tests"),
taskUpdate("item_1", "prepare the workspace", "in_progress"),
taskUpdate("exec_2", "Exec — run tests", "in_progress"),
]);
});
it("renders identical command progress lines as distinct native tasks when ids differ", () => {
expect(
buildSlackProgressStreamStartChunks({
title: "Shelling...",
lines: [
{
id: "cmd-1",
kind: "item",
icon: "🛠️",
label: "Exec",
text: "🛠️ Exec",
toolName: "exec",
},
{
id: "cmd-2",
kind: "item",
icon: "🛠️",
label: "Exec",
text: "🛠️ Exec",
toolName: "exec",
},
],
}),
).toEqual([
planUpdate("Shelling..."),
taskUpdate(expect.stringMatching(/^cmd_1_[a-f0-9]{8}$/u), "🛠 Exec", "in_progress"),
taskUpdate(expect.stringMatching(/^cmd_2_[a-f0-9]{8}$/u), "🛠 Exec", "in_progress"),
]);
});
it("keeps id-derived native task ids stable when completion changes visible status text", () => {
const running = buildSlackProgressStreamUpdateChunks({
title: "Shelling...",
lines: [
{
id: "call-2",
kind: "tool",
icon: "🛠️",
label: "Bash",
text: "🛠️ Bash",
toolName: "bash",
},
],
});
const completed = buildSlackProgressStreamUpdateChunks({
title: "Shelling...",
lines: [
{
id: "call-2",
kind: "command-output",
icon: "🛠️",
label: "Bash",
detail: "completed",
status: "completed",
text: "🛠️ completed",
toolName: "bash",
},
],
});
const runningTaskId =
running?.[1] && typeof running[1] === "object" && "id" in running[1]
? running[1].id
: undefined;
expect(running?.[1]).toMatchObject({ id: expect.stringMatching(/^call_2_[a-f0-9]{8}$/u) });
expect(completed?.[1]).toMatchObject({
id: runningTaskId,
status: "complete",
title: "Bash — completed",
});
});
it("does not emit native stream chunks when there are no tasks", () => {
expect(
buildSlackProgressStreamStartChunks({
title: "Shelling...",
lines: [],
}),
).toBeUndefined();
});
it("updates native Slack progress without creating duplicate plan blocks", () => {
expect(
buildSlackProgressStreamUpdateChunks({
title: "Shelling",
lines: [itemLine("tool one", "Tool one"), itemLine("tool two", "Tool two")],
}),
).toEqual([
planUpdate("Shelling"),
taskUpdate("item_1", "tool one", "in_progress"),
taskUpdate("item_2", "tool two", "in_progress"),
]);
});
it("marks unfinished native Slack progress tasks complete for finalization", () => {
expect(
buildSlackProgressStreamCompletionChunks({
lines: [
{ kind: "item", label: "Tool one", text: "tool one" },
{
kind: "command-output",
label: "Exec",
detail: "command failed",
status: "exit 1",
text: "Exec: exit 1",
},
],
}),
).toEqual([
planUpdate("Exec — command failed · exit 1"),
taskUpdate("item_1", "tool one", "complete"),
taskUpdate("command_output_2", "Exec — command failed · exit 1", "error"),
]);
});
});
+188 -17
View File
@@ -1,3 +1,5 @@
import { createHash } from "node:crypto";
import type { AnyChunk } from "@slack/types";
import type { Block, KnownBlock } from "@slack/web-api";
import type { ChannelProgressDraftLine } from "openclaw/plugin-sdk/channel-outbound";
import { SLACK_MAX_BLOCKS } from "./blocks-input.js";
@@ -6,6 +8,18 @@ import { truncateSlackText } from "./truncate.js";
const SLACK_PROGRESS_FIELD_MAX = 1800;
const DEFAULT_SLACK_PROGRESS_DETAIL_MAX_CHARS = 120;
const DEFAULT_SLACK_PROGRESS_TASK_DETAIL_MAX_CHARS = 48;
const SLACK_PROGRESS_CHUNK_TEXT_MAX = 256;
const SLACK_PROGRESS_TASK_TITLE_MAX = 120;
const SLACK_PROGRESS_PLAN_FALLBACK_TITLE = "Thinking";
type SlackPlanTaskStatus = "in_progress" | "complete" | "error";
type SlackPlanTask = {
id: string;
title: string;
status: SlackPlanTaskStatus;
};
function field(text: string) {
return {
@@ -14,8 +28,8 @@ function field(text: string) {
};
}
function lineTitle(line: ChannelProgressDraftLine): string {
return `${line.icon ?? "•"} *${escapeSlackMrkdwn(line.label)}*`;
function resolveMaxLineChars(value: number | undefined, fallback: number): number {
return value && value > 0 ? Math.floor(value) : fallback;
}
function compactDetail(value: string, maxChars: number): string {
@@ -35,26 +49,156 @@ function compactDetail(value: string, maxChars: number): string {
.trimStart()}`;
}
function lineDetail(line: ChannelProgressDraftLine, maxChars: number): string {
const parts = [
line.detail,
line.status && !line.detail?.includes(line.status) ? line.status : undefined,
]
function compactTitle(value: string): string {
return truncateSlackText(value.replace(/\s+/g, " ").trim(), SLACK_PROGRESS_TASK_TITLE_MAX);
}
function compactChunkText(value: string): string {
return truncateSlackText(value.replace(/\s+/g, " ").trim(), SLACK_PROGRESS_CHUNK_TEXT_MAX);
}
function lineDetailParts(line: ChannelProgressDraftLine): string[] {
return [line.detail, line.status && !line.detail?.includes(line.status) ? line.status : undefined]
.map((part) => part?.trim())
.filter((part): part is string => Boolean(part));
return parts.length ? escapeSlackMrkdwn(compactDetail(parts.join(" · "), maxChars)) : " ";
}
function legacyLineTitle(line: ChannelProgressDraftLine): string {
return `${line.icon ?? "•"} *${escapeSlackMrkdwn(line.label)}*`;
}
function legacyLineDetail(line: ChannelProgressDraftLine, maxChars: number): string {
const detail = lineDetailParts(line).join(" · ");
return detail ? escapeSlackMrkdwn(compactDetail(detail, maxChars)) : "—";
}
function lineTaskTitle(line: ChannelProgressDraftLine, maxLineChars: number): string {
const label = line.label.replace(/\s+/g, " ").trim() || line.toolName || line.kind || "Update";
const detail = lineDetailParts(line).join(" · ");
const fallback = line.text.replace(/\s+/g, " ").trim();
if (detail) {
return compactTitle(`${label}${compactDetail(detail, maxLineChars)}`);
}
if (fallback && fallback !== label) {
return compactTitle(fallback);
}
return compactTitle(label);
}
function lineTaskStatus(line: ChannelProgressDraftLine): SlackPlanTaskStatus {
const normalized = line.status?.replace(/\s+/g, " ").trim().toLowerCase();
if (!normalized) {
return "in_progress";
}
if (
normalized === "complete" ||
normalized === "completed" ||
normalized === "done" ||
normalized === "ok" ||
normalized === "success" ||
normalized === "succeeded" ||
normalized === "successful" ||
normalized === "exit 0"
) {
return "complete";
}
if (
normalized === "error" ||
normalized === "failed" ||
normalized === "failure" ||
normalized.startsWith("exit ")
) {
return normalized === "exit 0" ? "complete" : "error";
}
return "in_progress";
}
function slugTaskIdPart(value: string | undefined): string {
const normalized = value
?.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
return normalized || "task";
}
function stableTaskIdPart(value: string): string {
const suffix = createHash("sha256").update(value).digest("hex").slice(0, 8);
return `${slugTaskIdPart(value)}_${suffix}`;
}
function buildPlanTasks(params: {
lines: readonly ChannelProgressDraftLine[];
maxLineChars?: number;
}): SlackPlanTask[] {
const maxLineChars = resolveMaxLineChars(
params.maxLineChars,
DEFAULT_SLACK_PROGRESS_TASK_DETAIL_MAX_CHARS,
);
return params.lines.slice(-SLACK_MAX_BLOCKS).map((line, index) => ({
id: line.id
? stableTaskIdPart(line.id)
: `${slugTaskIdPart(line.toolName ?? line.kind ?? line.label)}_${index + 1}`,
title: lineTaskTitle(line, maxLineChars),
status: lineTaskStatus(line),
}));
}
function resolvePlanTitle(params: {
label?: string;
title?: string;
tasks: readonly SlackPlanTask[];
}): string {
return compactChunkText(
params.title?.trim() ||
params.label?.trim() ||
params.tasks.at(-1)?.title ||
SLACK_PROGRESS_PLAN_FALLBACK_TITLE,
);
}
function buildSlackProgressStreamChunks(params: {
label?: string;
title?: string;
lines: readonly ChannelProgressDraftLine[];
maxLineChars?: number;
completeInProgress?: boolean;
finalInProgressStatus?: SlackPlanTaskStatus;
}): AnyChunk[] | undefined {
const tasks = buildPlanTasks({ lines: params.lines, maxLineChars: params.maxLineChars });
if (tasks.length === 0) {
return undefined;
}
const title = resolvePlanTitle({ label: params.label, title: params.title, tasks });
const chunks: AnyChunk[] = [
{
type: "plan_update",
title,
},
...tasks.map((task) => ({
type: "task_update" as const,
id: task.id,
title: task.title,
status:
task.status === "in_progress"
? (params.finalInProgressStatus ?? (params.completeInProgress ? "complete" : task.status))
: task.status,
})),
];
return chunks;
}
export function buildSlackProgressDraftBlocks(params: {
label?: string;
title?: string;
lines: readonly ChannelProgressDraftLine[];
maxLineChars?: number;
}): (Block | KnownBlock)[] | undefined {
const label = params.label?.trim();
const maxLineChars =
params.maxLineChars && params.maxLineChars > 0
? Math.floor(params.maxLineChars)
: DEFAULT_SLACK_PROGRESS_DETAIL_MAX_CHARS;
const label = params.label?.trim() || params.title?.trim();
const maxLineChars = resolveMaxLineChars(
params.maxLineChars,
DEFAULT_SLACK_PROGRESS_DETAIL_MAX_CHARS,
);
const renderedBlocks: (Block | KnownBlock)[] = [
...(label
? [
@@ -65,10 +209,37 @@ export function buildSlackProgressDraftBlocks(params: {
]
: []),
...params.lines.map((line) => ({
type: "section",
fields: [field(lineTitle(line)), field(lineDetail(line, maxLineChars))],
type: "section" as const,
fields: [field(legacyLineTitle(line)), field(legacyLineDetail(line, maxLineChars))],
})),
].slice(-SLACK_MAX_BLOCKS);
const blocks: (Block | KnownBlock)[] = renderedBlocks;
return blocks.length ? blocks : undefined;
return renderedBlocks.length ? renderedBlocks : undefined;
}
export function buildSlackProgressStreamStartChunks(params: {
label?: string;
title?: string;
lines: readonly ChannelProgressDraftLine[];
maxLineChars?: number;
}): AnyChunk[] | undefined {
return buildSlackProgressStreamChunks(params);
}
export function buildSlackProgressStreamUpdateChunks(params: {
label?: string;
title?: string;
lines: readonly ChannelProgressDraftLine[];
maxLineChars?: number;
}): AnyChunk[] | undefined {
return buildSlackProgressStreamChunks(params);
}
export function buildSlackProgressStreamCompletionChunks(params: {
label?: string;
title?: string;
lines: readonly ChannelProgressDraftLine[];
maxLineChars?: number;
finalInProgressStatus?: SlackPlanTaskStatus;
}): AnyChunk[] | undefined {
return buildSlackProgressStreamChunks({ ...params, completeInProgress: true });
}
+48
View File
@@ -35,6 +35,54 @@ function slackApiError(code: string): Error {
}
describe("stopSlackStream finalize error handling", () => {
it("starts and appends supported structured stream chunks without buffering markdown text", async () => {
const append = vi.fn(async () => ({ ts: "1700000000.100205" }));
const client = {
chatStream: vi.fn(() => ({
append,
stop: vi.fn(async () => {}),
})),
};
const chunks = [{ type: "plan_update" as const, title: "Inspecting" }];
const session = await startSlackStream({
client: client as never,
channel: "C123",
threadTs: "1700000000.000100",
chunks,
taskDisplayMode: "plan",
});
expect(client.chatStream).toHaveBeenCalledWith({
channel: "C123",
thread_ts: "1700000000.000100",
task_display_mode: "plan",
});
expect(append).toHaveBeenCalledWith({ chunks });
expect(session.delivered).toBe(true);
expect(session.pendingText).toBe("");
});
it("appends supported task update chunks to an active stream", async () => {
const session = makeSession({
appendImpl: async () => ({ ts: "1700000000.100206" }),
});
const chunks = [
{
type: "task_update" as const,
id: "item_1",
title: "Run tests",
status: "in_progress" as const,
},
];
await appendSlackStream({ session, chunks });
expect(session.streamer.append).toHaveBeenCalledWith({ chunks });
expect(session.delivered).toBe(true);
expect(session.pendingText).toBe("");
});
it("swallows user_not_found after prior append flushed (delivered=true)", async () => {
const session = makeSession({
appendImpl: async () => ({ ts: "1700000000.100200" }), // non-null => flushed
+50 -20
View File
@@ -11,7 +11,7 @@
* @see https://docs.slack.dev/reference/methods/chat.stopStream
*/
import type { MessageMetadata } from "@slack/types";
import type { AnyChunk, MessageMetadata } from "@slack/types";
import type { WebClient } from "@slack/web-api";
import type { ChatStreamer } from "@slack/web-api/dist/chat-stream.js";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
@@ -46,6 +46,10 @@ type StartSlackStreamParams = {
threadTs: string;
/** Optional initial markdown text to include in the stream start. */
text?: string;
/** Optional structured Slack stream chunks to include in the stream start. */
chunks?: AnyChunk[];
/** Native Slack task display mode for task_update chunks. */
taskDisplayMode?: "plan" | "timeline";
/**
* The team ID of the workspace this stream belongs to.
* Required by the Slack API for `chat.startStream` / `chat.stopStream`.
@@ -62,13 +66,16 @@ type StartSlackStreamParams = {
type AppendSlackStreamParams = {
session: SlackStreamSession;
text: string;
text?: string;
chunks?: AnyChunk[];
};
type StopSlackStreamParams = {
session: SlackStreamSession;
/** Optional final markdown text to append before stopping. */
text?: string;
/** Optional final stream chunks to append before stopping. */
chunks?: AnyChunk[];
metadata?: MessageMetadata;
};
@@ -107,7 +114,7 @@ export class SlackStreamNotDeliveredError extends Error {
export async function startSlackStream(
params: StartSlackStreamParams,
): Promise<SlackStreamSession> {
const { client, channel, threadTs, text, teamId, userId } = params;
const { client, channel, threadTs, text, chunks, taskDisplayMode, teamId, userId } = params;
logVerbose(
`slack-stream: starting stream in ${channel} thread=${threadTs}${teamId ? ` team=${teamId}` : ""}${userId ? ` user=${userId}` : ""}`,
@@ -116,6 +123,7 @@ export async function startSlackStream(
const streamer = client.chatStream({
channel,
thread_ts: threadTs,
...(taskDisplayMode ? { task_display_mode: taskDisplayMode } : {}),
...(teamId ? { recipient_team_id: teamId } : {}),
...(userId ? { recipient_user_id: userId } : {}),
});
@@ -129,19 +137,27 @@ export async function startSlackStream(
pendingText: "",
};
if (text) {
session.pendingText += text;
if (text || chunks?.length) {
if (text) {
session.pendingText += text;
}
// Slack SDK ChatStreamer keeps short markdown_text chunks in a local buffer
// and returns null until buffer_size is reached. Only a non-null response
// means Slack acknowledged startStream/appendStream.
// and returns null until buffer_size is reached. Structured chunks force a
// flush. Only a non-null response means Slack acknowledged
// startStream/appendStream.
try {
const result = await streamer.append({ markdown_text: text });
const result = await streamer.append({
...(text ? { markdown_text: text } : {}),
...(chunks?.length ? { chunks } : {}),
});
if (result) {
session.delivered = true;
session.pendingText = "";
}
logVerbose(
`slack-stream: appended initial text (${text.length} chars, ${result ? "flushed" : "buffered"})`,
`slack-stream: appended initial payload (${text?.length ?? 0} chars, ${
chunks?.length ?? 0
} chunks, ${result ? "flushed" : "buffered"})`,
);
} catch (err) {
if (isBenignSlackFinalizeError(err) && session.pendingText) {
@@ -161,27 +177,36 @@ export async function startSlackStream(
* Append markdown text to an active Slack stream.
*/
export async function appendSlackStream(params: AppendSlackStreamParams): Promise<void> {
const { session, text } = params;
const { session, text, chunks } = params;
if (session.stopped) {
logVerbose("slack-stream: attempted to append to a stopped stream, ignoring");
return;
}
if (!text) {
if (!text && !chunks?.length) {
return;
}
session.pendingText += text;
if (text) {
session.pendingText += text;
}
try {
// Same SDK contract as startSlackStream: null means local-only buffer,
// non-null means Slack accepted the pending buffer and it is visible.
const result = await session.streamer.append({ markdown_text: text });
// non-null means Slack accepted the pending buffer/chunks and it is visible.
const result = await session.streamer.append({
...(text ? { markdown_text: text } : {}),
...(chunks?.length ? { chunks } : {}),
});
if (result) {
session.delivered = true;
session.pendingText = "";
}
logVerbose(`slack-stream: appended ${text.length} chars (${result ? "flushed" : "buffered"})`);
logVerbose(
`slack-stream: appended ${text?.length ?? 0} chars, ${chunks?.length ?? 0} chunks (${
result ? "flushed" : "buffered"
})`,
);
} catch (err) {
if (isBenignSlackFinalizeError(err) && session.pendingText) {
throw new SlackStreamNotDeliveredError(
@@ -212,7 +237,7 @@ export async function appendSlackStream(params: AppendSlackStreamParams): Promis
* All other errors propagate unchanged.
*/
export async function stopSlackStream(params: StopSlackStreamParams): Promise<void> {
const { session, text, metadata } = params;
const { session, text, chunks, metadata } = params;
if (session.stopped) {
logVerbose("slack-stream: stream already stopped, ignoring duplicate stop");
@@ -231,10 +256,15 @@ export async function stopSlackStream(params: StopSlackStreamParams): Promise<vo
);
try {
await session.streamer.stop({
...(text ? { markdown_text: text } : {}),
...(metadata ? { metadata } : {}),
});
await session.streamer.stop(
text || chunks?.length || metadata
? {
...(text ? { markdown_text: text } : {}),
...(chunks?.length ? { chunks } : {}),
...(metadata ? { metadata } : {}),
}
: undefined,
);
session.delivered = true;
session.pendingText = "";
} catch (err) {
@@ -105,6 +105,8 @@ export type GetReplyOptions = {
onToolResult?: (payload: ReplyPayload) => Promise<void> | void;
/** Called when a tool phase starts/updates, before summary payloads are emitted. */
onToolStart?: (payload: {
itemId?: string;
toolCallId?: string;
name?: string;
phase?: string;
args?: Record<string, unknown>;
@@ -3140,6 +3140,8 @@ describe("runAgentTurnWithFallback", () => {
expect(result.kind).toBe("success");
expect(onItemEvent).not.toHaveBeenCalled();
expect(onToolStart).toHaveBeenCalledWith({
itemId: "cmd-1",
toolCallId: "cmd-1",
name: "bash",
phase: "start",
args: { command: "pnpm test" },
@@ -3221,6 +3223,8 @@ describe("runAgentTurnWithFallback", () => {
expect(result.kind).toBe("success");
expect(onToolStart).toHaveBeenCalledWith({
itemId: undefined,
toolCallId: undefined,
name: "exec",
phase: "start",
args: { command: "pnpm test -- --watch=false" },
@@ -3265,6 +3269,8 @@ describe("runAgentTurnWithFallback", () => {
try {
expect(result.kind).toBe("success");
expect(onToolStart).toHaveBeenCalledWith({
itemId: undefined,
toolCallId: undefined,
name: "exec",
phase: "start",
args: { command: "echo hi" },
@@ -2278,6 +2278,8 @@ export async function runAgentTurnWithFallback(params: {
}
if (phase === "start" || phase === "update") {
const toolStartProgressPromise = params.opts?.onToolStart?.({
itemId: readStringValue(evt.data.itemId),
toolCallId: readStringValue(evt.data.toolCallId),
name,
phase,
args,
+13 -2
View File
@@ -21,10 +21,10 @@ export type {
ChannelStreamingConfig,
ChannelStreamingProgressConfig,
ChannelStreamingPreviewConfig,
SlackChannelStreamingConfig,
StreamingMode,
TextChunkMode,
} from "../config/types.base.js";
export type { SlackChannelStreamingConfig } from "../config/types.slack.js";
type StreamingCompatEntry = {
streaming?: unknown;
@@ -207,6 +207,8 @@ const EMOJI_PREFIX_RE = /^\p{Extended_Pictographic}/u;
export type ChannelProgressDraftLineInput =
| {
event: "tool";
itemId?: string;
toolCallId?: string;
name?: string;
phase?: string;
args?: Record<string, unknown>;
@@ -240,6 +242,8 @@ export type ChannelProgressDraftLineInput =
}
| {
event: "command-output";
itemId?: string;
toolCallId?: string;
phase?: string;
title?: string;
name?: string;
@@ -248,6 +252,8 @@ export type ChannelProgressDraftLineInput =
}
| {
event: "patch";
itemId?: string;
toolCallId?: string;
phase?: string;
title?: string;
name?: string;
@@ -291,6 +297,7 @@ function buildNamedProgressLine(
metas: readonly (string | undefined | null)[] | undefined,
options?: ChannelProgressLineOptions,
fields?: {
id?: string;
status?: string;
},
): ChannelProgressDraftLine | undefined {
@@ -309,6 +316,7 @@ function buildNamedProgressLine(
? text.slice(prefix.length + 2).trim()
: compactCommandPrefix;
return {
...(fields?.id ? { id: fields.id } : {}),
kind,
text,
label: display.label,
@@ -416,6 +424,7 @@ export function buildChannelProgressDraftLine(
input.phase && !input.name ? input.phase : undefined,
],
options,
{ id: input.itemId ?? input.toolCallId },
);
}
case "item": {
@@ -431,6 +440,7 @@ export function buildChannelProgressDraftLine(
}
if (name) {
return buildNamedProgressLine(input.event, name, [meta], options, {
id: input.itemId,
status: input.status,
});
}
@@ -483,7 +493,7 @@ export function buildChannelProgressDraftLine(
input.name ?? "exec",
[status, input.title],
options,
{ status },
{ id: input.itemId ?? input.toolCallId, status },
);
}
case "patch": {
@@ -495,6 +505,7 @@ export function buildChannelProgressDraftLine(
input.name ?? "apply_patch",
patchMetas(input),
options,
{ id: input.itemId ?? input.toolCallId },
);
}
}
File diff suppressed because one or more lines are too long
+16
View File
@@ -225,11 +225,27 @@ describe("config schema", () => {
const channelSchema = channelsProps?.matrix as Record<string, unknown> | undefined;
const channelProps = channelSchema?.properties as Record<string, unknown> | undefined;
expect(channelProps).toHaveProperty("accessToken");
const progressPropsFor = (channelId: string) => {
const channel = channelsProps?.[channelId] as Record<string, unknown> | undefined;
const properties = channel?.properties as Record<string, unknown> | undefined;
const streaming = properties?.streaming as Record<string, unknown> | undefined;
const streamingProperties = streaming?.properties as Record<string, unknown> | undefined;
const progress = streamingProperties?.progress as Record<string, unknown> | undefined;
return progress?.properties as Record<string, unknown> | undefined;
};
expect(progressPropsFor("slack")).toHaveProperty("nativeTaskCards");
expect(progressPropsFor("discord")).not.toHaveProperty("nativeTaskCards");
expect(progressPropsFor("telegram")).not.toHaveProperty("nativeTaskCards");
expect(res.uiHints["channels.matrix"]?.label).toBe("Matrix");
expect(res.uiHints["channels.matrix.accessToken"]?.sensitive).toBe(true);
expect(res.uiHints["channels.matrix.streaming.progress.label"]?.label).toBe(
"Matrix Progress Label",
);
expect(res.uiHints["channels.slack.streaming.progress.nativeTaskCards"]?.label).toBe(
"Slack Native Progress Task Cards",
);
expect(res.uiHints["channels.discord.streaming.progress.nativeTaskCards"]).toBeUndefined();
expect(res.uiHints["channels.telegram.streaming.progress.nativeTaskCards"]).toBeUndefined();
expect(res.uiHints["channels.discord.streaming.progress.toolProgress"]?.label).toBe(
"Discord Progress Tool Lines",
);
-5
View File
@@ -100,11 +100,6 @@ export type ChannelPreviewStreamingConfig = Pick<
"mode" | "chunkMode" | "preview" | "progress" | "block"
>;
export type SlackChannelStreamingConfig = Pick<
ChannelStreamingConfig,
"mode" | "chunkMode" | "preview" | "progress" | "block" | "nativeTransport"
>;
export type MarkdownTableMode = "off" | "bullets" | "code" | "block";
export type MarkdownConfig = {
+17 -1
View File
@@ -1,10 +1,14 @@
import type {
ChannelStreamingBlockConfig,
ChannelStreamingProgressConfig,
ChannelStreamingPreviewConfig,
ContextVisibilityMode,
DmPolicy,
GroupPolicy,
MarkdownConfig,
ReplyToMode,
SlackChannelStreamingConfig,
StreamingMode,
TextChunkMode,
} from "./types.base.js";
import type { ChannelBotLoopProtectionConfig } from "./types.bot-loop-protection.js";
import type {
@@ -51,6 +55,18 @@ export type SlackChannelConfig = {
export type SlackReactionNotificationMode = "off" | "own" | "all" | "allowlist";
export type SlackStreamingMode = "off" | "partial" | "block" | "progress";
export type SlackStreamingProgressConfig = ChannelStreamingProgressConfig & {
/** Opt in to Slack-native task cards for progress mode. Default: false. */
nativeTaskCards?: boolean;
};
export type SlackChannelStreamingConfig = {
mode?: StreamingMode;
chunkMode?: TextChunkMode;
nativeTransport?: boolean;
preview?: ChannelStreamingPreviewConfig;
progress?: SlackStreamingProgressConfig;
block?: ChannelStreamingBlockConfig;
};
export type SlackExecApprovalTarget = "dm" | "channel" | "both";
export type SlackExecApprovalConfig = {
/** Enable mode for Slack exec approvals on this account. Default: auto when approvers can be resolved; false disables. */
+4
View File
@@ -102,6 +102,9 @@ const ChannelStreamingProgressSchema = z
commandText: z.enum(["raw", "status"]).optional(),
})
.strict();
const SlackStreamingProgressSchema = ChannelStreamingProgressSchema.extend({
nativeTaskCards: z.boolean().optional(),
}).strict();
const ChannelPreviewStreamingConfigSchema = z
.object({
mode: UnifiedStreamingModeSchema.optional(),
@@ -116,6 +119,7 @@ const TelegramPreviewStreamingConfigSchema = ChannelPreviewStreamingConfigSchema
}).strict();
const SlackStreamingConfigSchema = ChannelPreviewStreamingConfigSchema.extend({
nativeTransport: z.boolean().optional(),
progress: SlackStreamingProgressSchema.optional(),
}).strict();
const SlackCapabilitiesSchema = z.union([
z.array(z.string()),
+26
View File
@@ -503,6 +503,32 @@ describe("channel-streaming", () => {
).toBe("🛠️ Exec\n• Checking the app-server stream");
});
it("preserves stable ids on named tool and command-output progress lines", () => {
const toolLine = buildChannelProgressDraftLine({
event: "tool",
itemId: "tool:item-1",
toolCallId: "call-1",
name: "bash",
phase: "start",
});
const commandLine = buildChannelProgressDraftLine({
event: "command-output",
itemId: "command:item-1",
toolCallId: "call-1",
name: "bash",
phase: "end",
exitCode: 0,
});
expect(toolLine).toMatchObject({ id: "tool:item-1", kind: "tool", toolName: "bash" });
expect(commandLine).toMatchObject({
id: "command:item-1",
kind: "command-output",
status: "completed",
toolName: "bash",
});
});
it("starts progress drafts after five seconds or a second work event", async () => {
vi.useFakeTimers();
const onStart = vi.fn(async () => {});
+13 -1
View File
@@ -1,11 +1,12 @@
// Public fetch/proxy helpers for plugins that need wrapped fetch behavior.
import type { GuardedFetchOptions } from "../infra/net/fetch-guard.js";
export { resolveFetch, wrapFetchWithAbortSignal } from "../infra/fetch.js";
export {
createHttp1EnvHttpProxyAgent,
createHttp1ProxyAgent,
} from "../infra/net/undici-runtime.js";
export { withTrustedEnvProxyGuardedFetchMode } from "../infra/net/fetch-guard.ts";
export {
addActiveManagedProxyTlsOptions,
resolveActiveManagedProxyTlsOptions,
@@ -24,3 +25,14 @@ export {
export { getProxyUrlFromFetch, makeProxyFetch } from "../infra/net/proxy-fetch.js";
export { createPinnedLookup } from "../infra/net/ssrf.js";
export type { PinnedDispatcherPolicy } from "../infra/net/ssrf.js";
type GuardedFetchPresetOptions = Omit<
GuardedFetchOptions,
"mode" | "proxy" | "dangerouslyAllowEnvProxyWithoutPinnedDns"
>;
export function withTrustedEnvProxyGuardedFetchMode(
params: GuardedFetchPresetOptions,
): GuardedFetchOptions {
return { ...params, mode: "trusted_env_proxy" };
}