feat(discord): show per-tool glyphs on progress draft tool rows (#168032)

## What Problem This Solves

Discord progress drafts show tool-log rows without a per-tool glyph (`• Exec: running`), while Telegram shows `🛠️ Exec: running` since #167392. Discord already marks the 🧠 reasoning and 💬 commentary lanes, so its tool rows were the only unmarked lane.

## User Impact

Discord tool-log rows in progress drafts now start with the tool's text glyph (`🛠️ Exec`, `📖 Read`, `🩹 Apply Patch`). The 🧠 / 💬 lanes, plan rows, approval rows, and plain status rows are unchanged. No config changes. Slack and other channels keep plain rows.

## Why This Change Was Made

#167392 added the core opt-in (`toolIcons?: boolean` on `createChannelProgressDraftCompositor`, glyph table in `src/channels/progress-tool-glyphs.ts`) and left Discord as a follow-up. This PR sets `toolIcons: true` on every Discord compositor so all Discord paths render the same:

- live draft preview (`message-handler.draft-preview.ts`)
- retained progress continuation after handoff (`message-handler.progress-continuation.ts`)
- retained-card `edit` action rendering a progress snapshot (`runtime.messaging.messages.ts`)

No Discord-specific glyph code. Rows that already carry an explicit `icon` keep it.

## Tests

- `message-handler.draft-preview.rest.test.ts`: new regression through the real draft preview and Discord REST client: `🧠 _Planning the check_` / `💬 Looking at the workspace` / `🛠️ Exec: running` / `📖 Read: running`. Fails on main with `• Exec: running` / `• Read: running`.
- `message-bodies.test.ts`: a retained row's explicit icon (`🧪 Read`) wins over the tool glyph.
- `message-handler.process.draft-final.test.ts`: existing expectations updated to the glyph rows.

## Bounded cost

- One static `Record` lookup per core-built tool row on Discord (core opt-in from #167392).
- No new config keys, protocol fields, network calls, or extra Discord sends/edits; the draft text gains one glyph and a space per tool row, inside the existing per-message length bounds.
- Production: +3 lines. Tests: +65/−5. Docs: +3/−3. No extra Discord sends or edits in the runs above beyond the existing progress-draft cadence.

## Evidence

Isolated gateway (`HOME`/`OPENCLAW_STATE_DIR`/`OPENCLAW_CONFIG_PATH` under `/tmp`) with the real bundled Discord plugin against QA Lab's Crabline Discord provider server, `streaming.mode: "progress"`, `progress.toolProgress: true`. Rows below are the Discord REST bodies the plugin sent, from the Crabline provider recorder (`artifacts/crabline/discord-provider-server.jsonl`).

**Live model `openai/gpt-6-astra`** (`qa suite --provider-mode live-frontier --model openai/gpt-6-astra --channel-driver crabline --channel discord`), prompt asked for one exec then one read:

```
POST  "Working\n\n🛠️ Exec: running\n🛠️ Exec: running"
PATCH "Working\n\n🛠️ Exec: running\n🛠️ Exec"
PATCH "…\n\n🛠️ Exec\n🛠️ Exec\n🛠️ Exec: running"
PATCH "…\n\n🛠️ Exec\n🛠️ Exec\n🛠️ Exec\n📖 Read from glyph-proof.txt"
POST  final reply (separate message)
```

**Scripted model (`mock-openai`)**, commentary + one exec:

```
POST  "Working\n\n💬 SLACK-QA-COMMENTARY-200C8C6D"
PATCH "Working\n\n💬 SLACK-QA-COMMENTARY-200C8C6D\n🛠️ Exec: running"
PATCH "Working\n\n💬 SLACK-QA-COMMENTARY-200C8C6D\n🛠️ Exec"
POST  "SLACK-QA-COMMENTARY-DONE-200C8C6D"
```

The 💬 commentary lane keeps its prefix, and the final reply is one separate message, as before. Before this change, the same rows render as `• Exec: running` / `• Read: running` (the new REST regression fails on main with exactly that diff).
This commit is contained in:
Ayaan Zaidi 2026-10-09 17:26:50 -07:00 • committed by GitHub
parent 999bf5ecc5
commit cb9bdcd6e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 71 additions and 8 deletions

View file

@ -99,7 +99,7 @@ Messages in the same channel continue reaching the [reply queue](/concepts/queue
- `block` emits draft-sized chunks; tune size and breakpoints with `streaming.preview.chunk` (`minChars`, `maxChars`, `breakPreference`), clamped to `textChunkLimit`. An explicit non-`off` preview mode overrides inherited `agents.defaults.blockStreamingDefault: "on"`; explicit `streaming.block.enabled: true` overrides the preview. If a turn cannot use previews, inherited block delivery still applies.
- `progress` keeps one editable status draft until final delivery. By default it is quiet: the agent's latest preamble or narration as a status headline, 💬 commentary and 🧠 reasoning when they stream, ✅ / ▸ / ▢ plan steps, and any approval request or failed command. Ordinary tool calls do not add rows.
- Media, error, and explicit-reply finals cancel pending preview edits.
- `streaming.progress.toolProgress: true` adds the rolling tool log underneath the headline: rows such as `Bash: run tests` or `Web Search: for "query"` (default `false`). `streaming.preview.toolProgress` controls tool rows in `partial` and `block` modes, where they default to `true`.
- `streaming.progress.toolProgress: true` adds the rolling tool log underneath the headline: rows such as `🛠️ Exec: running` or `📖 Read from notes.txt` (default `false`). Each tool row starts with a text glyph for its tool. `streaming.preview.toolProgress` controls tool rows in `partial` and `block` modes, where they default to `true`.
- `streaming.progress.commentary` (default `false`) opts into raw assistant commentary in the temporary progress draft. The default preamble/narration status line is independent of this option. Commentary is cleaned before display, stays transient, and does not change final answer delivery.
- `streaming.progress.maxLineChars` controls the per-line progress preview budget. Prose is shortened on word boundaries; command and path details keep useful suffixes.
- `streaming.preview.commandText` / `streaming.progress.commandText` controls command/exec detail in compact progress lines: `status` (default, tool label only) or `raw` (explicit command text).

View file

@ -26,8 +26,8 @@ requests. On Discord and Telegram, it also keeps the current operation and
delegated task status visible without a detailed tool log or utility model.
Intermediate tool failures and nonzero command exits stay out of the draft.
Set `streaming.progress.toolProgress: true` to add a rolling tool log,
including tool failures, with rows such as `Bash: run tests`. Telegram
prefixes each tool row with a text glyph for its tool, such as
including tool failures, with rows such as `Bash: run tests`. Discord and
Telegram prefix each tool row with a text glyph for its tool, such as
`📖 Read: from docs/index.md`; other channels keep plain rows. Channel plugins opt in
through the `toolIcons` option of `createChannelProgressDraftCompositor`.

View file

@ -278,6 +278,29 @@ describe("Discord retained progress edits", () => {
expect(current.content.length).toBeLessThanOrEqual(100);
});
it("keeps a retained row's explicit icon over the tool glyph", async () => {
await discordMessageActions.handleAction?.({
channel: "discord",
action: "edit",
cfg: {
channels: {
discord: {
token,
groupPolicy: "open",
streaming: { progress: { label: false, toolProgress: true } },
},
},
},
params: { to: `channel:${channelId}`, messageId, message: "Generic fallback" },
progressSnapshot: {
lines: [{ kind: "tool", text: "Read", label: "Read", toolName: "read", icon: "🧪" }],
},
conversationReadOrigin: "direct-operator",
});
expect(current.content).toBe("🧪 Read");
});
it.each([true, false])(
"uses the current DM channel only for its captured user target (matching: %s)",
async (matching) => {

View file

@ -125,6 +125,7 @@ export async function handleDiscordMessageManagementAction(ctx: DiscordMessaging
active: true,
seed: `${ctx.accountId}:${channelId}`,
initialSnapshot: snapshot,
toolIcons: true,
}).getText(),
{
maxChars: Math.min(ctx.accountConfig.textChunkLimit ?? 2000, 2000),

View file

@ -213,6 +213,41 @@ describe("Discord draft preview REST lifecycle", () => {
}
});
it("prefixes tool rows with tool glyphs and keeps the reasoning and commentary lanes", async () => {
vi.useFakeTimers();
const { controller, visible } = createContinuationHarness({
discordConfig: {
streaming: {
mode: "progress",
progress: { label: false, toolProgress: true, commentary: true },
},
},
});
try {
await controller.pushReasoningProgress("Planning the check");
await controller.pushItemEvent({
itemId: "preamble-1",
kind: "preamble",
phase: "end",
progressText: "Looking at the workspace",
});
await controller.pushItemEvent(
projectAgentToolActivity({ toolCallId: "exec-1", name: "exec", phase: "start" }),
);
await controller.pushItemEvent(
projectAgentToolActivity({ toolCallId: "read-1", name: "read", phase: "start" }),
);
await vi.advanceTimersByTimeAsync(1_500);
await controller.flush();
expect([...visible.values()]).toEqual([
"🧠 _Planning the check_\n💬 Looking at the workspace\n🛠️ Exec: running\n📖 Read: running",
]);
} finally {
await controller.cleanup();
vi.useRealTimers();
}
});
it("keeps one confirmed card after yield and isolates a queued turn's cleanup", async () => {
vi.useFakeTimers();
const harness = createContinuationHarness({ discordConfig: {} });

View file

@ -125,6 +125,7 @@ export function createDiscordDraftPreviewController(params: {
seed: progressSeed,
reasoningLinePrefix: "🧠 ",
commentaryLinePrefix: "💬 ",
toolIcons: true,
commentaryItalics: false,
update: async (previewText, options) => {
if (!draftStream) {

View file

@ -209,7 +209,7 @@ describe("processDiscordMessage draft streaming final delivery", () => {
const updates = draftStream.update.mock.calls.map((call) => call[0]);
expect(updates).toContain(
"Reading the gateway config and restarting agents.\n\n• Exec: running",
"Reading the gateway config and restarting agents.\n\n🛠️ Exec: running",
);
expectFinalAnswerText("done");
});
@ -281,7 +281,7 @@ describe("processDiscordMessage draft streaming final delivery", () => {
await runProcessDiscordMessage(ctx);
expect(getLastDispatchReplyOptions()?.sourceReplyDeliveryMode).toBe("message_tool_only");
expect(draftStream.update).toHaveBeenCalledWith("Working\n\n• Exec: running\n• exec done", {
expect(draftStream.update).toHaveBeenCalledWith("Working\n\n🛠️ Exec: running\n• exec done", {
complete: true,
});
expect(deliverDiscordReply).not.toHaveBeenCalled();
@ -611,7 +611,9 @@ describe("processDiscordMessage draft streaming progress", () => {
await runProgressScenario({ toolProgress: true, label: "Investigating", commandText: "raw" });
expect(draftStream.retarget).toHaveBeenCalledWith("thread-1");
expect(draftStream.update).toHaveBeenLastCalledWith("Investigating\n\n• Checked the pipeline.");
expect(draftStream.update).toHaveBeenLastCalledWith(
"Investigating\n\n🛠️ Checked the pipeline.",
);
expect(draftStream.messageId()).toBeDefined();
expect(deliverDiscordReply).not.toHaveBeenCalled();
});
@ -764,7 +766,7 @@ describe("processDiscordMessage draft streaming reasoning", () => {
await runProgressScenario({ toolProgress: true, label: "Clawing..." });
expect(draftStream.update).toHaveBeenCalledExactlyOnceWith("Clawing...\n\n• Apply Patch", {
expect(draftStream.update).toHaveBeenCalledExactlyOnceWith("Clawing...\n\n🩹 Apply Patch", {
complete: true,
});
});
@ -786,7 +788,7 @@ describe("processDiscordMessage draft streaming reasoning", () => {
await runProgressScenario({ toolProgress: true, label: "Clawing..." });
expect(draftStream.update).toHaveBeenCalledWith("Clawing...\n\n• Exec: running\n• done", {
expect(draftStream.update).toHaveBeenCalledWith("Clawing...\n\n🛠️ Exec: running\n• done", {
complete: true,
});
expect(draftStream.update.mock.calls.map((call) => call[0]).join("\n")).not.toContain(

View file

@ -40,6 +40,7 @@ export function retainDiscordProgressDraft(params: {
active: true,
seed: params.seed,
reasoningGate: false,
toolIcons: true,
initialSnapshot: params.snapshot,
update: async (text, options) => {
if (retired) {