fix(discord): hide internal Code Mode wait progress (#99893)

Hide invocation-specific Code Mode wait controls from channel progress while preserving telemetry and visible non-internal tools.
This commit is contained in:
Peter Steinberger
2026-07-04 01:45:50 -07:00
committed by GitHub
parent 3d6a2216ea
commit 3d404478b8
14 changed files with 335 additions and 5 deletions

View File

@@ -730,6 +730,65 @@ describe("agentLoop tool termination", () => {
};
}
it("marks lifecycle events from the concrete hidden tool instance", async () => {
let turn = 0;
const streamFn: StreamFn = () => {
turn += 1;
const stream = createAssistantMessageEventStream();
queueMicrotask(() => {
const message =
turn === 1
? makeAssistantMessage([
{ type: "toolCall", id: "call-wait", name: "wait", arguments: {} },
])
: makeAssistantMessage([{ type: "text", text: "done" }]);
stream.push({
type: "done",
reason: message.stopReason === "toolUse" ? "toolUse" : "stop",
message,
});
stream.end();
});
return stream;
};
const hiddenTool: AgentTool = {
...makeTool("wait", []),
hideFromChannelProgress: true,
execute: async (_toolCallId, _args, _signal, onUpdate) => {
onUpdate?.({
content: [{ type: "text", text: "still waiting" }],
details: { status: "waiting" },
});
return {
content: [{ type: "text", text: "resumed" }],
details: { status: "completed" },
};
},
};
const events = await collectEvents(
agentLoop(
[{ role: "user", content: "resume", timestamp: 1 }],
{ systemPrompt: "", messages: [], tools: [hiddenTool] },
{ ...config, toolExecution: "sequential" },
undefined,
streamFn,
),
);
const lifecycleEvents = events.filter((event) => event.type.startsWith("tool_execution_"));
expect(lifecycleEvents.map((event) => event.type)).toEqual([
"tool_execution_start",
"tool_execution_update",
"tool_execution_end",
]);
expect(
lifecycleEvents.every(
(event) => "hideFromChannelProgress" in event && event.hideFromChannelProgress === true,
),
).toBe(true);
});
it("continues after a side-effect tool result when afterToolCall records it without terminate", async () => {
const executed: string[] = [];
let turn = 0;

View File

@@ -605,6 +605,19 @@ type ResolvedToolCallOutcome =
| { kind: "resolved"; tool?: AgentTool }
| { kind: "error"; error: unknown };
function hidesToolCallFromChannelProgress(
context: AgentContext,
toolCall: AgentToolCall,
resolvedToolCalls: Map<AgentToolCall, ResolvedToolCallOutcome>,
): boolean {
const resolution = resolvedToolCalls.get(toolCall);
const tool =
resolution?.kind === "resolved"
? resolution.tool
: context.tools?.find((candidate) => candidate.name === toolCall.name);
return tool?.hideFromChannelProgress === true;
}
async function executeToolCallsSequential(
currentContext: AgentContext,
assistantMessage: AssistantMessage,
@@ -618,11 +631,17 @@ async function executeToolCallsSequential(
const messages: ToolResultMessage[] = [];
for (const toolCall of toolCalls) {
const hideFromChannelProgress = hidesToolCallFromChannelProgress(
currentContext,
toolCall,
resolvedToolCalls,
);
await emit({
type: "tool_execution_start",
toolCallId: toolCall.id,
toolName: toolCall.name,
args: toolCall.arguments,
...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
});
const preparation = await prepareToolCall(
@@ -640,6 +659,7 @@ async function executeToolCallsSequential(
result: preparation.result,
isError: preparation.isError,
executionStarted: false,
...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
};
} else {
const executed = await executePreparedToolCall(preparation, signal, emit);
@@ -682,11 +702,17 @@ async function executeToolCallsParallel(
const finalizedCalls: FinalizedToolCallEntry[] = [];
for (const toolCall of toolCalls) {
const hideFromChannelProgress = hidesToolCallFromChannelProgress(
currentContext,
toolCall,
resolvedToolCalls,
);
await emit({
type: "tool_execution_start",
toolCallId: toolCall.id,
toolName: toolCall.name,
args: toolCall.arguments,
...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
});
const preparation = await prepareToolCall(
@@ -703,6 +729,7 @@ async function executeToolCallsParallel(
result: preparation.result,
isError: preparation.isError,
executionStarted: false,
...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
} satisfies FinalizedToolCallOutcome;
await emitToolExecutionEnd(finalized, emit);
finalizedCalls.push(finalized);
@@ -769,6 +796,7 @@ type FinalizedToolCallOutcome = {
result: AgentToolResult<unknown>;
isError: boolean;
executionStarted: boolean;
hideFromChannelProgress?: boolean;
};
type FinalizedToolCallEntry = FinalizedToolCallOutcome | (() => Promise<FinalizedToolCallOutcome>);
@@ -947,6 +975,9 @@ async function executePreparedToolCall(
toolName: prepared.toolCall.name,
args: prepared.toolCall.arguments,
partialResult,
...(prepared.tool.hideFromChannelProgress === true
? { hideFromChannelProgress: true }
: {}),
}),
),
);
@@ -1006,6 +1037,7 @@ async function finalizeExecutedToolCall(
result,
isError,
executionStarted: true,
...(prepared.tool.hideFromChannelProgress === true ? { hideFromChannelProgress: true } : {}),
};
}
@@ -1027,6 +1059,7 @@ async function emitToolExecutionEnd(
result: finalized.result,
isError: finalized.isError,
executionStarted: finalized.executionStarted,
...(finalized.hideFromChannelProgress === true ? { hideFromChannelProgress: true } : {}),
});
}

View File

@@ -462,6 +462,8 @@ export interface AgentTool<
> extends Tool<TParameters> {
/** Human-readable label for UI display. */
label: string;
/** Preserve lifecycle telemetry without rendering transient channel progress. */
hideFromChannelProgress?: boolean;
/**
* Optional compatibility shim for raw tool-call arguments before schema validation.
* Must return an object that matches `TParameters`.
@@ -514,13 +516,20 @@ export type AgentEvent =
| { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent }
| { type: "message_end"; message: AgentMessage }
// Tool execution lifecycle
| { type: "tool_execution_start"; toolCallId: string; toolName: string; args: unknown }
| {
type: "tool_execution_start";
toolCallId: string;
toolName: string;
args: unknown;
hideFromChannelProgress?: boolean;
}
| {
type: "tool_execution_update";
toolCallId: string;
toolName: string;
args: unknown;
partialResult: unknown;
hideFromChannelProgress?: boolean;
}
| {
type: "tool_execution_end";
@@ -530,4 +539,5 @@ export type AgentEvent =
isError: boolean;
/** False when resolution, argument preparation, validation, or policy blocked execution. */
executionStarted?: boolean;
hideFromChannelProgress?: boolean;
};

View File

@@ -269,6 +269,13 @@ describe("Code Mode", () => {
expect(compacted.catalogToolCount).toBe(2);
});
it("marks only the internal wait control as hidden from channel progress", () => {
const { tools } = createCodeModeHarness();
expect(tools[0].hideFromChannelProgress).toBeUndefined();
expect(tools[1].hideFromChannelProgress).toBe(true);
});
it("tells models to return the final code value", () => {
const { config, catalogRef, tools: codeModeTools } = createCodeModeHarness();
const compacted = applyCodeModeCatalog({

View File

@@ -1172,6 +1172,7 @@ export function createCodeModeTools(ctx: CodeModeToolContext): AnyAgentTool[] {
const waitTool = markCodeModeControlTool({
name: CODE_MODE_WAIT_TOOL_NAME,
label: "wait",
hideFromChannelProgress: true,
description: "Resume a suspended OpenClaw code mode run returned by exec.",
parameters: Type.Object({
runId: Type.String({ description: "Code mode run id returned by exec." }),

View File

@@ -3756,6 +3756,9 @@ export async function runEmbeddedAttempt(
toolCallId: toolParams.toolCallId,
args: toolParams.input,
replaySafe: replaySafeTools.has(toolParams.tool as never),
hideFromChannelProgress:
"hideFromChannelProgress" in toolParams.tool &&
toolParams.tool.hideFromChannelProgress === true,
execute: async () =>
await toolParams.tool.execute(
toolParams.toolCallId,

View File

@@ -393,6 +393,68 @@ describe("handleToolExecutionStart read path checks", () => {
expect(ctx.state.toolMetaById.has("tool-callback-rejects")).toBe(true);
expect(warn).toHaveBeenCalledWith(expect.stringContaining("tool agent event callback failed"));
});
it("preserves hidden tool telemetry while marking its channel progress private", async () => {
const { ctx, onAgentEvent } = createTestContext();
await handleToolExecutionStart(ctx, {
type: "tool_execution_start",
toolName: "wait",
toolCallId: "tool-code-wait",
args: { runId: "cm_1" },
hideFromChannelProgress: true,
});
handleToolExecutionUpdate(ctx, {
type: "tool_execution_update",
toolName: "wait",
toolCallId: "tool-code-wait",
args: { runId: "cm_1" },
partialResult: { status: "waiting" },
hideFromChannelProgress: true,
});
await handleToolExecutionEnd(ctx, {
type: "tool_execution_end",
toolName: "wait",
toolCallId: "tool-code-wait",
isError: false,
result: { details: { status: "completed" } },
hideFromChannelProgress: true,
});
const lifecycleEvents = onAgentEvent.mock.calls
.map((call) => call[0] as CapturedAgentEvent)
.filter((event) => event.data?.name === "wait");
expect(lifecycleEvents).not.toHaveLength(0);
expect(lifecycleEvents.every((event) => event.data?.hideFromChannelProgress === true)).toBe(
true,
);
});
it("keeps an unmarked catalog tool named wait visible", async () => {
const { ctx, onAgentEvent } = createTestContext();
await handleToolExecutionStart(ctx, {
type: "tool_execution_start",
toolName: "wait",
toolCallId: "tool-catalog-wait",
args: {},
});
await handleToolExecutionEnd(ctx, {
type: "tool_execution_end",
toolName: "wait",
toolCallId: "tool-catalog-wait",
isError: false,
result: { details: { status: "completed" } },
});
const lifecycleEvents = onAgentEvent.mock.calls
.map((call) => call[0] as CapturedAgentEvent)
.filter((event) => event.data?.name === "wait");
expect(lifecycleEvents).not.toHaveLength(0);
expect(lifecycleEvents.every((event) => event.data?.hideFromChannelProgress !== true)).toBe(
true,
);
});
});
describe("handleToolExecutionEnd cron mutation tracking", () => {

View File

@@ -788,6 +788,7 @@ export function handleToolExecutionStart(
toolCallId: string;
args: unknown;
replaySafe?: boolean;
hideFromChannelProgress?: boolean;
},
): void | Promise<void> {
const continueAfterBlockReplyFlush = (): void | Promise<void> => {
@@ -804,6 +805,7 @@ export function handleToolExecutionStart(
const continueToolExecutionStart = () => {
const rawToolName = evt.toolName;
const toolName = normalizeToolName(rawToolName);
const hideFromChannelProgress = evt.hideFromChannelProgress === true;
const toolCallId = evt.toolCallId;
const args = evt.args;
const runId = ctx.params.runId;
@@ -911,6 +913,7 @@ export function handleToolExecutionStart(
name: toolName,
toolCallId,
args: sanitizeToolArgs(args) as Record<string, unknown>,
...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
},
});
const itemData: AgentItemEventData = {
@@ -923,6 +926,7 @@ export function handleToolExecutionStart(
meta,
toolCallId,
startedAt,
...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
};
emitTrackedItemEvent(ctx, itemData);
// Best-effort typing signal; do not block tool summaries on slow emitters.
@@ -933,6 +937,7 @@ export function handleToolExecutionStart(
name: toolName,
toolCallId,
args: sanitizeToolArgs(args) as Record<string, unknown>,
...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
},
});
@@ -1026,10 +1031,12 @@ export function handleToolExecutionUpdate(
toolName: string;
toolCallId: string;
partialResult?: unknown;
hideFromChannelProgress?: boolean;
},
) {
const toolName = normalizeToolName(evt.toolName);
const toolCallId = evt.toolCallId;
const hideFromChannelProgress = evt.hideFromChannelProgress === true;
const partial = evt.partialResult;
const sanitized = sanitizeToolResult(partial);
const isExecTool = isExecToolName(toolName);
@@ -1048,6 +1055,7 @@ export function handleToolExecutionUpdate(
name: toolName,
toolCallId,
partialResult: liveResult,
...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
},
});
}
@@ -1059,6 +1067,7 @@ export function handleToolExecutionUpdate(
status: "running",
name: toolName,
toolCallId,
...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
...(toolProgress
? { progressText: toolProgress.text }
: { meta: ctx.state.toolMetaById.get(toolCallId)?.meta }),
@@ -1071,6 +1080,7 @@ export function handleToolExecutionUpdate(
phase: "update",
name: toolName,
toolCallId,
...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
},
});
}
@@ -1118,6 +1128,7 @@ export async function handleToolExecutionEnd(
) {
const rawToolName = evt.toolName;
const toolName = normalizeToolName(rawToolName);
const hideFromChannelProgress = evt.hideFromChannelProgress === true;
const toolCallId = evt.toolCallId;
const runId = ctx.params.runId;
const isError = evt.isError;
@@ -1330,6 +1341,7 @@ export async function handleToolExecutionEnd(
meta,
isError: isToolError,
result: eventResult,
...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
},
});
const endedAt = Date.now();
@@ -1345,6 +1357,7 @@ export async function handleToolExecutionEnd(
toolCallId,
startedAt: startData?.startTime,
endedAt,
...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
...(isToolError && extractToolErrorMessage(sanitizedResult)
? { error: extractToolErrorMessage(sanitizedResult) }
: {}),
@@ -1358,6 +1371,7 @@ export async function handleToolExecutionEnd(
toolCallId,
meta,
isError: isToolError,
...(hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
},
});

View File

@@ -1350,6 +1350,7 @@ export function subscribeEmbeddedAgentSession(params: SubscribeEmbeddedAgentSess
toolCallId: string;
args: unknown;
replaySafe?: boolean;
hideFromChannelProgress?: boolean;
execute: () => Promise<T>;
}): Promise<T> => {
await handleToolExecutionStart(ctx, {
@@ -1358,6 +1359,7 @@ export function subscribeEmbeddedAgentSession(params: SubscribeEmbeddedAgentSess
toolCallId: toolParams.toolCallId,
args: toolParams.args,
replaySafe: toolParams.replaySafe,
hideFromChannelProgress: toolParams.hideFromChannelProgress,
} as never);
try {
const result = await toolParams.execute();
@@ -1368,6 +1370,7 @@ export function subscribeEmbeddedAgentSession(params: SubscribeEmbeddedAgentSess
isError: false,
executionStarted: true,
result,
hideFromChannelProgress: toolParams.hideFromChannelProgress,
} as never);
return result;
} catch (error) {
@@ -1378,6 +1381,7 @@ export function subscribeEmbeddedAgentSession(params: SubscribeEmbeddedAgentSess
isError: true,
executionStarted: true,
result: buildToolLifecycleErrorResult(error),
hideFromChannelProgress: toolParams.hideFromChannelProgress,
} as never);
throw error;
}

View File

@@ -4566,6 +4566,82 @@ describe("runAgentTurnWithFallback", () => {
});
});
it("hides internal lifecycle events while preserving visible tool progress", async () => {
const onItemEvent = vi.fn();
const onToolStart = vi.fn();
state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => {
await params.onAgentEvent?.({
stream: "tool",
data: {
name: "exec",
phase: "start",
args: { command: "pwd" },
},
});
await params.onAgentEvent?.({
stream: "item",
data: {
itemId: "tool:exec-1",
kind: "tool",
title: "exec pwd",
name: "exec",
phase: "start",
status: "running",
},
});
await params.onAgentEvent?.({
stream: "tool",
data: {
name: "wait",
phase: "start",
args: { runId: "ordinary_wait" },
},
});
await params.onAgentEvent?.({
stream: "tool",
data: {
name: "wait",
phase: "start",
args: { runId: "cm_1" },
hideFromChannelProgress: true,
},
});
await params.onAgentEvent?.({
stream: "item",
data: {
itemId: "tool:wait-1",
kind: "tool",
title: "wait",
name: "wait",
phase: "start",
status: "running",
hideFromChannelProgress: true,
},
});
return { payloads: [{ text: "final" }], meta: {} };
});
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
const result = await runAgentTurnWithFallback({
...createMinimalRunAgentTurnParams({
opts: { onItemEvent, onToolStart } satisfies GetReplyOptions,
}),
});
expect(result.kind).toBe("success");
expect(onToolStart).toHaveBeenCalledTimes(2);
expect(onToolStart).toHaveBeenCalledWith(
expect.objectContaining({ name: "exec", phase: "start" }),
);
expect(onToolStart).toHaveBeenCalledWith(
expect.objectContaining({ name: "wait", phase: "start" }),
);
expect(onItemEvent).toHaveBeenCalledTimes(1);
expect(onItemEvent).toHaveBeenCalledWith(
expect.objectContaining({ name: "exec", phase: "start" }),
);
});
it("forwards raw tool progress detail mode to tool-start reply options", async () => {
const onToolStart = vi.fn();
state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => {

View File

@@ -2697,7 +2697,7 @@ async function runAgentTurnWithFallbackInternal(
}
// Trigger typing when tools start executing.
// Must await to ensure typing indicator starts before tool summaries are emitted.
if (evt.stream === "tool") {
if (evt.stream === "tool" && evt.data.hideFromChannelProgress !== true) {
const phase = readStringValue(evt.data.phase) ?? "";
const name = readStringValue(evt.data.name);
const toolCallId = readStringValue(evt.data.toolCallId) ?? "";
@@ -2741,6 +2741,8 @@ async function runAgentTurnWithFallbackInternal(
evt.stream === "item" &&
evt.data.suppressChannelProgress === true &&
Boolean(params.opts?.onToolStart);
const hideItemFromChannelProgress =
evt.stream === "item" && evt.data.hideFromChannelProgress === true;
const itemPhase =
evt.stream === "item" ? readStringValue(evt.data.phase) : "";
const itemName =
@@ -2793,6 +2795,7 @@ async function runAgentTurnWithFallbackInternal(
}
if (
evt.stream === "item" &&
!hideItemFromChannelProgress &&
!suppressItemChannelProgress &&
(!suppressProgressAfterMessageToolDelivery ||
completedMessageToolDelivery)

View File

@@ -3165,6 +3165,60 @@ describe("createFollowupRunner progress forwarding", () => {
expect(onCommandOutput).not.toHaveBeenCalled();
});
it("keeps internal tool lifecycle events out of queued channel progress", async () => {
const onToolStart = vi.fn(async () => {});
const onItemEvent = vi.fn(async () => {});
runEmbeddedAgentMock.mockImplementationOnce(
async (args: {
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => Promise<void>;
}) => {
await args.onAgentEvent?.({
stream: "tool",
data: {
phase: "start",
name: "wait",
hideFromChannelProgress: true,
},
});
await args.onAgentEvent?.({
stream: "item",
data: {
phase: "start",
itemId: "tool:wait-1",
title: "wait",
hideFromChannelProgress: true,
},
});
return { payloads: [{ text: "final" }], meta: { agentMeta: {} } };
},
);
const runner = createFollowupRunner({
opts: {
allowToolLifecycleWhenProgressHidden: true,
onToolStart,
onItemEvent,
},
typing: createMockTypingController(),
typingMode: "instant",
defaultModel: "claude",
});
await runner(
createQueuedRun({
run: {
messageProvider: "discord",
sourceReplyDeliveryMode: "message_tool_only",
verboseLevel: "off",
},
}),
);
expect(onToolStart).not.toHaveBeenCalled();
expect(onItemEvent).not.toHaveBeenCalled();
});
it("keeps queued follow-up progress quiet when verbose state is missing", async () => {
const onToolStart = vi.fn(async () => {});
const onCommandOutput = vi.fn(async () => {});

View File

@@ -167,7 +167,7 @@ async function forwardFollowupProgressEvent(params: {
return;
}
if (evt.stream === "tool") {
if (evt.stream === "tool" && evt.data.hideFromChannelProgress !== true) {
const phase = readStringValue(evt.data.phase) ?? "";
const name = readStringValue(evt.data.name);
if (phase === "start" || phase === "update") {
@@ -193,7 +193,9 @@ async function forwardFollowupProgressEvent(params: {
evt.stream === "item" &&
evt.data.suppressChannelProgress === true &&
Boolean(opts?.onToolStart);
if (evt.stream === "item" && !suppressItemChannelProgress) {
const hideItemFromChannelProgress =
evt.stream === "item" && evt.data.hideFromChannelProgress === true;
if (evt.stream === "item" && !suppressItemChannelProgress && !hideItemFromChannelProgress) {
await opts?.onItemEvent?.({
itemId: readStringValue(evt.data.itemId),
toolCallId: readStringValue(evt.data.toolCallId),

View File

@@ -2,9 +2,9 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
import type { VerboseLevel } from "../auto-reply/thinking.js";
import { createAbortError } from "./abort-signal.js";
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
import { notifyListeners, registerListener } from "../shared/listeners.js";
import { createAbortError } from "./abort-signal.js";
/** Stream name for agent events delivered to gateway listeners and plugin host hooks. */
export type AgentEventStream =
@@ -51,6 +51,8 @@ export type AgentItemEventData = {
progressText?: string;
/** Preserve item telemetry while letting channel progress render a sibling tool event instead. */
suppressChannelProgress?: boolean;
/** Preserve activity telemetry without rendering this internal item in channel progress. */
hideFromChannelProgress?: boolean;
approvalId?: string;
approvalSlug?: string;
};