mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(slack): live session cards as the default progress mode (#122552)
* feat(slack): render live session cards as the default Slack progress mode Slack streaming.mode default flips partial->progress. Progress mode renders one live Block Kit session card with a status header, narration, plan, activity, diff stat, and elapsed time; it is edited in place and finalized to success or error with an Open in OpenClaw button when gateway.publicOrigin is set. Final assistant text always delivers separately. The shared progress compositor gains a success-only additive per-turn diffStat mirroring the task ledger fold. resolveGatewayPublicOrigin is exported through the plugin SDK. The diffs viewer URL falls back to publicOrigin. The old rich/text progress render fork is deleted. Native task cards remain unchanged and opt-in. * chore(config): regenerate bundled channel config metadata * refactor(slack): keep session card state type internal * refactor(slack): split session-card and diff-stat owners under lint ceilings * refactor(channels): reuse diff-stat type from its owner module Import ChannelProgressDraftDiffStat from progress-draft-diffstat instead of redeclaring it in the compositor, resolving the all-exports deadcode scan. * chore(plugin-sdk): regenerate api baselines for channel barrels Baselines drifted after the rebase reconciled them against main; regenerate to match the branch's actual channel-message/channel-outbound surface. * fix(slack): drop a session card that cannot terminalize after final delivery If the final reply is delivered but the terminal card edit fails, the caller now clears the stale card instead of leaving it stuck in its Working state (mirrors the pre-card preview cleanup). Adds a transport-failure regression and corrects three tests that asserted the prior ignore-the-result behavior. Documents resolveGatewayPublicOrigin as a dependency-light runtime helper on the config-contracts SDK subpath, which previously described a type-only surface. Addresses ClawSweeper P2 (unfinalized card) and P1 (runtime SDK contract). * fix(slack): suppress default tool messages under the default progress card resolveChannelStreamingSuppressDefaultToolProgressMessages re-derived the stream mode from config with an "off" default, unlike its sibling resolvers which take a caller-resolved mode override. After this branch made progress the Slack default, a default-config channel turn saw mode "off" and left a stray "Using tool: X" plain message posting alongside the session card. Thread the caller-resolved mode through (compositor passes params.mode; Slack dispatch passes slackStreaming.mode), matching resolveChannelStreamingPreviewToolProgress. Retarget the progress-session-card delivery-trace golden at an EMPTY Slack config so it proves the real default path; the regenerated golden is byte-identical, confirming defaults now yield the clean card sequence (one card post, separate final text, one terminal update with the Open in OpenClaw button, no stray tool message). Switch the dispatch delivery-mode mock to the real resolver so the card tests exercise the true channel default (automatic), not a hand-rolled one. * chore(plugin-sdk): regenerate api baselines for the streaming mode param resolveChannelStreamingSuppressDefaultToolProgressMessages gained an optional mode override; the changed signature reflows the surface hash of every barrel that re-exports it, so regenerate the affected baselines. * chore(config): regenerate config baselines
This commit is contained in:
committed by
GitHub
parent
901dd11a13
commit
508dd471b0
@@ -1,4 +1,4 @@
|
||||
42a33b67a94f8dd3c5ffd61d439cb6128ea8347f9f98d0f71647898756952ee1 config-baseline.json
|
||||
c8d9553463c613cd5e62eb3567add11fbf1733165aed22bdd2e947cd1a157ffe config-baseline.core.json
|
||||
552f5ae69ac13628d754e796bace6e800242d09cacbe762593c17ef3693ba754 config-baseline.channel.json
|
||||
061d4bc0ba8f8aad8ee1593521d65edc3e947b6ff7e4149c9783ce6ef806d448 config-baseline.json
|
||||
c12619ab274c0ce34d858a705a6ef542a4c0f71c9d58977b7949bb452b62f9eb config-baseline.core.json
|
||||
dd317647cf5ccf8d23774dcc7208d5ca749c08486137c6e5e6c8b7114592356f config-baseline.channel.json
|
||||
4bcc2364924c80f38139f0508945b6d28b33b70dd2973f0685a8f221d672ac94 config-baseline.plugin.json
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"f0a3282bbb9dd2ff22df32d5f86a27a488e6f7f6b007548d2026712b4e59a6c5","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
|
||||
{"contentHash":"3197bbee115b315bb7ffaec2f495abba5bc9981a90751760da6c430be3ee5445","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"60140db5b14eb3811a0398983df4729a0c4c2036f49a85622c1ed4a62b2e232a","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
|
||||
{"contentHash":"bc2750304f9959144c04143e8f5b14ca56bae3f8ec1bdfb27d05ddbe77e01d53","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"a5fd8c9fbf19c5a9eb6dca252cd551616571ddf344cf8e2963135e4b418cf063","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"}
|
||||
{"contentHash":"adc174fdd6ceeaa664ecf483d76aec22ecb111a7a8f6534cb78463b4347abb5e","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"e78d0b185d0a718fc12254b09e8432265614c8cdddae804a383fd0da3093968a","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
|
||||
{"contentHash":"362c2fc4b9048470c4857d542f7593bb5a8a8484d4c2176f4c9e560af642db7e","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"f1804d094895fffe929f32fbda11ddbef26be518eb310efff3b337cc069feb53","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
|
||||
{"contentHash":"bf8fa1e5fbb43f0a5fc3b891b93a848684414d78cb88c8f75b73491e4e46b0c7","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"96daa5866ecf95c20d81bab20d27289a6bb97019e94899e33b431846ba537208","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"}
|
||||
{"contentHash":"4cd42b2807c10408d0067265697f27a18bce6a1de9a4988e1a2e879f87c74598","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"9e96e620155dbc97b8cc74b1eeaf8987fc535d23e9f3c86450a9a75cc202964d","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
|
||||
{"contentHash":"b3dc5322d5837138fa4b857d5ca0f18cef4c1bcebb10874bef30f42c91e04465","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"febc6854cb89d5c26debeec50008526d0a8d97738f727011c3942509bb538fcc","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
|
||||
{"contentHash":"ad3ff6b5e1b3c11bf4e03599ec3724697869e5d342f5597c835ae1dd45b7feff","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"b3806b628ae37ac8242ab0f0a8387cd209c78ff9233486269e279677afda4c1b","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
|
||||
{"contentHash":"d2bfeec50ac2ee0abbf987a58b004820060f1d3d5634834e781d125da7e7a930","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"de23263a833b2d80eac0f7d7f41225ca0350a8918ae9b605d554a9400a9cfa2a","entrypoint":"channel-streaming","importSpecifier":"openclaw/plugin-sdk/channel-streaming"}
|
||||
{"contentHash":"2e6f9a8e8a07de789f6673813b5acc17dd08a11420c9de1b3ac509d37786adc6","entrypoint":"channel-streaming","importSpecifier":"openclaw/plugin-sdk/channel-streaming"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"424adb55a599d61ed8a334b9df45398864aa105d47d06bcf2a59dd644e6c0188","entrypoint":"config-contracts","importSpecifier":"openclaw/plugin-sdk/config-contracts"}
|
||||
{"contentHash":"02217a87f4bf0e081b406641519df90da15acd8aa430a9049f0c1584aabd2e3a","entrypoint":"config-contracts","importSpecifier":"openclaw/plugin-sdk/config-contracts"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"4ba3f7cf748c5ba26f22d626499c4bce05ef8e17f69bf864a87c1df3ab2dc5d4","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
||||
{"contentHash":"d24c93b08546f841c3f60d03383214671a3bd8e479ff2c1aa3d7a934ef1e0d28","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"518f095d39488d9c6fcfff4cf68043bca7308e4e807020d718f6a05f139d1b6b","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
|
||||
{"contentHash":"a910e8c530f0dc757c11a62e9b6898dffb5d0274de0311728badfdae657eb1bc","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"8b35ab0994d669d6bb95dd64bcd1d2c721fb5716d8c997d048775ab7d9cd0153","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
|
||||
{"contentHash":"4ba70d6eaf72efab607d4b2f9783df5bafdf21ab43a1f4a2947a48a829ed0337","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"3447ab3b4f9d00dd164e97954dbccb7cd53d2298aa5204c2017f390f15588437","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
|
||||
{"contentHash":"db01fbcef6813a72f89535130a49036afdf998d88de3bb29e2e5d7c62d6c95eb","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"6dea61641b09e01cdf88055074dceebfb16545da28b6dfefed6b1d85e66dbfeb","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
|
||||
{"contentHash":"80ebabbad4a9bc7b8b6eacc806fe3d94d468f3650dee8b97e4cf2883d025d08f","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"c9e9acdc7f66b6020b738d7a26657a09edce6f21c5ab5acd65e0dac8a7c3bb6f","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
|
||||
{"contentHash":"7766933d97e904bf753203045d712d8423cd33f8e018aedb39b5cdc8ac0b8a5f","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"a45abfd8f04f808a9b7bd2703b903be221b0bdb55fb3782f7e0e9d4ee71871a0","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
|
||||
{"contentHash":"17b9e593a0f1aebd5e985c34cbfc6a6df8963f8e73b25cfb9ca756a0e0918b77","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"dffc96ff312adfa28713a43b47d8ffc70e44c65be64c65e129d775a8fd77728b","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
|
||||
{"contentHash":"86096091c7e63bac10baec86c07cf7d9256e1ba1433a5dbb60b86df81e53ca11","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"4a6a6b7e93f7169d0aed68b74121ff4676d120d4a88e1b06ced1f2434e030677","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
|
||||
{"contentHash":"ee451a45f1687cede02fb776e87a630ed21fe485e1e5af1e36281161351d8278","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
|
||||
|
||||
@@ -1492,9 +1492,9 @@ The default scope (`"group-mentions"`) does not fire ack reactions in direct mes
|
||||
`channels.slack.streaming` controls live preview behavior:
|
||||
|
||||
- `off`: disable live preview streaming.
|
||||
- `partial` (default): replace preview text with the latest partial output.
|
||||
- `partial`: replace preview text with the latest partial output. Set this to restore the previous default behavior.
|
||||
- `block`: append chunked preview updates.
|
||||
- `progress`: show progress status text while generating, then send final text.
|
||||
- `progress` (default): maintain one live Block Kit session card in the thread while work runs, finalize that card in place, and send the assistant's final text as a separate message.
|
||||
- `streaming.preview.toolProgress`: when draft preview is active, route tool/progress updates into the same edited preview message (default: `true`). Set `false` to keep separate tool/progress messages.
|
||||
- `streaming.preview.commandText` / `streaming.progress.commandText`: `status` keeps compact tool-progress lines while hiding raw command/exec text (default); set `raw` to opt into command text.
|
||||
|
||||
@@ -1518,12 +1518,14 @@ 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.
|
||||
The default session card shows the current title, optional narration, plan checklist, recent activity, tool/file totals, and elapsed time. Completion changes the header to success or error while preserving the last plan and activity. When `gateway.publicOrigin` is configured, terminal cards include an **Open in OpenClaw** button linked to that session. If the Control UI is served below a path prefix, also set `gateway.controlUi.basePath`.
|
||||
|
||||
Slack native progress task cards remain a separate opt-in path. Set `channels.slack.streaming.progress.nativeTaskCards` to `true` with `channels.slack.streaming.mode="progress"` to use Slack's native plan/task stream instead of the Block Kit session card. This setting is unchanged.
|
||||
|
||||
- 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.
|
||||
- Custom outbound username/icon settings keep portable previews enabled. OpenClaw keeps the preview app-authored so partial/block previews can be removed before a separately customized final; progress mode may instead collapse the app-authored draft into a receipt. Slack does not allow impersonated messages to be deleted.
|
||||
- Custom outbound username/icon settings keep portable previews enabled. OpenClaw keeps the preview or session card app-authored and delivers the customized final separately. Slack does not allow impersonated messages to be deleted.
|
||||
- Media and non-text payloads fall back to normal delivery.
|
||||
- Media/error finals cancel pending preview edits; eligible text/block finals flush only when they can edit the preview in place.
|
||||
- If streaming fails mid-reply, OpenClaw falls back to normal delivery for remaining payloads.
|
||||
@@ -1553,7 +1555,6 @@ Opt in to Slack native progress task cards:
|
||||
mode: "progress",
|
||||
progress: {
|
||||
nativeTaskCards: true,
|
||||
render: "rich",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -183,14 +183,14 @@ instead of being overwritten in one editable draft.
|
||||
|
||||
### Channel mapping
|
||||
|
||||
Discord defaults to `off` when `streaming` is unset, Telegram defaults to
|
||||
`progress`, and Slack, Mattermost, and MS Teams default to `partial`.
|
||||
Discord defaults to `off` when `streaming` is unset, Telegram and Slack default
|
||||
to `progress`, and Mattermost and MS Teams default to `partial`.
|
||||
|
||||
| Channel | `off` | `partial` | `block` | `progress` |
|
||||
| ---------- | ------------- | --------- | ------- | --------------------------------- |
|
||||
| Telegram | Yes | Yes | Yes | editable progress draft (default) |
|
||||
| Discord | Yes (default) | Yes | Yes | editable progress draft (opt-in) |
|
||||
| Slack | Yes | Yes | Yes | Yes |
|
||||
| Slack | Yes | Yes | Yes | Block Kit session card (default) |
|
||||
| Mattermost | Yes | Yes | Yes | Yes |
|
||||
| MS Teams | Yes | Yes | Yes | native progress stream |
|
||||
|
||||
@@ -203,7 +203,7 @@ Slack-only:
|
||||
|
||||
- `channels.slack.streaming.nativeTransport` toggles Slack native streaming API
|
||||
calls (`chat.startStream`/`chat.appendStream`/`chat.stopStream`) when
|
||||
`channels.slack.streaming.mode="partial"` (default: `true`).
|
||||
`channels.slack.streaming.mode="partial"` (`nativeTransport` defaults to `true`).
|
||||
- Slack native streaming and Slack assistant thread status require a reply
|
||||
thread target. Top-level DMs do not show that thread-style preview, but can
|
||||
still use Slack draft preview posts and edits.
|
||||
@@ -270,14 +270,17 @@ Slack-only:
|
||||
- `partial` can use Slack native streaming (`chat.startStream`/`append`/`stop`)
|
||||
when available.
|
||||
- `block` uses append-style draft previews.
|
||||
- `progress` uses status preview text, then the final answer.
|
||||
- `progress` maintains one live Block Kit session card, finalizes it to success
|
||||
or error, and always posts the assistant's final text as a separate message.
|
||||
- Terminal cards include **Open in OpenClaw** when `gateway.publicOrigin` is set.
|
||||
Slack's native plan/task stream remains opt-in through
|
||||
`streaming.progress.nativeTaskCards: true`.
|
||||
- Top-level DMs without a reply thread use draft preview posts and edits
|
||||
instead of Slack native streaming.
|
||||
- Native and draft preview streaming suppress block replies for that turn, so a
|
||||
Slack reply is streamed by one delivery path only.
|
||||
- Final media/error payloads and progress finals do not create throwaway draft
|
||||
messages; only text/block finals that can edit the preview flush pending
|
||||
draft text.
|
||||
- A successful turn with no visible reply still deletes its draft card. A
|
||||
failed no-reply turn retains the card in its error state.
|
||||
|
||||
### Mattermost
|
||||
|
||||
@@ -341,7 +344,7 @@ Supported surfaces:
|
||||
`"status"` (the default). Set either option to `"raw"` to opt into command
|
||||
text. This policy is shared by draft/progress channels
|
||||
that use OpenClaw's compact progress renderer, including Discord, Matrix,
|
||||
Microsoft Teams, Mattermost, Slack draft previews, and Telegram. To disable
|
||||
Microsoft Teams, Mattermost, Slack session cards, and Telegram. To disable
|
||||
preview edits entirely, set `streaming.mode` to `off`.
|
||||
|
||||
## Progress draft rendering
|
||||
@@ -356,6 +359,9 @@ channel:
|
||||
| `streaming.progress.label` | `"auto"` | Draft title; a custom string, or `false` to hide it |
|
||||
| `streaming.progress.labels` | built-in pool | Candidate labels used when `label: "auto"` |
|
||||
|
||||
Slack always renders progress mode as its fixed session-card layout; these
|
||||
limits still bound the activity rows and plan text inside that card.
|
||||
|
||||
### Commentary progress lane
|
||||
|
||||
Beyond tool-progress, the compact progress renderer can surface one more lane
|
||||
|
||||
@@ -722,6 +722,9 @@ it on other network interfaces according to macOS Sharing settings.
|
||||
loopback hosts (`localhost`, `127.0.0.1`, or `[::1]`) during local development.
|
||||
Per-requester MCP OAuth requires this value and uses
|
||||
`<publicOrigin>/oauth/mcp/callback` as its callback URL.
|
||||
Slack session-card actions and plugin-generated viewer links also use this
|
||||
origin. Set `gateway.controlUi.basePath` separately when the Control UI is
|
||||
served below a reverse-proxy path prefix.
|
||||
- `bind`: `auto`, `loopback` (default), `lan` (`0.0.0.0`), `tailnet` (Tailscale IPv4 when available, otherwise loopback), or `custom` (one IPv4 address). A resolved `tailnet` address and any `custom` address other than `127.0.0.1` or `0.0.0.0` require `127.0.0.1` on the same port for same-host clients; startup fails if either listener cannot bind. Non-loopback exposure remains limited to the selected interface.
|
||||
- **Legacy bind aliases**: use bind mode values in `gateway.bind` (`auto`, `loopback`, `lan`, `tailnet`, `custom`), not host aliases (`0.0.0.0`, `127.0.0.1`, `localhost`, `::`, `::1`).
|
||||
- **Docker note**: the default `loopback` bind listens on `127.0.0.1` inside the container. With Docker bridge networking (`-p 18789:18789`), traffic arrives on `eth0`, so the gateway is unreachable. Use `--network host`, or set `bind: "lan"` (or `bind: "custom"` with `customBindHost: "0.0.0.0"`) to listen on all interfaces.
|
||||
|
||||
@@ -237,7 +237,7 @@ Use `isLoopbackHost(host)` when a plugin must accept only the local machine. It
|
||||
| `plugin-sdk/tts-runtime` | Private-local after July 2026; Supported facade for text-to-speech config schemas and runtime helpers |
|
||||
| `plugin-sdk/gateway-method-runtime` | Reserved Gateway method dispatch helper for plugin HTTP routes that declare `contracts.gatewayMethodDispatch: ["authenticated-request"]` |
|
||||
| `plugin-sdk/gateway-runtime` | Gateway client, event-loop-ready client start helper, gateway CLI RPC, gateway protocol errors, advertised LAN host resolution, and channel-status patch helpers |
|
||||
| `plugin-sdk/config-contracts` | Focused type-only config surface for plugin config shapes such as `OpenClawConfig` and channel/provider config types |
|
||||
| `plugin-sdk/config-contracts` | Focused config surface for plugin config shapes such as `OpenClawConfig` and channel/provider config types, plus the dependency-light runtime helper `resolveGatewayPublicOrigin(cfg)` which returns the normalized `gateway.publicOrigin` (bare http(s) origin, optional reverse-proxy path, no query/hash) or `undefined` when unset, for building links back to the Gateway |
|
||||
| `plugin-sdk/plugin-config-runtime` | Deprecated compatibility facade for runtime plugin-config helpers; new plugins use `api.pluginConfig` plus focused config contracts, snapshots, and mutation helpers |
|
||||
| `plugin-sdk/config-mutation` | Transactional config mutation helpers such as `mutateConfigFile`, `replaceConfigFile`, and `logConfigUpdated` |
|
||||
| `plugin-sdk/message-tool-delivery-hints` | Private-local after July 2026; Shared message-tool delivery metadata hint strings |
|
||||
|
||||
+2
-2
@@ -307,7 +307,7 @@ Viewer assets:
|
||||
|
||||
The viewer document resolves these assets relative to the viewer URL, so an optional `baseUrl` path prefix carries through to asset requests too.
|
||||
|
||||
URL resolution order: tool-call `baseUrl` (after strict validation) -> plugin `viewerBaseUrl` -> loopback `127.0.0.1` default. If gateway bind mode is `custom` and `gateway.customBindHost` is set, that host is used instead of loopback.
|
||||
URL resolution order: tool-call `baseUrl` (after strict validation) -> plugin `viewerBaseUrl` -> `gateway.publicOrigin` -> the existing bind-aware Gateway fallback.
|
||||
|
||||
`baseUrl` rules: must be `http://` or `https://`; query and hash are rejected; origin plus optional base path is allowed.
|
||||
|
||||
@@ -365,7 +365,7 @@ Common failure text: `Diff PNG/PDF rendering requires a Chromium-compatible brow
|
||||
</Accordion>
|
||||
<Accordion title="Viewer accessibility">
|
||||
- Viewer URL resolves to `127.0.0.1` by default.
|
||||
- For remote access, either set plugin `viewerBaseUrl`, pass `baseUrl` per call, or use `gateway.bind=custom` with `gateway.customBindHost`.
|
||||
- For remote access, set `gateway.publicOrigin`, set plugin `viewerBaseUrl`, or pass `baseUrl` per call.
|
||||
- If `gateway.trustedProxies` includes loopback for a same-host proxy (for example Tailscale Serve), raw loopback viewer requests without forwarded client-IP headers fail closed by design.
|
||||
- For that proxy topology, prefer `mode: "file"`/`"both"` for an attachment, or intentionally enable `security.allowRemoteViewer` plus plugin `viewerBaseUrl`/a proxy `baseUrl` for a shareable viewer link.
|
||||
- Enable `security.allowRemoteViewer` only when external viewer access is intended.
|
||||
|
||||
@@ -222,7 +222,7 @@ diff --git a/src/example.ts b/src/example.ts
|
||||
- The viewer is hosted locally through the gateway under `/plugins/diffs/...`.
|
||||
- Viewer HTML and metadata are ephemeral SQLite plugin blobs. The URL token is returned to the caller while SQLite stores only its SHA-256 hash.
|
||||
- Rendered PNG/PDF files remain temporary materializations in `$TMPDIR/openclaw-diffs` because delivery APIs require a file path. No JSON metadata sidecars are written or imported.
|
||||
- Default viewer URLs use loopback (`127.0.0.1`) unless you set plugin `viewerBaseUrl`, pass `baseUrl`, or use `gateway.bind=custom` + `gateway.customBindHost`.
|
||||
- Default viewer URLs use `gateway.publicOrigin` when configured, then the existing bind-aware Gateway fallback. Plugin `viewerBaseUrl` and per-call `baseUrl` take precedence.
|
||||
- If `gateway.trustedProxies` includes loopback for a same-host proxy (for example Tailscale Serve), raw `127.0.0.1` viewer requests without forwarded client-IP headers fail closed by design.
|
||||
- In that topology, prefer `mode=file` / `mode=both` for attachments, or intentionally enable remote viewers and set plugin `viewerBaseUrl` (or pass a proxy/public `baseUrl`) when you need a shareable viewer URL.
|
||||
- Remote viewer misses are throttled to reduce token-guess abuse.
|
||||
|
||||
@@ -418,7 +418,28 @@ describe("diffs viewer URL helpers", () => {
|
||||
).toBe("http://127.0.0.1:24444/plugins/diffs/view/id/token");
|
||||
});
|
||||
|
||||
it("uses custom bind host when provided", () => {
|
||||
it("resolves explicit, plugin, public, then bind-aware viewer bases", () => {
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: { gateway: { publicOrigin: "https://public.example.com" } },
|
||||
baseUrl: "https://explicit.example.com/review",
|
||||
viewerBaseUrl: "https://plugin.example.com/viewer",
|
||||
viewerPath: "/plugins/diffs/view/id/token",
|
||||
}),
|
||||
).toBe("https://explicit.example.com/review/plugins/diffs/view/id/token");
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: { gateway: { publicOrigin: "https://public.example.com" } },
|
||||
viewerBaseUrl: "https://plugin.example.com/viewer",
|
||||
viewerPath: "/plugins/diffs/view/id/token",
|
||||
}),
|
||||
).toBe("https://plugin.example.com/viewer/plugins/diffs/view/id/token");
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: { gateway: { publicOrigin: "https://public.example.com" } },
|
||||
viewerPath: "/plugins/diffs/view/id/token",
|
||||
}),
|
||||
).toBe("https://public.example.com/plugins/diffs/view/id/token");
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: {
|
||||
|
||||
@@ -240,7 +240,8 @@ export function createDiffsTool(params: {
|
||||
const viewerUrl = buildViewerUrl({
|
||||
config: params.api.config,
|
||||
viewerPath: artifact.viewerPath,
|
||||
baseUrl: normalizeBaseUrl(toolParams.baseUrl) ?? params.viewerBaseUrl,
|
||||
baseUrl: normalizeBaseUrl(toolParams.baseUrl),
|
||||
viewerBaseUrl: params.viewerBaseUrl,
|
||||
});
|
||||
|
||||
const baseDetails = {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// Diffs plugin module implements url behavior.
|
||||
import type { OpenClawConfig } from "../api.js";
|
||||
import {
|
||||
resolveGatewayPublicOrigin,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/config-contracts";
|
||||
|
||||
const DEFAULT_GATEWAY_PORT = 18789;
|
||||
type ViewerBaseUrlFieldName = "baseUrl" | "viewerBaseUrl";
|
||||
@@ -8,8 +11,13 @@ export function buildViewerUrl(params: {
|
||||
config: OpenClawConfig;
|
||||
viewerPath: string;
|
||||
baseUrl?: string;
|
||||
viewerBaseUrl?: string;
|
||||
}): string {
|
||||
const baseUrl = params.baseUrl?.trim() || resolveGatewayBaseUrl(params.config);
|
||||
const baseUrl =
|
||||
params.baseUrl?.trim() ||
|
||||
params.viewerBaseUrl?.trim() ||
|
||||
resolveGatewayPublicOrigin(params.config) ||
|
||||
resolveGatewayBaseUrl(params.config);
|
||||
const normalizedBase = normalizeViewerBaseUrl(baseUrl);
|
||||
const viewerPath = params.viewerPath.startsWith("/")
|
||||
? params.viewerPath
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{"seq":1,"at":0,"dir":"in","kind":"reply-start"}
|
||||
{"seq":2,"at":0,"dir":"out","kind":"assistant.threads.setStatus","data":{"payload":{"channel_id":"C0TRACE","status":"is typing...","thread_ts":"ts#1"},"result":{"ok":true},"target":"C0TRACE/ts#1"}}
|
||||
{"seq":3,"at":0,"dir":"in","kind":"tool-progress","data":{"name":"read","phase":"start"}}
|
||||
{"seq":4,"at":1500,"dir":"out","kind":"chat.postMessage","data":{"payload":{"blocks":[{"text":{"text":"🔄 *Working*","type":"mrkdwn"},"type":"section"},{"text":{"text":"📖 *Read* — —","type":"mrkdwn"},"type":"section"},{"elements":[{"text":"🛠️ 1 tools · ⏱ 2s","type":"mrkdwn"}],"type":"context"}],"channel":"C0TRACE","text":"Working\n\n📖 Read\n\n🔄 *Working*\n\n📖 *Read* — —\n\n🛠️ 1 tools · ⏱ 2s","thread_ts":"ts#1","unfurl_links":false},"result":{"ts":"ts#2"},"target":"C0TRACE"}}
|
||||
{"seq":5,"at":2000,"dir":"in","kind":"final","data":{"text":"The session card is complete."}}
|
||||
{"seq":6,"at":2000,"dir":"out","kind":"chat.postMessage","data":{"payload":{"channel":"C0TRACE","text":"The session card is complete.","thread_ts":"ts#1","unfurl_links":false},"result":{"ts":"ts#3"},"target":"C0TRACE"}}
|
||||
{"seq":7,"at":2000,"dir":"out","kind":"chat.update","data":{"payload":{"blocks":[{"text":{"text":"✅ *Working*","type":"mrkdwn"},"type":"section"},{"text":{"text":"📖 *Read* — —","type":"mrkdwn"},"type":"section"},{"elements":[{"text":"🛠️ 1 tool call · ⏱️ 2s","type":"mrkdwn"}],"type":"context"},{"elements":[{"action_id":"openclaw:session_link","text":{"text":"Open in OpenClaw","type":"plain_text"},"type":"button","url":"https://team.openclaw.ai/openclaw/chat/trace-agent/slack/channel/c0trace"}],"type":"actions"}],"channel":"C0TRACE","text":"Working\n\n📖 Read\n\n✅ *Working*\n\n📖 *Read* — —\n\n🛠️ 1 tool call · ⏱️ 2s\n\nOpen in OpenClaw","ts":"ts#2"},"result":{"ok":true},"target":"ts#2"}}
|
||||
{"seq":8,"at":2000,"dir":"in","kind":"idle"}
|
||||
{"seq":9,"at":2000,"dir":"out","kind":"assistant.threads.setStatus","data":{"payload":{"channel_id":"C0TRACE","status":"","thread_ts":"ts#1"},"result":{"ok":true},"target":"C0TRACE/ts#1"}}
|
||||
@@ -22,8 +22,8 @@ export const slackChannelConfigUiHints = {
|
||||
nativeCommands: true,
|
||||
implicitMentions: true,
|
||||
streaming: {
|
||||
"": 'Unified Slack stream preview mode: "off" | "partial" | "block" | "progress". Legacy boolean/streamMode keys are auto-mapped.',
|
||||
mode: 'Canonical Slack preview mode: "off" | "partial" | "block" | "progress".',
|
||||
"": 'Unified Slack stream preview mode: "off" | "partial" | "block" | "progress" (default). Legacy boolean/streamMode keys are auto-mapped.',
|
||||
mode: 'Canonical Slack preview mode: "off" | "partial" | "block" | "progress" (default).',
|
||||
chunkMode: 'Chunking mode for outbound Slack text delivery: "length" (default) or "newline".',
|
||||
"block.enabled":
|
||||
'Enable chunked block-style Slack preview delivery when channels.slack.streaming.mode="block".',
|
||||
@@ -34,8 +34,6 @@ export const slackChannelConfigUiHints = {
|
||||
"Show tool/progress activity in the live draft preview message (default: true). Set false to hide interim tool updates while the draft preview stays active.",
|
||||
"preview.commandText":
|
||||
'Command/exec detail in preview tool-progress lines: "status" is the safe default; "raw" opts into command text.',
|
||||
"progress.render":
|
||||
'Progress draft renderer: "text" uses one portable text body; "rich" renders structured Slack Block Kit fields with the same text fallback.',
|
||||
"progress.nativeTaskCards":
|
||||
'Opt in to Slack native task-card progress updates when channels.slack.streaming.mode="progress" and streaming.nativeTransport is enabled. Default: false.',
|
||||
},
|
||||
|
||||
@@ -164,7 +164,8 @@ type SlackTraceScenarioName =
|
||||
| "stream-stop-first-network-call"
|
||||
| "final-blocks-and-text"
|
||||
| "cancel-mid-stream"
|
||||
| "preview-edit-fallback";
|
||||
| "preview-edit-fallback"
|
||||
| "progress-session-card";
|
||||
|
||||
const NATIVE_SCENARIOS = new Set<SlackTraceScenarioName>([
|
||||
"streaming-happy-native",
|
||||
@@ -254,6 +255,13 @@ const slackTraceScenarios: Record<SlackTraceScenarioName, readonly DeliveryTrace
|
||||
{ kind: "final", text: PREVIEW_FINAL_TEXT },
|
||||
{ kind: "idle" },
|
||||
],
|
||||
"progress-session-card": [
|
||||
{ kind: "reply-start" },
|
||||
{ kind: "tool-progress", name: "read", phase: "start" },
|
||||
{ kind: "advance", ms: 2000 },
|
||||
{ kind: "final", text: "The session card is complete." },
|
||||
{ kind: "idle" },
|
||||
],
|
||||
};
|
||||
|
||||
/** Canonicalizes Slack `sec.micro` timestamps to `ts#N` in first-seen order. */
|
||||
@@ -421,7 +429,18 @@ function createRecordingSlackClient(): Record<string, unknown> {
|
||||
}
|
||||
|
||||
function createPreparedTraceMessage(scenario: SlackTraceScenarioName): PreparedSlackMessage {
|
||||
const cfg = { channels: { slack: { enabled: true } } } as OpenClawConfig;
|
||||
const progressCard = scenario === "progress-session-card";
|
||||
const cfg = {
|
||||
channels: { slack: { enabled: true } },
|
||||
...(progressCard
|
||||
? {
|
||||
gateway: {
|
||||
publicOrigin: "https://team.openclaw.ai",
|
||||
controlUi: { basePath: "/openclaw" },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
} as OpenClawConfig;
|
||||
const client = traceState.client;
|
||||
if (!client) {
|
||||
throw new Error("trace Slack client not initialized");
|
||||
@@ -469,9 +488,14 @@ function createPreparedTraceMessage(scenario: SlackTraceScenarioName): PreparedS
|
||||
},
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: {
|
||||
streaming: { mode: "partial", nativeTransport: NATIVE_SCENARIOS.has(scenario) },
|
||||
},
|
||||
config: progressCard
|
||||
? {}
|
||||
: {
|
||||
streaming: {
|
||||
mode: "partial",
|
||||
nativeTransport: NATIVE_SCENARIOS.has(scenario),
|
||||
},
|
||||
},
|
||||
},
|
||||
message: {
|
||||
type: "message",
|
||||
|
||||
@@ -197,6 +197,25 @@ describe("createSlackDraftStream", () => {
|
||||
expect(stream.messageId()).toBe("333.444");
|
||||
});
|
||||
|
||||
it("rearms updates after sealing and finalizing the previous message", async () => {
|
||||
const send = vi
|
||||
.fn<DraftSendFn>()
|
||||
.mockResolvedValueOnce(slackDraftSendResult("111.222"))
|
||||
.mockResolvedValueOnce(slackDraftSendResult("333.444"));
|
||||
const { stream } = createDraftStreamHarness({ send });
|
||||
|
||||
stream.update("first card");
|
||||
await stream.flush();
|
||||
await stream.seal();
|
||||
await expect(stream.finalizeMessage("111.222", async () => {})).resolves.toBe(true);
|
||||
stream.forceNewMessage();
|
||||
stream.update("second card");
|
||||
await stream.flush();
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect(stream.messageId()).toBe("333.444");
|
||||
});
|
||||
|
||||
it("continues below a human message that interrupts an in-progress Slack reply", async () => {
|
||||
const accountId = "interrupted-reply";
|
||||
const send = vi
|
||||
|
||||
@@ -196,6 +196,8 @@ export function createSlackDraftStream(params: {
|
||||
|
||||
const forceNewMessage = () => {
|
||||
stopTrackingConversationBoundary();
|
||||
streamState.stopped = false;
|
||||
streamState.final = false;
|
||||
streamMessageId = undefined;
|
||||
streamChannelId = undefined;
|
||||
lastVisibleUpdate = undefined;
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
SLACK_REPLY_BUTTON_ACTION_ID,
|
||||
SLACK_REPLY_LINK_ACTION_ID,
|
||||
SLACK_REPLY_SELECT_ACTION_ID,
|
||||
SLACK_SESSION_LINK_ACTION_ID,
|
||||
} from "../../reply-action-ids.js";
|
||||
import { truncateSlackText } from "../../truncate.js";
|
||||
import {
|
||||
@@ -387,6 +388,9 @@ function readSlackApprovalAction(parsed: ParsedSlackBlockAction): SlackApprovalA
|
||||
}
|
||||
|
||||
function isSlackReplyLinkAction(parsed: ParsedSlackBlockAction): boolean {
|
||||
if (parsed.actionId === SLACK_SESSION_LINK_ACTION_ID) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
parsed.actionId === SLACK_REPLY_LINK_ACTION_ID ||
|
||||
parsed.actionId.startsWith(`${SLACK_REPLY_LINK_ACTION_ID}:`)
|
||||
|
||||
@@ -2142,6 +2142,7 @@ describe("registerSlackInteractionEvents", () => {
|
||||
|
||||
it.each([
|
||||
{ name: "current", actionId: "openclaw:reply_link:1:1", value: undefined },
|
||||
{ name: "session", actionId: "openclaw:session_link", value: undefined },
|
||||
{
|
||||
name: "legacy",
|
||||
actionId: "openclaw:reply_button:1:1",
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
createChannelProgressReceiptTracker,
|
||||
formatChannelProgressDraftText,
|
||||
type ChannelProgressDraftCompositorSnapshot,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { resolveGatewayPublicOrigin } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { buildControlUiSessionPath } from "openclaw/plugin-sdk/session-discussion";
|
||||
import { createSlackDraftStream } from "../../draft-stream.js";
|
||||
import { formatSlackError } from "../../errors.js";
|
||||
import { buildSlackProgressCardBlocks } from "../../progress-blocks.js";
|
||||
import { escapeSlackMrkdwn } from "../mrkdwn.js";
|
||||
import {
|
||||
combineProgressHeadlineAndExplanation,
|
||||
resolveStructuredProgressLines,
|
||||
} from "./dispatch-progress-render.js";
|
||||
import type { SlackDispatchSetup } from "./dispatch-setup.js";
|
||||
import { finalizeSlackPreviewEdit } from "./preview-finalize.js";
|
||||
|
||||
type DraftProgressCardState = "working" | "success" | "error";
|
||||
|
||||
export function createSlackDraftProgressCardRuntime(params: {
|
||||
setup: Pick<SlackDispatchSetup, "account" | "cfg" | "ctx" | "prepared" | "slackClient">;
|
||||
draftStream: ReturnType<typeof createSlackDraftStream> | undefined;
|
||||
enabled: boolean;
|
||||
progressReceipt: ReturnType<typeof createChannelProgressReceiptTracker>;
|
||||
progressSeed: string;
|
||||
explicitTitle: string | undefined;
|
||||
maxLineChars: number;
|
||||
getSnapshot: () => ChannelProgressDraftCompositorSnapshot;
|
||||
getThreadTs: () => string | undefined;
|
||||
}) {
|
||||
const { account, cfg, ctx, prepared, slackClient } = params.setup;
|
||||
let latestFallbackText = "";
|
||||
let finalStatus: Exclude<DraftProgressCardState, "working"> | undefined;
|
||||
|
||||
const resolveSessionUrl = () => {
|
||||
const publicOrigin = resolveGatewayPublicOrigin(cfg);
|
||||
if (!publicOrigin) {
|
||||
return undefined;
|
||||
}
|
||||
const url = new URL(publicOrigin);
|
||||
const path = buildControlUiSessionPath({
|
||||
namespace: "chat",
|
||||
sessionKey: prepared.route.sessionKey,
|
||||
fallbackAgentId: prepared.route.agentId,
|
||||
basePath: cfg.gateway?.controlUi?.basePath,
|
||||
});
|
||||
if (!path) {
|
||||
return undefined;
|
||||
}
|
||||
url.pathname = path;
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const resolveText = (snapshot: ChannelProgressDraftCompositorSnapshot) =>
|
||||
latestFallbackText ||
|
||||
formatChannelProgressDraftText({
|
||||
entry: account.config,
|
||||
lines: [...snapshot.lines],
|
||||
seed: params.progressSeed,
|
||||
formatLine: formatSlackProgressDraftLine,
|
||||
narration: snapshot.statusHeadline,
|
||||
plan: snapshot.plan,
|
||||
});
|
||||
|
||||
const resolvePresentation = (
|
||||
snapshot: ChannelProgressDraftCompositorSnapshot,
|
||||
state: DraftProgressCardState,
|
||||
) => {
|
||||
const title = params.explicitTitle ?? snapshot.statusHeadline ?? "Working";
|
||||
const narration = params.explicitTitle
|
||||
? combineProgressHeadlineAndExplanation(snapshot.statusHeadline, snapshot.planExplanation)
|
||||
: snapshot.planExplanation && snapshot.planExplanation !== title
|
||||
? snapshot.planExplanation
|
||||
: undefined;
|
||||
return buildSlackProgressCardBlocks({
|
||||
state,
|
||||
title,
|
||||
narration,
|
||||
plan: snapshot.plan,
|
||||
lines: resolveStructuredProgressLines(snapshot.lines),
|
||||
maxLineChars: params.maxLineChars,
|
||||
diffStat: snapshot.diffStat,
|
||||
...(state === "working"
|
||||
? {
|
||||
toolCalls: params.progressReceipt.toolCalls,
|
||||
elapsedSeconds: params.progressReceipt.elapsedSeconds,
|
||||
}
|
||||
: {
|
||||
receiptSummary: params.progressReceipt.buildSummaryLine(),
|
||||
sessionUrl: resolveSessionUrl(),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const finalize = async (
|
||||
status: Exclude<DraftProgressCardState, "working">,
|
||||
snapshot = params.getSnapshot(),
|
||||
fallbackText = resolveText(snapshot),
|
||||
): Promise<boolean> => {
|
||||
if (!params.draftStream || !params.enabled) {
|
||||
return false;
|
||||
}
|
||||
const terminalStatus = finalStatus === "error" || status === "error" ? "error" : "success";
|
||||
if (finalStatus === terminalStatus) {
|
||||
return true;
|
||||
}
|
||||
await params.draftStream.flush();
|
||||
const channelId = params.draftStream.channelId();
|
||||
const messageId = params.draftStream.messageId();
|
||||
if (!channelId || !messageId) {
|
||||
return false;
|
||||
}
|
||||
await params.draftStream.seal();
|
||||
try {
|
||||
const finalized = await params.draftStream.finalizeMessage(messageId, async () => {
|
||||
await finalizeSlackPreviewEdit({
|
||||
client: slackClient,
|
||||
token: ctx.botToken,
|
||||
accountId: account.accountId,
|
||||
channelId,
|
||||
messageId,
|
||||
text: fallbackText,
|
||||
blocks: resolvePresentation(snapshot, terminalStatus),
|
||||
threadTs: params.getThreadTs(),
|
||||
});
|
||||
});
|
||||
if (finalized) {
|
||||
finalStatus = terminalStatus;
|
||||
}
|
||||
return finalized;
|
||||
} catch (err) {
|
||||
logVerbose(`slack: progress card final edit failed (${formatSlackError(err)})`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
resolveText,
|
||||
resolvePresentation,
|
||||
finalize,
|
||||
setFallbackText(text: string) {
|
||||
latestFallbackText = text;
|
||||
},
|
||||
reset() {
|
||||
latestFallbackText = "";
|
||||
finalStatus = undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function formatSlackProgressDraftLine(line: string): string {
|
||||
if (/^(?:🧠|💬)\s/u.test(line)) {
|
||||
return line;
|
||||
}
|
||||
|
||||
const italicCommentary = /^_(.*)_$/su.exec(line);
|
||||
if (!italicCommentary) {
|
||||
return escapeSlackMrkdwn(line);
|
||||
}
|
||||
|
||||
const content = italicCommentary[1]!
|
||||
.split(/(`[^`\n]+`)/u)
|
||||
.map((segment, index) => {
|
||||
if (index % 2 === 0) {
|
||||
return escapeSlackMrkdwn(segment);
|
||||
}
|
||||
const code = segment
|
||||
.slice(1, -1)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
return `\`${code}\``;
|
||||
})
|
||||
.join("");
|
||||
|
||||
return `_${content}_`;
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
formatChannelProgressDraftText,
|
||||
isChannelProgressDraftWorkToolName,
|
||||
resolveChannelProgressDraftMaxLineChars,
|
||||
resolveChannelProgressDraftRender,
|
||||
resolveChannelStreamingPreviewToolProgress,
|
||||
resolveChannelStreamingSuppressDefaultToolProgressMessages,
|
||||
type ChannelProgressDraftCompositorSnapshot,
|
||||
@@ -18,7 +17,6 @@ import { createSlackDraftStream } from "../../draft-stream.js";
|
||||
import { formatSlackError } from "../../errors.js";
|
||||
import { SLACK_TEXT_LIMIT } from "../../limits.js";
|
||||
import {
|
||||
buildSlackProgressDraftBlocks,
|
||||
buildSlackProgressStreamCompletionChunks,
|
||||
buildSlackProgressStreamStartChunks,
|
||||
buildSlackProgressStreamUpdateChunks,
|
||||
@@ -32,18 +30,20 @@ import {
|
||||
stopSlackStream,
|
||||
type SlackStreamSession,
|
||||
} from "../../streaming.js";
|
||||
import { escapeSlackMrkdwn } from "../mrkdwn.js";
|
||||
import {
|
||||
resolveExplicitSlackProgressTitle,
|
||||
resolveSlackStreamRecipientTeamId,
|
||||
} from "./dispatch-helpers.js";
|
||||
import {
|
||||
createSlackDraftProgressCardRuntime,
|
||||
formatSlackProgressDraftLine,
|
||||
} from "./dispatch-progress-card.js";
|
||||
import { collapseSlackProgressReceipt } from "./dispatch-progress-io.js";
|
||||
import {
|
||||
buildNativeProgressChunks as buildRenderedNativeProgressChunks,
|
||||
combineProgressHeadlineAndExplanation,
|
||||
resolveNativeProgressLines,
|
||||
resolveNativeProgressPlan,
|
||||
resolveStructuredProgressLines,
|
||||
} from "./dispatch-progress-render.js";
|
||||
import type { SlackDispatchSetup } from "./dispatch-setup.js";
|
||||
import type { SlackStreamingDeliveryRuntime } from "./dispatch-streaming.js";
|
||||
@@ -107,6 +107,7 @@ export function createSlackProgressRuntime(runtimeParams: {
|
||||
const suppressDefaultToolProgressMessages =
|
||||
resolveChannelStreamingSuppressDefaultToolProgressMessages(account.config, {
|
||||
draftStreamActive: Boolean(draftStream) || useNativeProgressStreaming,
|
||||
mode: slackStreaming.mode,
|
||||
previewToolProgressEnabled,
|
||||
previewStreamingEnabled,
|
||||
});
|
||||
@@ -125,10 +126,20 @@ export function createSlackProgressRuntime(runtimeParams: {
|
||||
let progressReceiptCollapsed = false;
|
||||
let pendingNativeProgressReceipt: string | undefined;
|
||||
const progressSeed = `${account.accountId}:${message.channel}`;
|
||||
const useRichProgressDraft =
|
||||
streamMode === "status_final" && resolveChannelProgressDraftRender(account.config) === "rich";
|
||||
const useDraftProgressCard = Boolean(draftStream) && streamMode === "status_final";
|
||||
const explicitProgressTitle = resolveExplicitSlackProgressTitle(account.config);
|
||||
const progressDraftMaxLineChars = resolveChannelProgressDraftMaxLineChars(account.config);
|
||||
const progressCard = createSlackDraftProgressCardRuntime({
|
||||
setup: { account, cfg, ctx, prepared, slackClient },
|
||||
draftStream,
|
||||
enabled: useDraftProgressCard,
|
||||
progressReceipt,
|
||||
progressSeed,
|
||||
explicitTitle: explicitProgressTitle,
|
||||
maxLineChars: progressDraftMaxLineChars,
|
||||
getSnapshot: () => progressDraft.getSnapshot(),
|
||||
getThreadTs: () => delivery.usedReplyThreadTs,
|
||||
});
|
||||
|
||||
const waitForNativeProgressStreamStart = async (): Promise<boolean> => {
|
||||
if (delivery.streamSession || !delivery.nativeProgressStreamStartPromise) {
|
||||
@@ -337,7 +348,7 @@ export function createSlackProgressRuntime(runtimeParams: {
|
||||
input.event === "tool" || input.event === "item" || input.event === "command-output"
|
||||
? buildChannelProgressDraftLineForEntry(account.config, input, options)
|
||||
: buildChannelProgressDraftLine(input, options),
|
||||
updateOnLineChange: useNativeProgressStreaming || useRichProgressDraft,
|
||||
updateOnLineChange: useNativeProgressStreaming || useDraftProgressCard,
|
||||
update: async (previewText, options) => {
|
||||
if (useNativeProgressStreaming) {
|
||||
return await updateNativeProgressStream();
|
||||
@@ -346,23 +357,13 @@ export function createSlackProgressRuntime(runtimeParams: {
|
||||
return false;
|
||||
}
|
||||
const snapshot = progressDraft.getSnapshot();
|
||||
const structuredLines = resolveStructuredProgressLines(options?.lines ?? snapshot.lines);
|
||||
const richNarration = combineProgressHeadlineAndExplanation(
|
||||
snapshot.statusHeadline,
|
||||
snapshot.planExplanation,
|
||||
);
|
||||
const richProgressBlocks = useRichProgressDraft
|
||||
? buildSlackProgressDraftBlocks({
|
||||
title: explicitProgressTitle,
|
||||
lines: structuredLines,
|
||||
plan: snapshot.plan,
|
||||
narration: richNarration,
|
||||
maxLineChars: progressDraftMaxLineChars,
|
||||
})
|
||||
: undefined;
|
||||
progressCard.setFallbackText(previewText);
|
||||
draftStream.update(
|
||||
useRichProgressDraft && richProgressBlocks
|
||||
? { text: previewText, blocks: richProgressBlocks }
|
||||
useDraftProgressCard
|
||||
? {
|
||||
text: previewText,
|
||||
blocks: progressCard.resolvePresentation(snapshot, "working"),
|
||||
}
|
||||
: previewText,
|
||||
);
|
||||
hasStreamedMessage = true;
|
||||
@@ -557,6 +558,8 @@ export function createSlackProgressRuntime(runtimeParams: {
|
||||
progressDraft.reset();
|
||||
};
|
||||
const beginNewProgressTurn = async (options?: { force?: boolean }) => {
|
||||
const priorSnapshot = progressDraft.getSnapshot();
|
||||
const priorFallbackText = progressCard.resolveText(priorSnapshot);
|
||||
const completionChunks =
|
||||
useNativeProgressStreaming && !nativeProgressCompletionSent
|
||||
? buildNativeProgressCompletionChunks(nativeProgressTerminalStatus)
|
||||
@@ -569,6 +572,7 @@ export function createSlackProgressRuntime(runtimeParams: {
|
||||
if (useNativeProgressStreaming) {
|
||||
await finishNativeProgressTurn(completionChunks);
|
||||
} else {
|
||||
await progressCard.finalize("success", priorSnapshot, priorFallbackText);
|
||||
draftStream?.forceNewMessage();
|
||||
}
|
||||
resetProgressTurnState();
|
||||
@@ -576,6 +580,7 @@ export function createSlackProgressRuntime(runtimeParams: {
|
||||
nativeProgressCompletionSent = false;
|
||||
nativeProgressTerminalStatus = "complete";
|
||||
nativeProgressChunkKey = undefined;
|
||||
progressCard.reset();
|
||||
// A re-armed turn is a new visible reply: it must not dedupe against or
|
||||
// inherit delivery state from the settled turn (mirrors queued admission).
|
||||
resetPreviewDeliveryState();
|
||||
@@ -619,6 +624,7 @@ export function createSlackProgressRuntime(runtimeParams: {
|
||||
return {
|
||||
draftStream,
|
||||
streamMode,
|
||||
useDraftProgressCard,
|
||||
useNativeProgressStreaming,
|
||||
progressDraftActive,
|
||||
previewToolProgressEnabled,
|
||||
@@ -648,6 +654,7 @@ export function createSlackProgressRuntime(runtimeParams: {
|
||||
beginNewProgressTurn,
|
||||
buildNativeProgressCompletionChunks,
|
||||
collapseProgressReceipt,
|
||||
finalizeDraftProgressCard: progressCard.finalize,
|
||||
onDraftBoundary,
|
||||
onQueuedFollowupAdmitted,
|
||||
pushPlanProgress,
|
||||
@@ -661,31 +668,3 @@ export function createSlackProgressRuntime(runtimeParams: {
|
||||
shouldYieldDraftProgress: () => shouldYieldDraftProgress(),
|
||||
};
|
||||
}
|
||||
|
||||
function formatSlackProgressDraftLine(line: string): string {
|
||||
if (/^(?:🧠|💬)\s/u.test(line)) {
|
||||
return line;
|
||||
}
|
||||
|
||||
const italicCommentary = /^_(.*)_$/su.exec(line);
|
||||
if (!italicCommentary) {
|
||||
return escapeSlackMrkdwn(line);
|
||||
}
|
||||
|
||||
const content = italicCommentary[1]!
|
||||
.split(/(`[^`\n]+`)/u)
|
||||
.map((segment, index) => {
|
||||
if (index % 2 === 0) {
|
||||
return escapeSlackMrkdwn(segment);
|
||||
}
|
||||
const code = segment
|
||||
.slice(1, -1)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
return `\`${code}\``;
|
||||
})
|
||||
.join("");
|
||||
|
||||
return `_${content}_`;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ const createSlackDraftStreamMock = vi.fn();
|
||||
const deliverRepliesMock = vi.fn(
|
||||
async () => undefined as { messageId?: string; channelId?: string } | undefined,
|
||||
);
|
||||
const finalizeSlackPreviewEditMock = vi.fn(async () => {});
|
||||
const finalizeSlackPreviewEditMock = vi.fn(async (_input: { blocks?: unknown }) => {});
|
||||
const normalizeSlackOutboundTextMock = vi.fn((value: string) => value.trim());
|
||||
const postMessageMock = vi.fn(async () => ({ ok: true, ts: "171234.999" }));
|
||||
const chatUpdateMock = vi.fn(async () => ({ ok: true, ts: "171234.999" }));
|
||||
@@ -93,6 +93,7 @@ let mockedDispatchSequence: TestDispatchSequenceEntry[] = [];
|
||||
let mockedQueuedDispatchCounts: TestDispatchCounts = { tool: 0, block: 0, final: 0 };
|
||||
let mockedDispatcherCapturesDeliveryErrors = false;
|
||||
let mockedAgentRunTerminalOutcome: "completed" | "failed" | undefined;
|
||||
let mockedDispatchError: Error | undefined;
|
||||
|
||||
let mockedProgressEvents: string[] = [];
|
||||
let mockedEmptyProgressToolName: string | undefined;
|
||||
@@ -288,6 +289,22 @@ function createDraftStreamStub() {
|
||||
};
|
||||
}
|
||||
|
||||
function draftUpdateTexts(draftStream: ReturnType<typeof createDraftStreamStub>): string[] {
|
||||
return draftStream.update.mock.calls.map(([update]) => {
|
||||
if (typeof update === "string") {
|
||||
return update;
|
||||
}
|
||||
return requireRecord(update, "draft update").text as string;
|
||||
});
|
||||
}
|
||||
|
||||
function expectLastDraftUpdateText(
|
||||
draftStream: ReturnType<typeof createDraftStreamStub>,
|
||||
expected: string,
|
||||
) {
|
||||
expect(draftUpdateTexts(draftStream).at(-1)).toBe(expected);
|
||||
}
|
||||
|
||||
function createPreparedSlackMessage(params?: {
|
||||
cfg?: Record<string, unknown>;
|
||||
accountConfig?: Record<string, unknown>;
|
||||
@@ -490,25 +507,8 @@ vi.mock("openclaw/plugin-sdk/channel-outbound", async (importOriginal) => {
|
||||
onModelSelected: undefined,
|
||||
};
|
||||
},
|
||||
resolveChannelMessageSourceReplyDeliveryMode: (params: {
|
||||
cfg?: { messages?: { groupChat?: { visibleReplies?: string } } };
|
||||
ctx?: { ChatType?: string; InboundEventKind?: string };
|
||||
requested?: "automatic" | "message_tool_only";
|
||||
}) => {
|
||||
if (params.requested) {
|
||||
return params.requested;
|
||||
}
|
||||
if (params.ctx?.InboundEventKind === "room_event") {
|
||||
return "message_tool_only";
|
||||
}
|
||||
const chatType = params.ctx?.ChatType;
|
||||
if (chatType === "group" || chatType === "channel") {
|
||||
return params.cfg?.messages?.groupChat?.visibleReplies === "automatic"
|
||||
? "automatic"
|
||||
: "message_tool_only";
|
||||
}
|
||||
return "automatic";
|
||||
},
|
||||
resolveChannelMessageSourceReplyDeliveryMode:
|
||||
actual.resolveChannelMessageSourceReplyDeliveryMode,
|
||||
resolveAgentOutboundIdentity: () => undefined,
|
||||
buildChannelProgressDraftLine: (params: {
|
||||
event?: string;
|
||||
@@ -753,9 +753,6 @@ vi.mock("openclaw/plugin-sdk/channel-outbound", async (importOriginal) => {
|
||||
const previousText = typeof previous === "string" ? previous.trim() : previous?.text.trim();
|
||||
return previousText === normalized ? lines : [...lines, line].slice(-params.maxLines);
|
||||
},
|
||||
resolveChannelProgressDraftRender: (entry?: {
|
||||
streaming?: { progress?: { render?: "text" | "rich" } };
|
||||
}) => entry?.streaming?.progress?.render ?? "text",
|
||||
resolveChannelStreamingBlockEnabled: () => mockedBlockStreamingEnabled,
|
||||
resolveChannelStreamingNativeTransport: () => mockedNativeStreaming,
|
||||
resolveChannelStreamingPreviewToolProgress: (entry?: {
|
||||
@@ -1061,6 +1058,9 @@ vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
|
||||
await params.replyOptions?.onItemEvent?.({ progressText });
|
||||
}
|
||||
}
|
||||
if (mockedDispatchError) {
|
||||
throw mockedDispatchError;
|
||||
}
|
||||
for (const entry of mockedDispatchSequence) {
|
||||
if (entry.kind === "queued_followup") {
|
||||
await params.replyOptions?.onQueuedFollowupAdmitted?.();
|
||||
@@ -1153,6 +1153,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
mockedQueuedDispatchCounts = { tool: 0, block: 0, final: 0 };
|
||||
mockedDispatcherCapturesDeliveryErrors = false;
|
||||
mockedAgentRunTerminalOutcome = undefined;
|
||||
mockedDispatchError = undefined;
|
||||
mockedProgressEvents = [];
|
||||
mockedEmptyProgressToolName = undefined;
|
||||
mockedReplyOptionEvents = [];
|
||||
@@ -2065,7 +2066,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(draftStream.update).toHaveBeenCalledWith(
|
||||
expect(draftUpdateTexts(draftStream)).toContain(
|
||||
"Shelling\n\n• ran <!here> <@U123> \\*bold\\* \\`code\\` & done",
|
||||
);
|
||||
});
|
||||
@@ -2089,10 +2090,11 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(draftStream.update).toHaveBeenLastCalledWith(
|
||||
expectLastDraftUpdateText(
|
||||
draftStream,
|
||||
["Shelling", "", "• exec", "🧠 _Reading the Slack handler_"].join("\n"),
|
||||
);
|
||||
const updates = draftStream.update.mock.calls.map((call) => String(call[0]));
|
||||
const updates = draftUpdateTexts(draftStream);
|
||||
expect(updates.join("\n")).not.toContain("Reasoning");
|
||||
});
|
||||
|
||||
@@ -2118,10 +2120,11 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(draftStream.update).toHaveBeenLastCalledWith(
|
||||
expectLastDraftUpdateText(
|
||||
draftStream,
|
||||
["Shelling", "", "• exec", "🧠 _Reading Checking_"].join("\n"),
|
||||
);
|
||||
const updates = draftStream.update.mock.calls.map((call) => String(call[0]));
|
||||
const updates = draftUpdateTexts(draftStream);
|
||||
expect(updates.join("\n")).not.toContain("Checking Reading");
|
||||
});
|
||||
|
||||
@@ -2145,10 +2148,8 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(draftStream.update).toHaveBeenLastCalledWith(
|
||||
["Shelling", "", "🧠 _Reading Checking_"].join("\n"),
|
||||
);
|
||||
const updates = draftStream.update.mock.calls.map((call) => String(call[0]));
|
||||
expectLastDraftUpdateText(draftStream, ["Shelling", "", "🧠 _Reading Checking_"].join("\n"));
|
||||
const updates = draftUpdateTexts(draftStream);
|
||||
expect(updates.join("\n")).toContain("Reading Checking");
|
||||
});
|
||||
|
||||
@@ -2172,7 +2173,8 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(draftStream.update).toHaveBeenLastCalledWith(
|
||||
expectLastDraftUpdateText(
|
||||
draftStream,
|
||||
["Shelling", "", "🧠 _Thinking about Slack preview state_"].join("\n"),
|
||||
);
|
||||
});
|
||||
@@ -2189,7 +2191,8 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(draftStream.update).toHaveBeenLastCalledWith(
|
||||
expectLastDraftUpdateText(
|
||||
draftStream,
|
||||
[
|
||||
"• step 1",
|
||||
"• step 2",
|
||||
@@ -2223,12 +2226,10 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(draftStream.update).toHaveBeenLastCalledWith(
|
||||
["Shelling", "", "• tool one", "• tool two"].join("\n"),
|
||||
);
|
||||
expectLastDraftUpdateText(draftStream, ["Shelling", "", "• tool one", "• tool two"].join("\n"));
|
||||
});
|
||||
|
||||
it("renders rich status-final progress drafts as legacy Slack section blocks and finalizes once", async () => {
|
||||
it("renders and finalizes one Slack session card while delivering final text separately", async () => {
|
||||
const draftStream = createDraftStreamStub();
|
||||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||||
finalizeSlackPreviewEditMock.mockResolvedValueOnce(undefined);
|
||||
@@ -2243,43 +2244,67 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
|
||||
await dispatchPreparedSlackMessage(
|
||||
createPreparedSlackMessage({
|
||||
accountConfig: { streaming: { progress: { label: "Shelling", render: "rich" } } },
|
||||
cfg: {
|
||||
gateway: {
|
||||
publicOrigin: "https://team.openclaw.ai",
|
||||
controlUi: { basePath: "/openclaw" },
|
||||
},
|
||||
},
|
||||
accountConfig: { streaming: { progress: { label: "Shelling" } } },
|
||||
}),
|
||||
);
|
||||
|
||||
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", {
|
||||
expect(draftStream.update).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
text: ["Shelling", "", "• tool one", "• tool two"].join("\n"),
|
||||
blocks: expect.arrayContaining([
|
||||
{ type: "section", text: { type: "mrkdwn", text: "🔄 *Shelling*" } },
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expectMockCallArgFields(finalizeSlackPreviewEditMock, 0, "session card final edit", {
|
||||
channelId: "C123",
|
||||
messageId: "171234.567",
|
||||
text: FINAL_REPLY_TEXT,
|
||||
});
|
||||
expect(deliverRepliesMock).not.toHaveBeenCalled();
|
||||
const finalEdit = requireRecord(
|
||||
requireMockCall(finalizeSlackPreviewEditMock, 0, "session card final edit")[0],
|
||||
"session card final edit",
|
||||
);
|
||||
expect(JSON.stringify(finalEdit.blocks)).toContain("✅ *Shelling*");
|
||||
expect(JSON.stringify(finalEdit.blocks)).toContain("Open in OpenClaw");
|
||||
expect(JSON.stringify(finalEdit.blocks)).toContain(
|
||||
"https://team.openclaw.ai/openclaw/chat/agent-1/slack/C123",
|
||||
);
|
||||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||||
expectDeliverReplyCall(0, FINAL_REPLY_TEXT);
|
||||
expect(draftStream.clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps plan explanation in rich blocks with a fresh preamble", async () => {
|
||||
it("clears the stale session card when the terminal edit fails after final delivery", async () => {
|
||||
const draftStream = createDraftStreamStub();
|
||||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||||
// Final reply lands, but terminalizing the card into its ✅ state fails.
|
||||
finalizeSlackPreviewEditMock.mockRejectedValueOnce(new Error("card edit failed"));
|
||||
mockedSlackStreamingMode = "progress";
|
||||
mockedSlackDraftMode = "status_final";
|
||||
mockedDispatchSequence = [{ kind: "final", payload: { text: FINAL_REPLY_TEXT } }];
|
||||
mockedReplyOptionEvents = [{ kind: "item", progressText: "working" }];
|
||||
|
||||
await dispatchPreparedSlackMessage(
|
||||
createPreparedSlackMessage({
|
||||
accountConfig: { streaming: { mode: "progress", progress: { label: "Working" } } },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||||
expectDeliverReplyCall(0, FINAL_REPLY_TEXT);
|
||||
// A card left in its Working state would misrepresent a finished turn; the
|
||||
// failed terminalization must drop it instead of leaving it stranded.
|
||||
expect(finalizeSlackPreviewEditMock).toHaveBeenCalledTimes(1);
|
||||
expect(draftStream.clear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps plan explanation in the session card with a fresh preamble", async () => {
|
||||
const draftStream = createDraftStreamStub();
|
||||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||||
mockedSlackStreamingMode = "progress";
|
||||
@@ -2303,7 +2328,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
await dispatchPreparedSlackMessage(
|
||||
createPreparedSlackMessage({
|
||||
accountConfig: {
|
||||
streaming: { mode: "progress", progress: { label: "Shelling", render: "rich" } },
|
||||
streaming: { mode: "progress", progress: { label: "Shelling" } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -2313,7 +2338,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
blocks: [
|
||||
{
|
||||
type: "section",
|
||||
text: { type: "mrkdwn", text: "*Shelling*" },
|
||||
text: { type: "mrkdwn", text: "🔄 *Shelling*" },
|
||||
},
|
||||
{
|
||||
type: "section",
|
||||
@@ -2326,11 +2351,15 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
type: "section",
|
||||
text: { type: "mrkdwn", text: "▸ Patch" },
|
||||
},
|
||||
{
|
||||
type: "context",
|
||||
elements: [{ type: "mrkdwn", text: "⏱ 1s" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps unlabeled rich Slack progress drafts as legacy section blocks", async () => {
|
||||
it("uses the default card title when no Slack progress label is configured", async () => {
|
||||
const draftStream = createDraftStreamStub();
|
||||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||||
finalizeSlackPreviewEditMock.mockResolvedValueOnce(undefined);
|
||||
@@ -2345,38 +2374,25 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
|
||||
await dispatchPreparedSlackMessage(
|
||||
createPreparedSlackMessage({
|
||||
accountConfig: { streaming: { progress: { render: "rich" } } },
|
||||
accountConfig: { streaming: { mode: "progress" } },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(draftStream.update).toHaveBeenLastCalledWith({
|
||||
text: ["Working", "", "• 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(draftStream.update).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
blocks: expect.arrayContaining([
|
||||
{ type: "section", text: { type: "mrkdwn", text: "🔄 *Working*" } },
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(finalizeSlackPreviewEditMock).toHaveBeenCalledTimes(1);
|
||||
expect(deliverRepliesMock).not.toHaveBeenCalled();
|
||||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("replaces the progress draft with the final answer without posting a receipt", async () => {
|
||||
it("delivers the final answer separately from the progress draft", async () => {
|
||||
const draftStream = createDraftStreamStub();
|
||||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||||
finalizeSlackPreviewEditMock.mockResolvedValueOnce(undefined);
|
||||
mockedReplyThreadTsSequence = [undefined];
|
||||
mockedSlackStreamingMode = "progress";
|
||||
mockedSlackDraftMode = "status_final";
|
||||
mockedDispatchSequence = [{ kind: "final", payload: { text: FINAL_REPLY_TEXT } }];
|
||||
@@ -2396,12 +2412,18 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(deliverRepliesMock).not.toHaveBeenCalled();
|
||||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||||
expectDeliverReplyCall(0, FINAL_REPLY_TEXT);
|
||||
expectMockCallArgFields(finalizeSlackPreviewEditMock, 0, "progress final edit", {
|
||||
channelId: "C123",
|
||||
messageId: "171234.567",
|
||||
text: FINAL_REPLY_TEXT,
|
||||
});
|
||||
const finalEdit = requireRecord(
|
||||
requireMockCall(finalizeSlackPreviewEditMock, 0, "progress final edit")[0],
|
||||
"progress final edit",
|
||||
);
|
||||
expect(finalEdit.text).not.toBe(FINAL_REPLY_TEXT);
|
||||
expect(JSON.stringify(finalEdit.blocks)).not.toContain("Open in OpenClaw");
|
||||
expect(finalizeSlackPreviewEditMock).toHaveBeenCalledTimes(1);
|
||||
expect(draftStream.clear).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -2414,6 +2436,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
async ({ finalText }) => {
|
||||
const draftStream = createDraftStreamStub();
|
||||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||||
finalizeSlackPreviewEditMock.mockResolvedValueOnce(undefined);
|
||||
mockedSlackStreamingMode = "progress";
|
||||
mockedSlackDraftMode = "status_final";
|
||||
mockedDispatchSequence = [{ kind: "final", payload: { text: finalText } }];
|
||||
@@ -2437,14 +2460,14 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(finalizeSlackPreviewEditMock).not.toHaveBeenCalled();
|
||||
expect(finalizeSlackPreviewEditMock).toHaveBeenCalledTimes(1);
|
||||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||||
expectDeliverReplyCall(0, finalText);
|
||||
expect(draftStream.clear).toHaveBeenCalledTimes(1);
|
||||
expect(draftStream.clear).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("retains the progress draft when both the final edit and fallback send fail", async () => {
|
||||
it("terminalizes the progress card as failed when final delivery fails", async () => {
|
||||
const draftStream = createDraftStreamStub();
|
||||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||||
deliverRepliesMock.mockRejectedValueOnce(new Error("final send failed"));
|
||||
@@ -2464,14 +2487,19 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
expect(draftStream.update).toHaveBeenCalled();
|
||||
expectMockCallArgFields(finalizeSlackPreviewEditMock, 0, "progress final edit", {
|
||||
messageId: "171234.567",
|
||||
text: FINAL_REPLY_TEXT,
|
||||
});
|
||||
const finalEdit = requireRecord(
|
||||
requireMockCall(finalizeSlackPreviewEditMock, 0, "failed progress card edit")[0],
|
||||
"failed progress card edit",
|
||||
);
|
||||
expect(JSON.stringify(finalEdit.blocks)).toContain("❌ *Working*");
|
||||
expect(draftStream.clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replaces a progress draft with an error final without creating a receipt", async () => {
|
||||
it("keeps and terminalizes the progress card when the final reply is an error", async () => {
|
||||
const draftStream = createDraftStreamStub();
|
||||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||||
finalizeSlackPreviewEditMock.mockResolvedValueOnce(undefined);
|
||||
mockedSlackStreamingMode = "progress";
|
||||
mockedSlackDraftMode = "status_final";
|
||||
mockedDispatchSequence = [{ kind: "final", payload: { text: "tool failed", isError: true } }];
|
||||
@@ -2484,8 +2512,70 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
);
|
||||
|
||||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||||
expect(finalizeSlackPreviewEditMock).toHaveBeenCalledTimes(1);
|
||||
const finalEdit = requireRecord(
|
||||
requireMockCall(finalizeSlackPreviewEditMock, 0, "error session card edit")[0],
|
||||
"error session card edit",
|
||||
);
|
||||
expect(JSON.stringify(finalEdit.blocks)).toContain("❌ *Working*");
|
||||
expect(draftStream.clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("terminalizes the progress card on a dispatch error", async () => {
|
||||
const draftStream = createDraftStreamStub();
|
||||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||||
mockedSlackStreamingMode = "progress";
|
||||
mockedSlackDraftMode = "status_final";
|
||||
mockedDispatchSequence = [];
|
||||
mockedReplyOptionEvents = [{ kind: "item", progressText: "working" }];
|
||||
mockedDispatchError = new Error("agent dispatch failed");
|
||||
|
||||
await expect(
|
||||
dispatchPreparedSlackMessage(
|
||||
createPreparedSlackMessage({
|
||||
accountConfig: { streaming: { mode: "progress", progress: { label: "Working" } } },
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow("agent dispatch failed");
|
||||
|
||||
const finalEdit = requireRecord(
|
||||
requireMockCall(finalizeSlackPreviewEditMock, 0, "dispatch error card edit")[0],
|
||||
"dispatch error card edit",
|
||||
);
|
||||
expect(JSON.stringify(finalEdit.blocks)).toContain("❌ *Working*");
|
||||
expect(draftStream.clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps a failed no-reply card but deletes a silent successful card", async () => {
|
||||
const failedDraft = createDraftStreamStub();
|
||||
const silentDraft = createDraftStreamStub();
|
||||
createSlackDraftStreamMock.mockReturnValueOnce(failedDraft).mockReturnValueOnce(silentDraft);
|
||||
finalizeSlackPreviewEditMock.mockResolvedValue(undefined);
|
||||
mockedSlackStreamingMode = "progress";
|
||||
mockedSlackDraftMode = "status_final";
|
||||
mockedDispatchSequence = [];
|
||||
mockedReplyOptionEvents = [{ kind: "item", progressText: "working" }];
|
||||
mockedAgentRunTerminalOutcome = "failed";
|
||||
|
||||
await dispatchPreparedSlackMessage(
|
||||
createPreparedSlackMessage({
|
||||
accountConfig: { streaming: { mode: "progress", progress: { label: "Working" } } },
|
||||
}),
|
||||
);
|
||||
expect(failedDraft.clear).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(finalizeSlackPreviewEditMock.mock.calls[0]?.[0]?.blocks)).toContain(
|
||||
"❌ *Working*",
|
||||
);
|
||||
|
||||
finalizeSlackPreviewEditMock.mockClear();
|
||||
mockedAgentRunTerminalOutcome = "completed";
|
||||
await dispatchPreparedSlackMessage(
|
||||
createPreparedSlackMessage({
|
||||
accountConfig: { streaming: { mode: "progress", progress: { label: "Working" } } },
|
||||
}),
|
||||
);
|
||||
expect(silentDraft.clear).toHaveBeenCalledTimes(1);
|
||||
expect(finalizeSlackPreviewEditMock).not.toHaveBeenCalled();
|
||||
expect(draftStream.clear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("mandatory E2E: streams native Slack progress with the newest meaningful plan title when no explicit label exists", async () => {
|
||||
@@ -3676,54 +3766,6 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves the last rich Slack progress lines after a draft boundary status update", async () => {
|
||||
const draftStream = createDraftStreamStub();
|
||||
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).not.toHaveBeenCalled();
|
||||
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("preserves text Slack progress lines after a draft boundary status update", async () => {
|
||||
const draftStream = createDraftStreamStub();
|
||||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||||
@@ -3744,9 +3786,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
);
|
||||
|
||||
expect(draftStream.forceNewMessage).not.toHaveBeenCalled();
|
||||
expect(draftStream.update).toHaveBeenLastCalledWith(
|
||||
["Working", "", "• tool one", "• tool two"].join("\n"),
|
||||
);
|
||||
expectLastDraftUpdateText(draftStream, ["Working", "", "• tool one", "• tool two"].join("\n"));
|
||||
});
|
||||
|
||||
it("re-arms an isolated progress draft on an assistant boundary after final delivery", async () => {
|
||||
@@ -3766,7 +3806,10 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
await requireCapturedItemEventHandler()({ progressText: "second turn" });
|
||||
|
||||
expect(draftStream.forceNewMessage).toHaveBeenCalledTimes(1);
|
||||
expect(draftStream.update).toHaveBeenLastCalledWith("Working\n\n• second turn");
|
||||
expect(finalizeSlackPreviewEditMock.mock.invocationCallOrder.at(-1)).toBeLessThan(
|
||||
draftStream.forceNewMessage.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
|
||||
);
|
||||
expectLastDraftUpdateText(draftStream, "Working\n\n• second turn");
|
||||
});
|
||||
|
||||
it("re-arms an isolated progress draft when a queued followup is admitted", async () => {
|
||||
@@ -3786,7 +3829,36 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
await requireCapturedItemEventHandler()({ progressText: "queued turn" });
|
||||
|
||||
expect(draftStream.forceNewMessage).toHaveBeenCalledTimes(1);
|
||||
expect(draftStream.update).toHaveBeenLastCalledWith("Working\n\n• queued turn");
|
||||
expect(finalizeSlackPreviewEditMock.mock.invocationCallOrder.at(-1)).toBeLessThan(
|
||||
draftStream.forceNewMessage.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
|
||||
);
|
||||
expectLastDraftUpdateText(draftStream, "Working\n\n• queued turn");
|
||||
});
|
||||
|
||||
it("finalizes a queued turn card before rotating to the admitted followup", async () => {
|
||||
const draftStream = createDraftStreamStub();
|
||||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||||
finalizeSlackPreviewEditMock.mockResolvedValue(undefined);
|
||||
mockedSlackStreamingMode = "progress";
|
||||
mockedSlackDraftMode = "status_final";
|
||||
mockedReplyOptionEvents = [{ kind: "item", progressText: "first turn" }];
|
||||
mockedDispatchSequence = [
|
||||
{ kind: "queued_followup" },
|
||||
{ kind: "item", progressText: "queued turn" },
|
||||
{ kind: "final", payload: { text: "queued answer" } },
|
||||
];
|
||||
|
||||
await dispatchPreparedSlackMessage(
|
||||
createPreparedSlackMessage({
|
||||
accountConfig: { streaming: { mode: "progress", progress: { label: "Working" } } },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(finalizeSlackPreviewEditMock).toHaveBeenCalledTimes(2);
|
||||
expect(finalizeSlackPreviewEditMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
draftStream.forceNewMessage.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
|
||||
);
|
||||
expectDeliverReplyCall(0, "queued answer");
|
||||
});
|
||||
|
||||
it("re-arms queued progress after a silent turn without a final delivery", async () => {
|
||||
@@ -3806,7 +3878,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
await requireCapturedItemEventHandler()({ progressText: "queued turn" });
|
||||
|
||||
expect(draftStream.forceNewMessage).toHaveBeenCalledTimes(1);
|
||||
expect(draftStream.update).toHaveBeenLastCalledWith("Working\n\n• queued turn");
|
||||
expectLastDraftUpdateText(draftStream, "Working\n\n• queued turn");
|
||||
});
|
||||
|
||||
it("forces a new draft message on assistant boundaries in partial mode", async () => {
|
||||
@@ -3865,8 +3937,8 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n\n🛠️ Exec\n• done");
|
||||
expect(draftStream.update.mock.calls.flat().join("\n")).not.toContain("pnpm test");
|
||||
expect(draftUpdateTexts(draftStream)).toContain("Shelling\n\n🛠️ Exec\n• done");
|
||||
expect(draftUpdateTexts(draftStream).join("\n")).not.toContain("pnpm test");
|
||||
});
|
||||
|
||||
it("preserves command output text when raw Slack progress is configured", async () => {
|
||||
@@ -3896,7 +3968,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(draftStream.update.mock.calls.flat().join("\n")).toContain("pnpm test -- --watch=false");
|
||||
expect(draftUpdateTexts(draftStream).join("\n")).toContain("pnpm test -- --watch=false");
|
||||
});
|
||||
|
||||
it("suppresses standalone Slack tool progress when progress lines are disabled", async () => {
|
||||
@@ -3955,8 +4027,8 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
|
||||
expect(capturedReplyOptions?.commentaryProgressEnabled).toBe(true);
|
||||
expect(capturedReplyOptions?.suppressDefaultToolProgressMessages).toBe(true);
|
||||
expect(draftStream.update).toHaveBeenLastCalledWith("_Preparing the smallest fix_");
|
||||
expect(draftStream.update.mock.calls.flat().join("\n")).not.toContain("pnpm test");
|
||||
expectLastDraftUpdateText(draftStream, "_Preparing the smallest fix_");
|
||||
expect(draftUpdateTexts(draftStream).join("\n")).not.toContain("pnpm test");
|
||||
|
||||
const updateCount = draftStream.update.mock.calls.length;
|
||||
capturedReplyOptions?.onVerboseProgressVisibility?.(() => true);
|
||||
@@ -3994,9 +4066,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(draftStream.update).toHaveBeenLastCalledWith(
|
||||
"_I’m using the `monorepo` skill on Linux x86\\_64._",
|
||||
);
|
||||
expectLastDraftUpdateText(draftStream, "_I’m using the `monorepo` skill on Linux x86\\_64._");
|
||||
});
|
||||
|
||||
it("escapes Slack mentions and formatting in commentary without losing outer italics or inline code", async () => {
|
||||
@@ -4026,12 +4096,13 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(draftStream.update).toHaveBeenLastCalledWith(
|
||||
expectLastDraftUpdateText(
|
||||
draftStream,
|
||||
"_checking <@U123> in <#C123> and <!channel> with \\*urgent\\* \\_context\\_ `src/one.ts`_",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the full latest preamble and turns the same Slack message into the final answer", async () => {
|
||||
it("keeps the full latest preamble in the card and posts the final answer separately", async () => {
|
||||
const draftStream = createDraftStreamStub();
|
||||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||||
finalizeSlackPreviewEditMock.mockResolvedValueOnce(undefined);
|
||||
@@ -4073,16 +4144,16 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(draftStream.update).toHaveBeenCalledWith(`_${firstPreamble}_`);
|
||||
expect(draftStream.update).toHaveBeenLastCalledWith(`_${latestPreamble}_`);
|
||||
expect(draftUpdateTexts(draftStream)).toContain(`_${firstPreamble}_`);
|
||||
expectLastDraftUpdateText(draftStream, `_${latestPreamble}_`);
|
||||
expect(finalizeSlackPreviewEditMock).toHaveBeenCalledTimes(1);
|
||||
expectMockCallArgFields(finalizeSlackPreviewEditMock, 0, "progress final edit", {
|
||||
channelId: "C123",
|
||||
messageId: "171234.567",
|
||||
text: FINAL_REPLY_TEXT,
|
||||
});
|
||||
expect(deliverRepliesMock).not.toHaveBeenCalled();
|
||||
expect(draftStream.update.mock.calls.flat().join("\n")).not.toMatch(/Working|💬|•|⏱️/u);
|
||||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||||
expectDeliverReplyCall(0, FINAL_REPLY_TEXT);
|
||||
expect(draftUpdateTexts(draftStream).join("\n")).not.toMatch(/Working|💬|•|⏱️/u);
|
||||
});
|
||||
|
||||
it("uses the enterprise event client for Slack commentary drafts", async () => {
|
||||
@@ -4128,7 +4199,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
expect(createSlackDraftStreamMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ eventScope }),
|
||||
);
|
||||
expect(draftStream.update).toHaveBeenLastCalledWith("_Using the scoped listener client_");
|
||||
expectLastDraftUpdateText(draftStream, "_Using the scoped listener client_");
|
||||
});
|
||||
|
||||
it("renders the latest Slack preamble as the status headline by default", async () => {
|
||||
@@ -4170,9 +4241,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
expect(capturedReplyOptions?.commentaryProgressEnabled).toBeUndefined();
|
||||
expect(capturedReplyOptions?.onVerboseProgressVisibility).toBeUndefined();
|
||||
expect(capturedReplyOptions?.progressPreambleEnabled).toBe(true);
|
||||
expect(draftStream.update).toHaveBeenLastCalledWith(
|
||||
"Keeping the released behavior\n\n• pnpm test",
|
||||
);
|
||||
expectLastDraftUpdateText(draftStream, "Keeping the released behavior\n\n• pnpm test");
|
||||
});
|
||||
|
||||
it("preserves Slack preamble previews outside progress mode", async () => {
|
||||
@@ -4257,7 +4326,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const updates = draftStream.update.mock.calls.flat().join("\n");
|
||||
const updates = draftUpdateTexts(draftStream).join("\n");
|
||||
expect(capturedReplyOptions?.commentaryProgressEnabled).toBeUndefined();
|
||||
expect(updates).toContain("pnpm test");
|
||||
expect(updates).toContain("Hidden commentary");
|
||||
@@ -4287,48 +4356,6 @@ 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();
|
||||
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";
|
||||
|
||||
@@ -111,6 +111,24 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (progress.useDraftProgressCard) {
|
||||
await delivery.deliverNormally({
|
||||
payload,
|
||||
kind: info.kind,
|
||||
forcedThreadTs: delivery.usedReplyThreadTs,
|
||||
});
|
||||
const finalized = await progress.finalizeDraftProgressCard(
|
||||
payload.isError === true ? "error" : "success",
|
||||
);
|
||||
// The final reply already landed separately. A card that could not be
|
||||
// terminalized would linger in its Working state and misrepresent an
|
||||
// in-progress turn, so drop it (mirrors the pre-card preview cleanup).
|
||||
if (!finalized) {
|
||||
await draftStream?.clear();
|
||||
}
|
||||
progress.progressDraft.markFinalReplyDelivered();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (progress.useNativeProgressStreaming) {
|
||||
await delivery.deliverNormally({
|
||||
@@ -498,7 +516,9 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
||||
dispatchError = err;
|
||||
} finally {
|
||||
progress.progressDraft.cancel();
|
||||
await draftStream?.discardPending();
|
||||
if (!progress.useDraftProgressCard) {
|
||||
await draftStream?.discardPending();
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -569,6 +589,10 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
||||
},
|
||||
);
|
||||
|
||||
if (dispatchError || agentRunFailed) {
|
||||
await progress.finalizeDraftProgressCard("error");
|
||||
}
|
||||
|
||||
if (statusReactionsEnabled) {
|
||||
if (dispatchError || agentRunFailed) {
|
||||
await statusReactions.setError();
|
||||
@@ -596,7 +620,11 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
||||
if (dispatchError) {
|
||||
throw toErrorObject(dispatchError, "Slack dispatch failed");
|
||||
}
|
||||
if (!anyReplyDelivered && !draftPreviewCommitted.value) {
|
||||
if (
|
||||
!anyReplyDelivered &&
|
||||
!draftPreviewCommitted.value &&
|
||||
!(agentRunFailed && progress.useDraftProgressCard)
|
||||
) {
|
||||
await draftStream?.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { formatChannelProgressDraftText } from "openclaw/plugin-sdk/channel-outbound";
|
||||
// Slack tests cover progress blocks plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildSlackProgressDraftBlocks,
|
||||
buildSlackProgressCardBlocks,
|
||||
buildSlackProgressStreamCompletionChunks,
|
||||
buildSlackProgressStreamStartChunks,
|
||||
buildSlackProgressStreamUpdateChunks,
|
||||
@@ -50,27 +49,6 @@ function contentTaskId(prefix: string) {
|
||||
return expect.stringMatching(new RegExp(`^${prefix}_[a-f0-9]{8}_1$`, "u"));
|
||||
}
|
||||
|
||||
function legacyHeadingBlock(text: string) {
|
||||
return {
|
||||
type: "section",
|
||||
text: { type: "mrkdwn", text },
|
||||
};
|
||||
}
|
||||
|
||||
function legacyLineBlock(title: string, detail: string) {
|
||||
return {
|
||||
type: "section",
|
||||
fields: [
|
||||
{ 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: unknown; title: string; status: string }) {
|
||||
expect(task).toEqual({
|
||||
type: "task_update",
|
||||
@@ -80,158 +58,96 @@ function expectTaskUpdate(task: unknown, fields: { id: unknown; title: string; s
|
||||
});
|
||||
}
|
||||
|
||||
describe("buildSlackProgressDraftBlocks", () => {
|
||||
it("keeps a typed checklist below Slack status draft text and work lines", () => {
|
||||
expect(
|
||||
formatChannelProgressDraftText({
|
||||
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
|
||||
lines: [toolLine("read the config")],
|
||||
narration: "Implementing the change.",
|
||||
plan: [
|
||||
{ step: "Inspect", status: "completed" },
|
||||
{ step: "Patch", status: "in_progress" },
|
||||
{ step: "Test", status: "pending" },
|
||||
],
|
||||
}),
|
||||
).toBe(
|
||||
"Shelling\n\nImplementing the change.\n\n🛠️ read the config\n✅ Inspect\n▸ Patch\n▢ Test",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps legacy rich draft rendering as section field blocks", () => {
|
||||
expect(
|
||||
buildSlackProgressDraftBlocks({
|
||||
label: "Shelling...",
|
||||
lines: [toolLine("run tests")],
|
||||
}),
|
||||
).toEqual([legacyHeadingBlock("*Shelling...*"), legacyLineBlock("🛠️ *Exec*", "run tests")]);
|
||||
});
|
||||
|
||||
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: [
|
||||
{
|
||||
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",
|
||||
},
|
||||
describe("buildSlackProgressCardBlocks", () => {
|
||||
it("renders the working card with narration, plan, one activity block, and live footer", () => {
|
||||
const blocks = buildSlackProgressCardBlocks({
|
||||
state: "working",
|
||||
title: "Implementing",
|
||||
narration: "Checking the workspace.",
|
||||
plan: [
|
||||
{ step: "Inspect", status: "completed" },
|
||||
{ step: "Patch", status: "in_progress" },
|
||||
],
|
||||
lines: [toolLine("run tests"), itemLine("prepare the workspace", "Preamble")],
|
||||
toolCalls: 3,
|
||||
elapsedSeconds: 12,
|
||||
diffStat: { files: 4, added: 2, removed: 1 },
|
||||
});
|
||||
|
||||
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: [
|
||||
{
|
||||
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");
|
||||
expectLegacyLineBlock(blocks?.[2], "• *Exec*", "command failed · exit 1");
|
||||
});
|
||||
|
||||
it("keeps newest rich progress lines when capping legacy draft blocks", () => {
|
||||
const blocksWithLabel = buildSlackProgressDraftBlocks({
|
||||
title: "Shelling...",
|
||||
lines: Array.from({ length: 60 }, (_value, index) => progressLine(index)),
|
||||
});
|
||||
expect(blocksWithLabel).toHaveLength(50);
|
||||
// The label block survives capping; tool lines yield the remaining budget.
|
||||
expect(JSON.stringify(blocksWithLabel?.[0])).toContain("Shelling...");
|
||||
expectLegacyLineBlock(blocksWithLabel?.[1], "🛠️ *Exec 11*", "run 11");
|
||||
expectLegacyLineBlock(blocksWithLabel?.at(-1), "🛠️ *Exec 59*", "run 59");
|
||||
|
||||
const blocksWithoutTitle = buildSlackProgressDraftBlocks({
|
||||
lines: Array.from({ length: 60 }, (_value, index) => progressLine(index)),
|
||||
});
|
||||
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("renders authored commentary Markdown in legacy rich draft details", () => {
|
||||
expect(
|
||||
buildSlackProgressDraftBlocks({
|
||||
lines: [
|
||||
{
|
||||
id: "commentary:preamble-1",
|
||||
kind: "item",
|
||||
label: "Commentary",
|
||||
text: "💬 Rendering the `sample-widget` fixture on **example.test**.",
|
||||
prefix: false,
|
||||
},
|
||||
{
|
||||
id: "reasoning",
|
||||
kind: "item",
|
||||
label: "Reasoning",
|
||||
text: "_Reading the Slack handler_",
|
||||
prefix: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
legacyLineBlock("• *Commentary*", "Rendering the `sample-widget` fixture on *example.test*."),
|
||||
legacyLineBlock("• *Reasoning*", "_Reading the Slack handler_"),
|
||||
expect(blocks).toEqual([
|
||||
{ type: "section", text: { type: "mrkdwn", text: "🔄 *Implementing*" } },
|
||||
{
|
||||
type: "section",
|
||||
text: { type: "mrkdwn", text: "_Checking the workspace._" },
|
||||
},
|
||||
{ type: "section", text: { type: "mrkdwn", text: "✅ Inspect\n▸ Patch" } },
|
||||
{
|
||||
type: "section",
|
||||
text: { type: "mrkdwn", text: "🛠️ *Exec* — run tests\n• *Preamble* — —" },
|
||||
},
|
||||
{
|
||||
type: "context",
|
||||
elements: [{ type: "mrkdwn", text: "🛠️ 3 tools · 📝 4 files +2 −1 · ⏱ 12s" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not emit legacy rich draft blocks when there are no lines or heading", () => {
|
||||
expect(
|
||||
buildSlackProgressDraftBlocks({
|
||||
lines: [],
|
||||
}),
|
||||
).toBeUndefined();
|
||||
it.each([
|
||||
{ state: "success" as const, icon: "✅" },
|
||||
{ state: "error" as const, icon: "❌" },
|
||||
])(
|
||||
"renders $state terminal cards and gates the session action on public URL",
|
||||
({ state, icon }) => {
|
||||
const blocks = buildSlackProgressCardBlocks({
|
||||
state,
|
||||
title: "Implementing",
|
||||
lines: [toolLine("run tests")],
|
||||
receiptSummary: "🛠️ 1 tool call · ⏱️ 8s",
|
||||
diffStat: { files: 2, added: 1, removed: 1 },
|
||||
sessionUrl: "https://team.openclaw.ai/openclaw/chat/main",
|
||||
});
|
||||
|
||||
expect(blocks[0]).toEqual({
|
||||
type: "section",
|
||||
text: { type: "mrkdwn", text: `${icon} *Implementing*` },
|
||||
});
|
||||
expect(blocks).toContainEqual({
|
||||
type: "context",
|
||||
elements: [{ type: "mrkdwn", text: "🛠️ 1 tool call · ⏱️ 8s · 📝 2 files +1 −1" }],
|
||||
});
|
||||
expect(blocks.at(-1)).toEqual({
|
||||
type: "actions",
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
action_id: "openclaw:session_link",
|
||||
text: { type: "plain_text", text: "Open in OpenClaw" },
|
||||
url: "https://team.openclaw.ai/openclaw/chat/main",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
buildSlackProgressCardBlocks({ state, title: "Implementing", lines: [] }),
|
||||
).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the newest activity rows inside one section and the Slack block budget", () => {
|
||||
const blocks = buildSlackProgressCardBlocks({
|
||||
state: "working",
|
||||
title: "Working",
|
||||
lines: Array.from({ length: 60 }, (_value, index) => progressLine(index)),
|
||||
elapsedSeconds: 1,
|
||||
});
|
||||
const activity = blocks.find(
|
||||
(block) => block.type === "section" && JSON.stringify(block).includes("Exec 59"),
|
||||
);
|
||||
|
||||
expect(blocks.length).toBeLessThanOrEqual(50);
|
||||
expect(activity).toBeDefined();
|
||||
expect(JSON.stringify(activity)).toContain("🛠️ *Exec 59* — run 59");
|
||||
expect(JSON.stringify(activity)).not.toContain("Exec 0");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -287,20 +203,6 @@ describe("native Slack progress stream chunks", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders the plan checklist in rich draft blocks", () => {
|
||||
const blocks = buildSlackProgressDraftBlocks({
|
||||
title: "Implementation",
|
||||
lines: [],
|
||||
plan: [
|
||||
{ step: "Inspect code", status: "completed" },
|
||||
{ step: "Run tests", status: "in_progress" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(JSON.stringify(blocks)).toContain("✅ Inspect code");
|
||||
expect(JSON.stringify(blocks)).toContain("▸ Run tests");
|
||||
});
|
||||
|
||||
it("terminalizes orphaned rows when a plan snapshot shrinks", () => {
|
||||
const first = reconcileSlackNativeTaskChunks({
|
||||
previousTasks: new Map(),
|
||||
|
||||
@@ -4,12 +4,14 @@ import type { AnyChunk } from "@slack/types";
|
||||
import type { Block, KnownBlock } from "@slack/web-api";
|
||||
import {
|
||||
type AgentPlanStep,
|
||||
type ChannelProgressDraftCompositorSnapshot,
|
||||
type ChannelProgressDraftLine,
|
||||
formatPlanChecklistLines,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { SLACK_MAX_BLOCKS } from "./blocks-input.js";
|
||||
import { normalizeSlackOutboundText } from "./format.js";
|
||||
import { escapeSlackMrkdwn } from "./monitor/mrkdwn.js";
|
||||
import { SLACK_SESSION_LINK_ACTION_ID } from "./reply-action-ids.js";
|
||||
import { truncateSlackText } from "./truncate.js";
|
||||
|
||||
const SLACK_PROGRESS_FIELD_MAX = 1800;
|
||||
@@ -254,15 +256,48 @@ function buildSlackProgressStreamChunks(params: {
|
||||
return chunks;
|
||||
}
|
||||
|
||||
export function buildSlackProgressDraftBlocks(params: {
|
||||
label?: string;
|
||||
title?: string;
|
||||
type SlackProgressCardState = "working" | "success" | "error";
|
||||
type SlackProgressDiffStat = NonNullable<ChannelProgressDraftCompositorSnapshot["diffStat"]>;
|
||||
|
||||
function formatDiffStat(diffStat: SlackProgressDiffStat | undefined): string | undefined {
|
||||
if (!diffStat || (diffStat.files === 0 && diffStat.added === 0 && diffStat.removed === 0)) {
|
||||
return undefined;
|
||||
}
|
||||
return [
|
||||
`📝 ${diffStat.files} files`,
|
||||
...(diffStat.added > 0 ? [`+${diffStat.added}`] : []),
|
||||
...(diffStat.removed > 0 ? [`−${diffStat.removed}`] : []),
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function buildActivityText(lines: readonly ChannelProgressDraftLine[], maxLineChars: number) {
|
||||
const rendered: string[] = [];
|
||||
let length = 0;
|
||||
for (const line of lines.slice(-SLACK_MAX_BLOCKS).toReversed()) {
|
||||
const row = `${legacyLineTitle(line)} — ${legacyLineDetail(line, maxLineChars)}`;
|
||||
const nextLength = length + row.length + (rendered.length > 0 ? 1 : 0);
|
||||
if (nextLength > SLACK_PROGRESS_FIELD_MAX) {
|
||||
break;
|
||||
}
|
||||
rendered.push(row);
|
||||
length = nextLength;
|
||||
}
|
||||
return rendered.toReversed().join("\n");
|
||||
}
|
||||
|
||||
export function buildSlackProgressCardBlocks(params: {
|
||||
state: SlackProgressCardState;
|
||||
title: string;
|
||||
lines: readonly ChannelProgressDraftLine[];
|
||||
plan?: readonly AgentPlanStep[];
|
||||
narration?: string;
|
||||
maxLineChars?: number;
|
||||
}): (Block | KnownBlock)[] | undefined {
|
||||
const label = params.label?.trim() || params.title?.trim();
|
||||
toolCalls?: number;
|
||||
elapsedSeconds?: number;
|
||||
diffStat?: SlackProgressDiffStat;
|
||||
receiptSummary?: string;
|
||||
sessionUrl?: string;
|
||||
}): (Block | KnownBlock)[] {
|
||||
const maxLineChars = resolveMaxLineChars(
|
||||
params.maxLineChars,
|
||||
DEFAULT_SLACK_PROGRESS_DETAIL_MAX_CHARS,
|
||||
@@ -272,18 +307,21 @@ export function buildSlackProgressDraftBlocks(params: {
|
||||
maxLineChars,
|
||||
});
|
||||
const narration = params.narration?.replace(/\s+/g, " ").trim();
|
||||
// Status blocks (label, narration, checklist) take priority over rolling
|
||||
// tool lines inside Slack's 50-block budget; the tail slice would otherwise
|
||||
// silently drop the checklist first.
|
||||
const headBlocks: (Block | KnownBlock)[] = [
|
||||
...(label
|
||||
? [
|
||||
{
|
||||
type: "section" as const,
|
||||
text: field(`*${escapeSlackMrkdwn(label)}*`),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
const activityText = buildActivityText(params.lines, maxLineChars);
|
||||
const diffStat = formatDiffStat(params.diffStat);
|
||||
const workingFooter = [
|
||||
...(params.toolCalls && params.toolCalls > 0 ? [`🛠️ ${params.toolCalls} tools`] : []),
|
||||
...(diffStat ? [diffStat] : []),
|
||||
...(params.elapsedSeconds && params.elapsedSeconds > 0 ? [`⏱ ${params.elapsedSeconds}s`] : []),
|
||||
].join(" · ");
|
||||
const terminalFooter = [params.receiptSummary?.trim(), diffStat].filter(Boolean).join(" · ");
|
||||
const footer = params.state === "working" ? workingFooter : terminalFooter;
|
||||
const icon = params.state === "working" ? "🔄" : params.state === "success" ? "✅" : "❌";
|
||||
const blocks: (Block | KnownBlock)[] = [
|
||||
{
|
||||
type: "section" as const,
|
||||
text: field(`${icon} *${escapeSlackMrkdwn(params.title.trim() || "Working")}*`),
|
||||
},
|
||||
...(narration
|
||||
? [
|
||||
{
|
||||
@@ -300,16 +338,39 @@ export function buildSlackProgressDraftBlocks(params: {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
].slice(0, SLACK_MAX_BLOCKS);
|
||||
const lineBudget = Math.max(0, SLACK_MAX_BLOCKS - headBlocks.length);
|
||||
const renderedBlocks: (Block | KnownBlock)[] = [
|
||||
...headBlocks,
|
||||
...params.lines.slice(-lineBudget).map((line) => ({
|
||||
type: "section" as const,
|
||||
fields: [field(legacyLineTitle(line)), field(legacyLineDetail(line, maxLineChars))],
|
||||
})),
|
||||
...(activityText
|
||||
? [
|
||||
{
|
||||
type: "section" as const,
|
||||
text: field(activityText),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(footer
|
||||
? [
|
||||
{
|
||||
type: "context" as const,
|
||||
elements: [field(footer)],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(params.state !== "working" && params.sessionUrl
|
||||
? [
|
||||
{
|
||||
type: "actions" as const,
|
||||
elements: [
|
||||
{
|
||||
type: "button" as const,
|
||||
action_id: SLACK_SESSION_LINK_ACTION_ID,
|
||||
text: { type: "plain_text" as const, text: "Open in OpenClaw" },
|
||||
url: params.sessionUrl,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
return renderedBlocks.length ? renderedBlocks : undefined;
|
||||
return blocks.slice(0, SLACK_MAX_BLOCKS);
|
||||
}
|
||||
|
||||
export type SlackNativeTaskSnapshot = ReadonlyMap<
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Block, KnownBlock } from "@slack/web-api";
|
||||
|
||||
export const SLACK_REPLY_BUTTON_ACTION_ID = "openclaw:reply_button";
|
||||
export const SLACK_REPLY_LINK_ACTION_ID = "openclaw:reply_link";
|
||||
export const SLACK_SESSION_LINK_ACTION_ID = "openclaw:session_link";
|
||||
export const SLACK_REPLY_SELECT_ACTION_ID = "openclaw:reply_select";
|
||||
export const SLACK_CALLBACK_BUTTON_ACTION_ID = "openclaw:callback_button";
|
||||
export const SLACK_CALLBACK_SELECT_ACTION_ID = "openclaw:callback_select";
|
||||
|
||||
@@ -3,8 +3,16 @@ import { describe, expect, it } from "vitest";
|
||||
import { applyAppendOnlyStreamUpdate, resolveSlackStreamingConfig } from "./stream-mode.js";
|
||||
|
||||
describe("resolveSlackStreamingConfig", () => {
|
||||
it("defaults to partial mode with native streaming enabled", () => {
|
||||
it("defaults to progress mode with native streaming enabled", () => {
|
||||
expect(resolveSlackStreamingConfig({})).toEqual({
|
||||
mode: "progress",
|
||||
nativeStreaming: true,
|
||||
draftMode: "status_final",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps explicit partial mode on the replace draft path", () => {
|
||||
expect(resolveSlackStreamingConfig({ streaming: { mode: "partial" } })).toEqual({
|
||||
mode: "partial",
|
||||
nativeStreaming: true,
|
||||
draftMode: "replace",
|
||||
|
||||
@@ -82,7 +82,7 @@ export function resolveSlackStreamingMode(
|
||||
if (typeof params.streaming === "boolean") {
|
||||
return params.streaming ? "partial" : "off";
|
||||
}
|
||||
return "partial";
|
||||
return "progress";
|
||||
}
|
||||
|
||||
export function resolveSlackNativeStreaming(
|
||||
|
||||
@@ -274,7 +274,8 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env
|
||||
// +1: QQBot 2.0.1 operator-approval Gateway client compatibility export.
|
||||
// +2: narrow channel agent-run terminal reader and outcome contract.
|
||||
// +5: narrow string, record, and error coercion helpers.
|
||||
4878,
|
||||
// +1: normalized Gateway public origin resolver for plugin-generated links.
|
||||
4879,
|
||||
env,
|
||||
),
|
||||
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
|
||||
@@ -340,7 +341,8 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env
|
||||
// +1: QQBot 2.0.1 operator-approval Gateway client compatibility export.
|
||||
// +1: narrow channel agent-run terminal reader.
|
||||
// +5: narrow string, record, and error coercion helpers.
|
||||
2931,
|
||||
// +1: normalized Gateway public origin resolver for plugin-generated links.
|
||||
2932,
|
||||
env,
|
||||
),
|
||||
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(
|
||||
|
||||
@@ -33,6 +33,8 @@ describe("createChannelProgressDraftCompositor", () => {
|
||||
receipt.noteReasoning();
|
||||
now = 43_000;
|
||||
|
||||
expect(receipt.toolCalls).toBe(1);
|
||||
expect(receipt.elapsedSeconds).toBe(42);
|
||||
expect(receipt.buildSummaryLine()).toBe("🧠 2 thoughts · 💬 1 note · 🛠️ 1 tool call · ⏱️ 42s");
|
||||
|
||||
receipt.reset();
|
||||
@@ -963,6 +965,30 @@ describe("createChannelProgressDraftCompositor", () => {
|
||||
expect.objectContaining({ id: "command-1", kind: "command-output", status: "completed" }),
|
||||
expect.objectContaining({ id: "patch-1", kind: "patch", toolName: "apply_patch" }),
|
||||
]);
|
||||
expect(progress.getSnapshot().diffStat).toBeUndefined();
|
||||
});
|
||||
|
||||
it("wires successful mutation completions into the snapshot diff stat", async () => {
|
||||
const progress = createTestProgressDraftCompositor({
|
||||
entry: { streaming: { mode: "progress", progress: { label: "Working" } } },
|
||||
update: vi.fn(),
|
||||
updateOnLineChange: true,
|
||||
});
|
||||
|
||||
await progress.pushToolEvent({
|
||||
toolCallId: "write-1",
|
||||
name: "write",
|
||||
phase: "start",
|
||||
args: { path: "src/example.ts", content: "one\ntwo" },
|
||||
});
|
||||
expect(progress.getSnapshot().diffStat).toBeUndefined();
|
||||
await progress.pushItemEvent({
|
||||
toolCallId: "write-1",
|
||||
kind: "tool",
|
||||
phase: "end",
|
||||
status: "completed",
|
||||
});
|
||||
expect(progress.getSnapshot().diffStat).toEqual({ files: 1, added: 2, removed: 0 });
|
||||
});
|
||||
|
||||
it("ignores status updates once the final reply started and clears both per turn", async () => {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
createProgressDraftDiffStatTracker,
|
||||
type ChannelProgressDraftDiffStat,
|
||||
} from "./progress-draft-diffstat.js";
|
||||
import {
|
||||
createChannelProgressDraftEventHandlers,
|
||||
type ChannelProgressDraftEventLineBuilder,
|
||||
@@ -46,6 +50,7 @@ export type ChannelProgressDraftCompositorSnapshot = Readonly<{
|
||||
statusHeadline?: string;
|
||||
plan?: readonly AgentPlanStep[];
|
||||
planExplanation?: string;
|
||||
diffStat?: ChannelProgressDraftDiffStat;
|
||||
}>;
|
||||
|
||||
type ChannelProgressDraftUpdateOptions = {
|
||||
@@ -106,12 +111,14 @@ export function createChannelProgressDraftCompositor(params: {
|
||||
params.active &&
|
||||
resolveChannelStreamingSuppressDefaultToolProgressMessages(params.entry, {
|
||||
draftStreamActive: true,
|
||||
mode: params.mode,
|
||||
previewToolProgressEnabled,
|
||||
});
|
||||
let progressSuppressed = false;
|
||||
let lines: ChannelProgressDraftCompositorLine[] = [];
|
||||
let lastRenderedText = "";
|
||||
let lastRenderedLines = lines;
|
||||
let lastRenderedDiffStatKey = "";
|
||||
let reasoningRawText = "";
|
||||
let lastReasoningLine: string | undefined;
|
||||
// Id-less commentary streams as cumulative snapshots ("Checking" → "Checking
|
||||
@@ -129,6 +136,14 @@ export function createChannelProgressDraftCompositor(params: {
|
||||
let planExplanation = "";
|
||||
let finalReplyStarted = false;
|
||||
let finalReplyDelivered = false;
|
||||
const diffStatTracker = createProgressDraftDiffStatTracker({
|
||||
canStage: () =>
|
||||
params.active &&
|
||||
params.mode === "progress" &&
|
||||
!progressSuppressed &&
|
||||
!finalReplyStarted &&
|
||||
!finalReplyDelivered,
|
||||
});
|
||||
let preambleExpiryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let lastStartRendered = false;
|
||||
|
||||
@@ -174,13 +189,17 @@ export function createChannelProgressDraftCompositor(params: {
|
||||
});
|
||||
};
|
||||
|
||||
const resolveDiffStat = diffStatTracker.resolve;
|
||||
|
||||
const getSnapshot = (): ChannelProgressDraftCompositorSnapshot => {
|
||||
const statusHeadline = resolveStatusText();
|
||||
const diffStat = resolveDiffStat();
|
||||
return {
|
||||
lines: lines.map((line) => (typeof line === "string" ? line : { ...line })),
|
||||
...(statusHeadline ? { statusHeadline } : {}),
|
||||
...(planSteps ? { plan: planSteps.map((entry) => ({ ...entry })) } : {}),
|
||||
...(planExplanation ? { planExplanation } : {}),
|
||||
...(diffStat ? { diffStat } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -190,6 +209,7 @@ export function createChannelProgressDraftCompositor(params: {
|
||||
lines = [];
|
||||
lastRenderedText = "";
|
||||
lastRenderedLines = lines;
|
||||
lastRenderedDiffStatKey = "";
|
||||
reasoningRawText = "";
|
||||
lastReasoningLine = undefined;
|
||||
lastIdLessCommentaryId = undefined;
|
||||
@@ -200,13 +220,17 @@ export function createChannelProgressDraftCompositor(params: {
|
||||
narrationText = "";
|
||||
planSteps = undefined;
|
||||
planExplanation = "";
|
||||
diffStatTracker.reset();
|
||||
lastStartRendered = false;
|
||||
};
|
||||
|
||||
const publish = async (options?: { flush?: boolean }): Promise<boolean> => {
|
||||
const text = formatDraftText();
|
||||
const linesChanged = params.updateOnLineChange === true && lines !== lastRenderedLines;
|
||||
if (!text || (text === lastRenderedText && !linesChanged)) {
|
||||
const diffStatKey = JSON.stringify(resolveDiffStat() ?? null);
|
||||
const structuredStateChanged =
|
||||
params.updateOnLineChange === true &&
|
||||
(lines !== lastRenderedLines || diffStatKey !== lastRenderedDiffStatKey);
|
||||
if (!text || (text === lastRenderedText && !structuredStateChanged)) {
|
||||
return false;
|
||||
}
|
||||
const observed = await settleProgressVisibilityCallbackResult(
|
||||
@@ -218,6 +242,7 @@ export function createChannelProgressDraftCompositor(params: {
|
||||
// Only accepted renders become the dedupe baseline; pending sends remain retryable.
|
||||
lastRenderedText = text;
|
||||
lastRenderedLines = lines;
|
||||
lastRenderedDiffStatKey = diffStatKey;
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -337,7 +362,10 @@ export function createChannelProgressDraftCompositor(params: {
|
||||
: lines;
|
||||
const lineChanged = nextLines !== lines;
|
||||
const hasUnconfirmedRender = formatDraftText(nextLines) !== lastRenderedText;
|
||||
if (shouldStoreLine && !lineChanged && !hasUnconfirmedRender) {
|
||||
const diffStatChanged =
|
||||
params.updateOnLineChange === true &&
|
||||
JSON.stringify(resolveDiffStat() ?? null) !== lastRenderedDiffStatKey;
|
||||
if (shouldStoreLine && !lineChanged && !hasUnconfirmedRender && !diffStatChanged) {
|
||||
return false;
|
||||
}
|
||||
// A work line lands between reasoning bursts: commit the current thinking
|
||||
@@ -384,6 +412,8 @@ export function createChannelProgressDraftCompositor(params: {
|
||||
const progressEventHandlers = createChannelProgressDraftEventHandlers({
|
||||
entry: params.entry,
|
||||
pushLine: noteProgress,
|
||||
onTool: diffStatTracker.stageToolEvent,
|
||||
onItem: diffStatTracker.commitItemEvent,
|
||||
...(params.buildProgressEventLine ? { buildLine: params.buildProgressEventLine } : {}),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createProgressDraftDiffStatTracker } from "./progress-draft-diffstat.js";
|
||||
|
||||
type DiffStatTracker = ReturnType<typeof createProgressDraftDiffStatTracker>;
|
||||
|
||||
function stageMutation(
|
||||
tracker: DiffStatTracker,
|
||||
toolCallId: string,
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
phase = "start",
|
||||
) {
|
||||
tracker.stageToolEvent({ toolCallId, name, phase, args });
|
||||
}
|
||||
|
||||
function completeMutation(tracker: DiffStatTracker, toolCallId: string, status = "completed") {
|
||||
tracker.commitItemEvent({ toolCallId, phase: "end", status });
|
||||
}
|
||||
|
||||
describe("createProgressDraftDiffStatTracker", () => {
|
||||
it("stages starts and commits successful terminal items additively", () => {
|
||||
const tracker = createProgressDraftDiffStatTracker({ canStage: () => true });
|
||||
|
||||
stageMutation(tracker, "write-1", "write", {
|
||||
path: "src/example.ts",
|
||||
content: "one\ntwo",
|
||||
});
|
||||
expect(tracker.resolve()).toBeUndefined();
|
||||
stageMutation(
|
||||
tracker,
|
||||
"write-1",
|
||||
"write",
|
||||
{ path: "src/example.ts", content: "ignored\npartial\nargs" },
|
||||
"update",
|
||||
);
|
||||
expect(tracker.resolve()).toBeUndefined();
|
||||
|
||||
completeMutation(tracker, "write-1");
|
||||
expect(tracker.resolve()).toEqual({ files: 1, added: 2, removed: 0 });
|
||||
|
||||
stageMutation(tracker, "edit-1", "edit", {
|
||||
path: "src/example.ts",
|
||||
edits: [{ oldText: "one\ntwo", newText: "three" }],
|
||||
});
|
||||
completeMutation(tracker, "edit-1");
|
||||
expect(tracker.resolve()).toEqual({ files: 1, added: 3, removed: 2 });
|
||||
|
||||
for (const status of ["failed", "error"]) {
|
||||
const toolCallId = `failed-${status}`;
|
||||
stageMutation(tracker, toolCallId, "write", {
|
||||
path: `src/${toolCallId}.ts`,
|
||||
content: "ignored",
|
||||
});
|
||||
completeMutation(tracker, toolCallId, status);
|
||||
}
|
||||
expect(tracker.resolve()).toEqual({ files: 1, added: 3, removed: 2 });
|
||||
|
||||
stageMutation(tracker, "patch-1", "apply_patch", {
|
||||
input: [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: src/example.ts",
|
||||
"@@",
|
||||
"-three",
|
||||
"+four",
|
||||
"+five",
|
||||
"*** Add File: src/new.ts",
|
||||
"+new",
|
||||
"+line",
|
||||
"*** End Patch",
|
||||
].join("\n"),
|
||||
});
|
||||
completeMutation(tracker, "patch-1");
|
||||
expect(tracker.resolve()).toEqual({ files: 2, added: 7, removed: 3 });
|
||||
|
||||
stageMutation(tracker, "codex-patch-1", "apply_patch", {
|
||||
changes: [
|
||||
{ path: "src/example.ts", stat: { added: 7, removed: 3 } },
|
||||
{ path: "src/third.ts", stat: { added: 5, removed: 2 } },
|
||||
],
|
||||
});
|
||||
completeMutation(tracker, "codex-patch-1");
|
||||
expect(tracker.resolve()).toEqual({ files: 3, added: 19, removed: 8 });
|
||||
|
||||
stageMutation(tracker, "pending-reset", "write", {
|
||||
path: "src/pending-reset.ts",
|
||||
content: "pending",
|
||||
});
|
||||
tracker.reset();
|
||||
expect(tracker.resolve()).toBeUndefined();
|
||||
completeMutation(tracker, "pending-reset");
|
||||
expect(tracker.resolve()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("bounds pending staging and distinct committed file tracking", () => {
|
||||
const tracker = createProgressDraftDiffStatTracker({ canStage: () => true });
|
||||
|
||||
for (let index = 0; index < 65; index += 1) {
|
||||
stageMutation(tracker, `write-${index}`, "write", {
|
||||
path: `src/file-${index}.ts`,
|
||||
content: "line",
|
||||
});
|
||||
}
|
||||
expect(tracker.resolve()).toBeUndefined();
|
||||
for (let index = 0; index < 65; index += 1) {
|
||||
completeMutation(tracker, `write-${index}`);
|
||||
}
|
||||
expect(tracker.resolve()).toEqual({ files: 64, added: 64, removed: 0 });
|
||||
|
||||
for (let index = 64; index < 257; index += 1) {
|
||||
const toolCallId = `restaged-write-${index}`;
|
||||
stageMutation(tracker, toolCallId, "write", {
|
||||
path: `src/file-${index}.ts`,
|
||||
content: "line",
|
||||
});
|
||||
completeMutation(tracker, toolCallId);
|
||||
}
|
||||
expect(tracker.resolve()).toEqual({ files: 257, added: 257, removed: 0 });
|
||||
|
||||
stageMutation(tracker, "edit-known", "edit", {
|
||||
path: "src/file-0.ts",
|
||||
oldText: "one\ntwo",
|
||||
newText: "one\ntwo\nthree",
|
||||
});
|
||||
completeMutation(tracker, "edit-known");
|
||||
expect(tracker.resolve()).toEqual({ files: 257, added: 260, removed: 2 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { readCompletedFileMutationDelta } from "../agents/file-mutation-args.js";
|
||||
import { resolveFileMutationToolName } from "../agents/tool-mutation-names.js";
|
||||
|
||||
const MAX_TRACKED_MUTATION_FILES = 256;
|
||||
const MAX_PENDING_MUTATION_DIFFS = 64;
|
||||
|
||||
type PendingMutationDelta = NonNullable<ReturnType<typeof readCompletedFileMutationDelta>>;
|
||||
|
||||
export type ChannelProgressDraftDiffStat = Readonly<{
|
||||
files: number;
|
||||
added: number;
|
||||
removed: number;
|
||||
}>;
|
||||
|
||||
export function createProgressDraftDiffStatTracker(params: { canStage: () => boolean }) {
|
||||
let hasCommittedDiff = false;
|
||||
let mutationFiles = new Set<string>();
|
||||
let mutationOverflowFiles = 0;
|
||||
let mutationAdded = 0;
|
||||
let mutationRemoved = 0;
|
||||
let pendingMutationDiffs = new Map<string, PendingMutationDelta>();
|
||||
|
||||
const reset = () => {
|
||||
hasCommittedDiff = false;
|
||||
mutationFiles = new Set();
|
||||
mutationOverflowFiles = 0;
|
||||
mutationAdded = 0;
|
||||
mutationRemoved = 0;
|
||||
pendingMutationDiffs = new Map();
|
||||
};
|
||||
|
||||
const stageToolEvent = (payload: {
|
||||
toolCallId?: string;
|
||||
name?: string;
|
||||
phase?: string;
|
||||
args?: Record<string, unknown>;
|
||||
}) => {
|
||||
if (!params.canStage()) {
|
||||
return;
|
||||
}
|
||||
const toolCallId = payload.toolCallId?.trim();
|
||||
if (payload.phase !== "start" || !toolCallId || !payload.name || !payload.args) {
|
||||
return;
|
||||
}
|
||||
const kind = resolveFileMutationToolName(payload.name);
|
||||
const delta = kind ? readCompletedFileMutationDelta(kind, payload.args) : undefined;
|
||||
if (!delta) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!pendingMutationDiffs.has(toolCallId) &&
|
||||
pendingMutationDiffs.size >= MAX_PENDING_MUTATION_DIFFS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
pendingMutationDiffs.set(toolCallId, delta);
|
||||
};
|
||||
|
||||
const commitItemEvent = (payload: { toolCallId?: string; phase?: string; status?: string }) => {
|
||||
const toolCallId = payload.toolCallId?.trim();
|
||||
if (!toolCallId || payload.phase !== "end") {
|
||||
return;
|
||||
}
|
||||
const delta = pendingMutationDiffs.get(toolCallId);
|
||||
if (!delta) {
|
||||
return;
|
||||
}
|
||||
pendingMutationDiffs.delete(toolCallId);
|
||||
const status = payload.status?.trim().toLowerCase();
|
||||
if (status === "failed" || status === "error") {
|
||||
return;
|
||||
}
|
||||
hasCommittedDiff = true;
|
||||
mutationAdded += delta.added;
|
||||
mutationRemoved += delta.removed;
|
||||
for (const file of delta.files) {
|
||||
if (mutationFiles.has(file)) {
|
||||
continue;
|
||||
}
|
||||
if (mutationFiles.size < MAX_TRACKED_MUTATION_FILES) {
|
||||
mutationFiles.add(file);
|
||||
continue;
|
||||
}
|
||||
// Overflow keeps file-count memory bounded. Repeated paths beyond the
|
||||
// tracked window may count again, while line totals remain authoritative.
|
||||
mutationOverflowFiles += 1;
|
||||
}
|
||||
};
|
||||
|
||||
const resolve = (): ChannelProgressDraftDiffStat | undefined =>
|
||||
hasCommittedDiff
|
||||
? {
|
||||
files: mutationFiles.size + mutationOverflowFiles,
|
||||
added: mutationAdded,
|
||||
removed: mutationRemoved,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
stageToolEvent,
|
||||
commitItemEvent,
|
||||
resolve,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -22,6 +22,8 @@ export type ChannelProgressDraftEventLineBuilder = (
|
||||
export function createChannelProgressDraftEventHandlers(params: {
|
||||
entry: StreamingCompatEntry | null | undefined;
|
||||
buildLine?: ChannelProgressDraftEventLineBuilder;
|
||||
onTool?: (payload: ToolProgressPayload) => void;
|
||||
onItem?: (payload: ItemProgressPayload) => void;
|
||||
pushLine: (
|
||||
line: ChannelProgressDraftEventLine | undefined,
|
||||
options?: { toolName?: string; startImmediately?: boolean },
|
||||
@@ -41,10 +43,12 @@ export function createChannelProgressDraftEventHandlers(params: {
|
||||
return {
|
||||
pushToolEvent: (payload: ToolProgressPayload) => {
|
||||
const { detailMode, ...input } = payload;
|
||||
params.onTool?.(payload);
|
||||
return pushEvent({ event: "tool", ...input }, detailMode);
|
||||
},
|
||||
pushItemEvent: (payload: ItemProgressPayload) => {
|
||||
const { kind: itemKind, ...input } = payload;
|
||||
params.onItem?.(payload);
|
||||
return pushEvent({ event: "item", ...input, itemKind });
|
||||
},
|
||||
pushApprovalEvent: (payload: ProgressPayload<"approval">) => {
|
||||
|
||||
@@ -29,6 +29,8 @@ export function createChannelProgressReceiptTracker(params?: { now?: () => numbe
|
||||
lastCommentaryText = "";
|
||||
};
|
||||
|
||||
const elapsedSeconds = () => Math.max(1, Math.round((now() - startedAt) / 1000));
|
||||
|
||||
return {
|
||||
noteReasoning() {
|
||||
reasoningOpen = true;
|
||||
@@ -58,9 +60,15 @@ export function createChannelProgressReceiptTracker(params?: { now?: () => numbe
|
||||
}
|
||||
},
|
||||
reset,
|
||||
get toolCalls() {
|
||||
return toolCalls;
|
||||
},
|
||||
get elapsedSeconds() {
|
||||
return elapsedSeconds();
|
||||
},
|
||||
buildSummaryLine() {
|
||||
closeReasoning();
|
||||
const seconds = Math.max(1, Math.round((now() - startedAt) / 1000));
|
||||
const seconds = elapsedSeconds();
|
||||
return [
|
||||
...(reasoningSteps > 0
|
||||
? [`🧠 ${reasoningSteps} thought${reasoningSteps === 1 ? "" : "s"}`]
|
||||
|
||||
@@ -820,7 +820,7 @@ export function resolveChannelStreamingPreviewToolProgress(
|
||||
/**
|
||||
* The channel's resolved stream mode. Only the caller knows it: channels pick
|
||||
* their own default when `streaming.mode` is unset (Telegram uses "progress",
|
||||
* Discord uses "off", and Slack uses "partial"), and this helper has no
|
||||
* Discord uses "off", and Slack uses "progress"), and this helper has no
|
||||
* channel identity to guess with. Omitting it reads the configured mode and
|
||||
* treats unset as "partial".
|
||||
*/
|
||||
@@ -885,6 +885,7 @@ export function resolveChannelStreamingSuppressDefaultToolProgressMessages(
|
||||
entry: StreamingCompatEntry | null | undefined,
|
||||
options?: {
|
||||
draftStreamActive?: boolean;
|
||||
mode?: StreamingMode;
|
||||
previewToolProgressEnabled?: boolean;
|
||||
previewStreamingEnabled?: boolean;
|
||||
},
|
||||
@@ -892,7 +893,7 @@ export function resolveChannelStreamingSuppressDefaultToolProgressMessages(
|
||||
if (options?.draftStreamActive === false || options?.previewStreamingEnabled === false) {
|
||||
return false;
|
||||
}
|
||||
const mode = resolveChannelPreviewStreamMode(entry, "off");
|
||||
const mode = options?.mode ?? resolveChannelPreviewStreamMode(entry, "off");
|
||||
if (mode === "off") {
|
||||
return false;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,16 @@
|
||||
import type { OpenClawConfig } from "./types.js";
|
||||
|
||||
export function resolveGatewayPublicOrigin(
|
||||
config: Pick<OpenClawConfig, "gateway"> | null | undefined,
|
||||
): string | undefined {
|
||||
const raw = config?.gateway?.publicOrigin?.trim();
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
return parsed.pathname === "/" && !parsed.search && !parsed.hash ? parsed.origin : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -199,7 +199,7 @@ export const RUNTIME_FIELD_HELP: Record<string, string> = {
|
||||
"gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback":
|
||||
"DANGEROUS toggle that enables Host-header based origin fallback for Control UI/WebChat websocket checks. This mode is supported when your deployment intentionally relies on Host-header origin policy; explicit gateway.controlUi.allowedOrigins remains the recommended hardened default.",
|
||||
"gateway.publicOrigin":
|
||||
"Externally reachable HTTPS origin of the Gateway. HTTP is allowed only for localhost, 127.0.0.1, or [::1]. Per-requester MCP OAuth uses it to build the callback URL at /oauth/mcp/callback; configure the reverse proxy or tunnel to route that path to the Gateway.",
|
||||
"Externally reachable HTTPS origin of the Gateway. HTTP is allowed only for localhost, 127.0.0.1, or [::1]. Per-requester MCP OAuth uses it to build the callback URL at /oauth/mcp/callback; channel session links and plugin-generated viewer links use it to reach the Control UI and Gateway routes.",
|
||||
"mcp.apps":
|
||||
"MCP Apps UI support. When enabled, configured MCP servers may provide interactive HTML views for their tool results.",
|
||||
"mcp.apps.enabled":
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Focused public config shape types used by bundled and third-party plugins.
|
||||
|
||||
export { resolveGatewayPublicOrigin } from "../config/gateway-public-origin.js";
|
||||
|
||||
export type { ChannelGroupPolicy } from "../config/group-policy.js";
|
||||
export type { SessionScope } from "../config/sessions/types.js";
|
||||
export type {
|
||||
|
||||
Reference in New Issue
Block a user