Skip to content

Commit b6bc3ed

Browse files
authored
fix: Codex turns stop showing typing during tool work (#95844)
* fix: bridge harness execution phases to typing * docs: clarify typing indicator activity triggers
1 parent 9ad959a commit b6bc3ed

5 files changed

Lines changed: 155 additions & 12 deletions

File tree

docs/concepts/typing-indicators.md

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ When `agents.defaults.typingMode` is **unset**, OpenClaw keeps the legacy behavi
1515

1616
- **Direct chats**: typing starts immediately once the model loop begins.
1717
- **Group chats with a mention**: typing starts immediately.
18-
- **Group chats without a mention**: typing starts only when message text begins streaming.
18+
- **Group chats without a mention**: typing starts when the admitted run has
19+
user-visible activity, such as harness execution activity or message text.
1920
- **Heartbeat runs**: typing starts when the heartbeat run begins if the
2021
resolved heartbeat target is a typing-capable chat and typing is not disabled.
2122

@@ -26,13 +27,14 @@ Set `agents.defaults.typingMode` to one of:
2627
- `never` - no typing indicator, ever.
2728
- `instant` - start typing **as soon as the model loop begins**, even if the run
2829
later returns only the silent reply token.
29-
- `thinking` - start typing on the **first reasoning delta** (requires
30-
`reasoningLevel: "stream"` for the run).
31-
- `message` - start typing on the **first non-silent text delta** (ignores
32-
the `NO_REPLY` silent token).
30+
- `thinking` - start typing on the **first reasoning delta** or on active
31+
harness execution after the turn is accepted.
32+
- `message` - start typing on the **first user-visible reply activity**, such as
33+
active harness execution or a non-silent text delta. Silent reply tokens such
34+
as `NO_REPLY` do not count as text activity.
3335

3436
Order of "how early it fires":
35-
`never``message``thinking``instant`
37+
`never``message`/`thinking``instant`
3638

3739
## Configuration
3840

@@ -62,11 +64,10 @@ Override mode or cadence per session:
6264

6365
## Notes
6466

65-
- `message` mode won't show typing for silent-only replies when the whole
66-
payload is the exact silent token (for example `NO_REPLY` / `no_reply`,
67-
matched case-insensitively).
68-
- `thinking` only fires if the run streams reasoning (`reasoningLevel: "stream"`).
69-
If the model doesn't emit reasoning deltas, typing won't start.
67+
- `message` mode does not start from silent reply tokens, but active execution
68+
can still show typing before any assistant text is available.
69+
- `thinking` still reacts to streamed reasoning (`reasoningLevel: "stream"`),
70+
and it can also start from active execution before reasoning deltas arrive.
7071
- Heartbeat typing is a liveness signal for the resolved delivery target. It
7172
starts at heartbeat run start instead of following `message` or `thinking`
7273
stream timing. Set `typingMode: "never"` to disable it.

src/auto-reply/reply/agent-runner-execution.test.ts

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,31 @@ type FallbackRunnerParams = {
327327
type EmbeddedAgentParams = {
328328
lifecycleGeneration?: string;
329329
onExecutionStarted?: (info?: { lifecycleGeneration?: string }) => void;
330+
onExecutionPhase?: (info: {
331+
phase:
332+
| "runner_entered"
333+
| "workspace"
334+
| "runtime_plugins"
335+
| "before_agent_reply"
336+
| "model_resolution"
337+
| "auth"
338+
| "context_engine"
339+
| "attempt_dispatch"
340+
| "context_assembled"
341+
| "turn_accepted"
342+
| "process_spawned"
343+
| "tool_execution_started"
344+
| "assistant_output_started"
345+
| "model_call_started";
346+
provider?: string;
347+
model?: string;
348+
backend?: string;
349+
source?: string;
350+
tool?: string;
351+
toolCallId?: string;
352+
itemId?: string;
353+
firstModelCallStarted?: boolean;
354+
}) => void;
330355
onBlockReply?: (payload: { text?: string; mediaUrls?: string[] }) => Promise<void> | void;
331356
onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => Promise<void> | void;
332357
onItemEvent?: (payload: {
@@ -361,6 +386,7 @@ function createMockTypingSignaler(): TypingSignaler {
361386
signalTextDelta: vi.fn(async () => {}),
362387
signalReasoningDelta: vi.fn(async () => {}),
363388
signalToolStart: vi.fn(async () => {}),
389+
signalExecutionActivity: vi.fn(async () => {}),
364390
};
365391
}
366392

@@ -515,6 +541,7 @@ function createMinimalRunAgentTurnParams(overrides?: {
515541
opts?: GetReplyOptions;
516542
replyOperation?: ReplyOperation;
517543
sessionCtx?: TemplateContext;
544+
typingSignals?: TypingSignaler;
518545
}) {
519546
return {
520547
commandBody: "fix it",
@@ -527,7 +554,7 @@ function createMinimalRunAgentTurnParams(overrides?: {
527554
} as unknown as TemplateContext),
528555
opts: overrides?.opts ?? ({} satisfies GetReplyOptions),
529556
replyOperation: overrides?.replyOperation,
530-
typingSignals: createMockTypingSignaler(),
557+
typingSignals: overrides?.typingSignals ?? createMockTypingSignaler(),
531558
blockReplyPipeline: null,
532559
blockStreamingEnabled: false,
533560
resolvedBlockStreamingBreak: "message_end" as const,
@@ -1327,6 +1354,73 @@ describe("runAgentTurnWithFallback", () => {
13271354
});
13281355
});
13291356

1357+
it("signals typing from embedded harness execution phases before assistant text", async () => {
1358+
const typingSignals = createMockTypingSignaler();
1359+
const onAgentRunStart = vi.fn();
1360+
state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => {
1361+
params.onExecutionPhase?.({
1362+
phase: "model_call_started",
1363+
provider: "openai",
1364+
model: "gpt-5.4",
1365+
firstModelCallStarted: true,
1366+
});
1367+
return { payloads: [{ text: "final" }], meta: {} };
1368+
});
1369+
1370+
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
1371+
const result = await runAgentTurnWithFallback({
1372+
...createMinimalRunAgentTurnParams({
1373+
opts: {
1374+
onAgentRunStart,
1375+
} satisfies GetReplyOptions,
1376+
}),
1377+
typingSignals,
1378+
});
1379+
1380+
expect(result.kind).toBe("success");
1381+
expect(typingSignals.signalExecutionActivity).toHaveBeenCalledOnce();
1382+
expect(typingSignals.signalRunStart).not.toHaveBeenCalled();
1383+
expect(onAgentRunStart).toHaveBeenCalledOnce();
1384+
});
1385+
1386+
it("forwards CLI harness execution phases into typing signals", async () => {
1387+
state.isCliProviderMock.mockReturnValue(true);
1388+
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
1389+
result: await params.run("codex-cli", "gpt-5.4"),
1390+
provider: "codex-cli",
1391+
model: "gpt-5.4",
1392+
attempts: [],
1393+
}));
1394+
state.runCliAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => {
1395+
params.onExecutionPhase?.({
1396+
phase: "process_spawned",
1397+
provider: "codex-cli",
1398+
model: "gpt-5.4",
1399+
backend: "codex",
1400+
});
1401+
return { payloads: [{ text: "final" }], meta: {} };
1402+
});
1403+
const followupRun = createFollowupRun();
1404+
followupRun.run.provider = "codex-cli";
1405+
followupRun.run.model = "gpt-5.4";
1406+
const typingSignals = createMockTypingSignaler();
1407+
1408+
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
1409+
const result = await runAgentTurnWithFallback(
1410+
createMinimalRunAgentTurnParams({
1411+
followupRun,
1412+
typingSignals,
1413+
}),
1414+
);
1415+
1416+
expect(result.kind).toBe("success");
1417+
expect(typingSignals.signalExecutionActivity).toHaveBeenCalledOnce();
1418+
expectMockCallArgFields(state.runCliAgentMock, 0, "CLI run params", {
1419+
provider: "codex-cli",
1420+
model: "gpt-5.4",
1421+
});
1422+
});
1423+
13301424
it("registers run ownership before asynchronous image preflight", async () => {
13311425
const agentEvents = await import("../../infra/agent-events.js");
13321426
const registerAgentRunContext = vi.mocked(agentEvents.registerAgentRunContext);

src/auto-reply/reply/agent-runner-execution.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
import { sanitizeUserFacingText } from "../../agents/embedded-agent-helpers/sanitize-user-facing-text.js";
4343
import { isMessagingToolSendAction } from "../../agents/embedded-agent-messaging.js";
4444
import { mergeEmbeddedAgentRunResultForModelFallbackExhaustion } from "../../agents/embedded-agent-runner/result-fallback-classifier.js";
45+
import type { RunEmbeddedAgentParams } from "../../agents/embedded-agent-runner/run/params.js";
4546
import { runEmbeddedAgent } from "../../agents/embedded-agent.js";
4647
import { isFailoverError } from "../../agents/failover-error.js";
4748
import type { FastModeAutoProgressState } from "../../agents/fast-mode.js";
@@ -1731,6 +1732,25 @@ export async function runAgentTurnWithFallback(params: {
17311732
didNotifyAgentRunStart = true;
17321733
params.opts?.onAgentRunStart?.(runId);
17331734
};
1735+
const signalExecutionPhaseForTyping = (
1736+
info: Parameters<NonNullable<RunEmbeddedAgentParams["onExecutionPhase"]>>[0],
1737+
) => {
1738+
const isUserVisibleExecutionActivity =
1739+
info.phase === "turn_accepted" ||
1740+
info.phase === "process_spawned" ||
1741+
info.phase === "model_call_started" ||
1742+
info.phase === "tool_execution_started" ||
1743+
info.phase === "assistant_output_started";
1744+
if (!isUserVisibleExecutionActivity) {
1745+
return;
1746+
}
1747+
notifyAgentRunStart();
1748+
void (
1749+
params.typingSignals.signalExecutionActivity?.() ?? params.typingSignals.signalRunStart()
1750+
).catch((err: unknown) => {
1751+
logVerbose(`execution phase typing signal failed: ${String(err)}`);
1752+
});
1753+
};
17341754
const currentMessageId = params.sessionCtx.MessageSidFull ?? params.sessionCtx.MessageSid;
17351755
const notifyUserAboutCompaction = shouldNotifyUserAboutCompaction(runtimeConfig);
17361756
const deliverCompactionNoticePayload = async (noticePayload: ReplyPayload, label: string) => {
@@ -2424,6 +2444,7 @@ export async function runAgentTurnWithFallback(params: {
24242444
toolsAllow: params.opts?.toolsAllow,
24252445
disableTools: params.opts?.disableTools,
24262446
abortSignal: runAbortSignal,
2447+
onExecutionPhase: signalExecutionPhaseForTyping,
24272448
replyOperation: params.replyOperation,
24282449
},
24292450
}),
@@ -2571,6 +2592,7 @@ export async function runAgentTurnWithFallback(params: {
25712592
lifecycleGeneration = info.lifecycleGeneration;
25722593
}
25732594
},
2595+
onExecutionPhase: signalExecutionPhaseForTyping,
25742596
blockReplyBreak: params.resolvedBlockStreamingBreak,
25752597
blockReplyChunking: params.blockReplyChunking,
25762598
onPartialReply: async (payload) => {

src/auto-reply/reply/reply-utils.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -880,6 +880,19 @@ describe("createTypingSignaler", () => {
880880
}
881881
});
882882

883+
it("starts typing on execution activity for active reply modes", async () => {
884+
for (const mode of ["instant", "message", "thinking"] as const) {
885+
const typing = createMockTypingController();
886+
const signaler = createTypingSignaler({ typing, mode, isHeartbeat: false });
887+
888+
await signaler.signalExecutionActivity?.();
889+
890+
expect(typing.startTypingLoop, `mode=${mode}`).toHaveBeenCalledTimes(1);
891+
expect(typing.refreshTypingTtl, `mode=${mode}`).toHaveBeenCalledTimes(1);
892+
expect(typing.startTypingOnText, `mode=${mode}`).not.toHaveBeenCalled();
893+
}
894+
});
895+
883896
it("suppresses typing when disabled", async () => {
884897
const disabledCases = [
885898
{ mode: "instant" as const, isHeartbeat: true },
@@ -892,6 +905,7 @@ describe("createTypingSignaler", () => {
892905
await signaler.signalRunStart();
893906
await signaler.signalTextDelta("hi");
894907
await signaler.signalReasoningDelta();
908+
await signaler.signalExecutionActivity?.();
895909

896910
expect(typing.startTypingLoop, `mode=${params.mode}`).not.toHaveBeenCalled();
897911
expect(typing.startTypingOnText, `mode=${params.mode}`).not.toHaveBeenCalled();

src/auto-reply/reply/typing-mode.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export type TypingSignaler = {
6363
signalTextDelta: (text?: string) => Promise<void>;
6464
signalReasoningDelta: () => Promise<void>;
6565
signalToolStart: () => Promise<void>;
66+
signalExecutionActivity?: () => Promise<void>;
6667
};
6768

6869
/** Creates a typing signaler that starts or refreshes typing from stream events. */
@@ -156,6 +157,16 @@ export function createTypingSignaler(params: {
156157
typing.refreshTypingTtl();
157158
};
158159

160+
const signalExecutionActivity = async () => {
161+
if (disabled) {
162+
return;
163+
}
164+
if (!typing.isActive()) {
165+
await typing.startTypingLoop();
166+
}
167+
typing.refreshTypingTtl();
168+
};
169+
159170
return {
160171
mode,
161172
shouldStartImmediately,
@@ -167,5 +178,6 @@ export function createTypingSignaler(params: {
167178
signalTextDelta,
168179
signalReasoningDelta,
169180
signalToolStart,
181+
signalExecutionActivity,
170182
};
171183
}

0 commit comments

Comments
 (0)