fix: Codex turns stop showing typing during tool work (#95844)

* fix: bridge harness execution phases to typing

* docs: clarify typing indicator activity triggers
This commit is contained in:
Josh Lehman
2026-06-24 11:50:09 -07:00
committed by GitHub
parent 9ad959a870
commit b6bc3ed0db
5 changed files with 155 additions and 12 deletions
+12 -11
View File
@@ -15,7 +15,8 @@ When `agents.defaults.typingMode` is **unset**, OpenClaw keeps the legacy behavi
- **Direct chats**: typing starts immediately once the model loop begins.
- **Group chats with a mention**: typing starts immediately.
- **Group chats without a mention**: typing starts only when message text begins streaming.
- **Group chats without a mention**: typing starts when the admitted run has
user-visible activity, such as harness execution activity or message text.
- **Heartbeat runs**: typing starts when the heartbeat run begins if the
resolved heartbeat target is a typing-capable chat and typing is not disabled.
@@ -26,13 +27,14 @@ Set `agents.defaults.typingMode` to one of:
- `never` - no typing indicator, ever.
- `instant` - start typing **as soon as the model loop begins**, even if the run
later returns only the silent reply token.
- `thinking` - start typing on the **first reasoning delta** (requires
`reasoningLevel: "stream"` for the run).
- `message` - start typing on the **first non-silent text delta** (ignores
the `NO_REPLY` silent token).
- `thinking` - start typing on the **first reasoning delta** or on active
harness execution after the turn is accepted.
- `message` - start typing on the **first user-visible reply activity**, such as
active harness execution or a non-silent text delta. Silent reply tokens such
as `NO_REPLY` do not count as text activity.
Order of "how early it fires":
`never``message``thinking``instant`
`never``message`/`thinking``instant`
## Configuration
@@ -62,11 +64,10 @@ Override mode or cadence per session:
## Notes
- `message` mode won't show typing for silent-only replies when the whole
payload is the exact silent token (for example `NO_REPLY` / `no_reply`,
matched case-insensitively).
- `thinking` only fires if the run streams reasoning (`reasoningLevel: "stream"`).
If the model doesn't emit reasoning deltas, typing won't start.
- `message` mode does not start from silent reply tokens, but active execution
can still show typing before any assistant text is available.
- `thinking` still reacts to streamed reasoning (`reasoningLevel: "stream"`),
and it can also start from active execution before reasoning deltas arrive.
- Heartbeat typing is a liveness signal for the resolved delivery target. It
starts at heartbeat run start instead of following `message` or `thinking`
stream timing. Set `typingMode: "never"` to disable it.
@@ -327,6 +327,31 @@ type FallbackRunnerParams = {
type EmbeddedAgentParams = {
lifecycleGeneration?: string;
onExecutionStarted?: (info?: { lifecycleGeneration?: string }) => void;
onExecutionPhase?: (info: {
phase:
| "runner_entered"
| "workspace"
| "runtime_plugins"
| "before_agent_reply"
| "model_resolution"
| "auth"
| "context_engine"
| "attempt_dispatch"
| "context_assembled"
| "turn_accepted"
| "process_spawned"
| "tool_execution_started"
| "assistant_output_started"
| "model_call_started";
provider?: string;
model?: string;
backend?: string;
source?: string;
tool?: string;
toolCallId?: string;
itemId?: string;
firstModelCallStarted?: boolean;
}) => void;
onBlockReply?: (payload: { text?: string; mediaUrls?: string[] }) => Promise<void> | void;
onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => Promise<void> | void;
onItemEvent?: (payload: {
@@ -361,6 +386,7 @@ function createMockTypingSignaler(): TypingSignaler {
signalTextDelta: vi.fn(async () => {}),
signalReasoningDelta: vi.fn(async () => {}),
signalToolStart: vi.fn(async () => {}),
signalExecutionActivity: vi.fn(async () => {}),
};
}
@@ -515,6 +541,7 @@ function createMinimalRunAgentTurnParams(overrides?: {
opts?: GetReplyOptions;
replyOperation?: ReplyOperation;
sessionCtx?: TemplateContext;
typingSignals?: TypingSignaler;
}) {
return {
commandBody: "fix it",
@@ -527,7 +554,7 @@ function createMinimalRunAgentTurnParams(overrides?: {
} as unknown as TemplateContext),
opts: overrides?.opts ?? ({} satisfies GetReplyOptions),
replyOperation: overrides?.replyOperation,
typingSignals: createMockTypingSignaler(),
typingSignals: overrides?.typingSignals ?? createMockTypingSignaler(),
blockReplyPipeline: null,
blockStreamingEnabled: false,
resolvedBlockStreamingBreak: "message_end" as const,
@@ -1327,6 +1354,73 @@ describe("runAgentTurnWithFallback", () => {
});
});
it("signals typing from embedded harness execution phases before assistant text", async () => {
const typingSignals = createMockTypingSignaler();
const onAgentRunStart = vi.fn();
state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => {
params.onExecutionPhase?.({
phase: "model_call_started",
provider: "openai",
model: "gpt-5.4",
firstModelCallStarted: true,
});
return { payloads: [{ text: "final" }], meta: {} };
});
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
const result = await runAgentTurnWithFallback({
...createMinimalRunAgentTurnParams({
opts: {
onAgentRunStart,
} satisfies GetReplyOptions,
}),
typingSignals,
});
expect(result.kind).toBe("success");
expect(typingSignals.signalExecutionActivity).toHaveBeenCalledOnce();
expect(typingSignals.signalRunStart).not.toHaveBeenCalled();
expect(onAgentRunStart).toHaveBeenCalledOnce();
});
it("forwards CLI harness execution phases into typing signals", async () => {
state.isCliProviderMock.mockReturnValue(true);
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
result: await params.run("codex-cli", "gpt-5.4"),
provider: "codex-cli",
model: "gpt-5.4",
attempts: [],
}));
state.runCliAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => {
params.onExecutionPhase?.({
phase: "process_spawned",
provider: "codex-cli",
model: "gpt-5.4",
backend: "codex",
});
return { payloads: [{ text: "final" }], meta: {} };
});
const followupRun = createFollowupRun();
followupRun.run.provider = "codex-cli";
followupRun.run.model = "gpt-5.4";
const typingSignals = createMockTypingSignaler();
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
const result = await runAgentTurnWithFallback(
createMinimalRunAgentTurnParams({
followupRun,
typingSignals,
}),
);
expect(result.kind).toBe("success");
expect(typingSignals.signalExecutionActivity).toHaveBeenCalledOnce();
expectMockCallArgFields(state.runCliAgentMock, 0, "CLI run params", {
provider: "codex-cli",
model: "gpt-5.4",
});
});
it("registers run ownership before asynchronous image preflight", async () => {
const agentEvents = await import("../../infra/agent-events.js");
const registerAgentRunContext = vi.mocked(agentEvents.registerAgentRunContext);
@@ -42,6 +42,7 @@ import {
import { sanitizeUserFacingText } from "../../agents/embedded-agent-helpers/sanitize-user-facing-text.js";
import { isMessagingToolSendAction } from "../../agents/embedded-agent-messaging.js";
import { mergeEmbeddedAgentRunResultForModelFallbackExhaustion } from "../../agents/embedded-agent-runner/result-fallback-classifier.js";
import type { RunEmbeddedAgentParams } from "../../agents/embedded-agent-runner/run/params.js";
import { runEmbeddedAgent } from "../../agents/embedded-agent.js";
import { isFailoverError } from "../../agents/failover-error.js";
import type { FastModeAutoProgressState } from "../../agents/fast-mode.js";
@@ -1731,6 +1732,25 @@ export async function runAgentTurnWithFallback(params: {
didNotifyAgentRunStart = true;
params.opts?.onAgentRunStart?.(runId);
};
const signalExecutionPhaseForTyping = (
info: Parameters<NonNullable<RunEmbeddedAgentParams["onExecutionPhase"]>>[0],
) => {
const isUserVisibleExecutionActivity =
info.phase === "turn_accepted" ||
info.phase === "process_spawned" ||
info.phase === "model_call_started" ||
info.phase === "tool_execution_started" ||
info.phase === "assistant_output_started";
if (!isUserVisibleExecutionActivity) {
return;
}
notifyAgentRunStart();
void (
params.typingSignals.signalExecutionActivity?.() ?? params.typingSignals.signalRunStart()
).catch((err: unknown) => {
logVerbose(`execution phase typing signal failed: ${String(err)}`);
});
};
const currentMessageId = params.sessionCtx.MessageSidFull ?? params.sessionCtx.MessageSid;
const notifyUserAboutCompaction = shouldNotifyUserAboutCompaction(runtimeConfig);
const deliverCompactionNoticePayload = async (noticePayload: ReplyPayload, label: string) => {
@@ -2424,6 +2444,7 @@ export async function runAgentTurnWithFallback(params: {
toolsAllow: params.opts?.toolsAllow,
disableTools: params.opts?.disableTools,
abortSignal: runAbortSignal,
onExecutionPhase: signalExecutionPhaseForTyping,
replyOperation: params.replyOperation,
},
}),
@@ -2571,6 +2592,7 @@ export async function runAgentTurnWithFallback(params: {
lifecycleGeneration = info.lifecycleGeneration;
}
},
onExecutionPhase: signalExecutionPhaseForTyping,
blockReplyBreak: params.resolvedBlockStreamingBreak,
blockReplyChunking: params.blockReplyChunking,
onPartialReply: async (payload) => {
+14
View File
@@ -880,6 +880,19 @@ describe("createTypingSignaler", () => {
}
});
it("starts typing on execution activity for active reply modes", async () => {
for (const mode of ["instant", "message", "thinking"] as const) {
const typing = createMockTypingController();
const signaler = createTypingSignaler({ typing, mode, isHeartbeat: false });
await signaler.signalExecutionActivity?.();
expect(typing.startTypingLoop, `mode=${mode}`).toHaveBeenCalledTimes(1);
expect(typing.refreshTypingTtl, `mode=${mode}`).toHaveBeenCalledTimes(1);
expect(typing.startTypingOnText, `mode=${mode}`).not.toHaveBeenCalled();
}
});
it("suppresses typing when disabled", async () => {
const disabledCases = [
{ mode: "instant" as const, isHeartbeat: true },
@@ -892,6 +905,7 @@ describe("createTypingSignaler", () => {
await signaler.signalRunStart();
await signaler.signalTextDelta("hi");
await signaler.signalReasoningDelta();
await signaler.signalExecutionActivity?.();
expect(typing.startTypingLoop, `mode=${params.mode}`).not.toHaveBeenCalled();
expect(typing.startTypingOnText, `mode=${params.mode}`).not.toHaveBeenCalled();
+12
View File
@@ -63,6 +63,7 @@ export type TypingSignaler = {
signalTextDelta: (text?: string) => Promise<void>;
signalReasoningDelta: () => Promise<void>;
signalToolStart: () => Promise<void>;
signalExecutionActivity?: () => Promise<void>;
};
/** Creates a typing signaler that starts or refreshes typing from stream events. */
@@ -156,6 +157,16 @@ export function createTypingSignaler(params: {
typing.refreshTypingTtl();
};
const signalExecutionActivity = async () => {
if (disabled) {
return;
}
if (!typing.isActive()) {
await typing.startTypingLoop();
}
typing.refreshTypingTtl();
};
return {
mode,
shouldStartImmediately,
@@ -167,5 +178,6 @@ export function createTypingSignaler(params: {
signalTextDelta,
signalReasoningDelta,
signalToolStart,
signalExecutionActivity,
};
}