mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user