mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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:
committed by
GitHub
parent
588078224b
commit
7c4601ec73
@@ -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`.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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 () => {});
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user