diff --git a/docs/concepts/typing-indicators.md b/docs/concepts/typing-indicators.md index 485dd809ad7c..61c8aca6aea8 100644 --- a/docs/concepts/typing-indicators.md +++ b/docs/concepts/typing-indicators.md @@ -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. diff --git a/src/auto-reply/reply/agent-runner-execution.test.ts b/src/auto-reply/reply/agent-runner-execution.test.ts index 3e37a086d938..0f7e6376f545 100644 --- a/src/auto-reply/reply/agent-runner-execution.test.ts +++ b/src/auto-reply/reply/agent-runner-execution.test.ts @@ -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; onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => Promise | 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); diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 42f2089d2080..473c0dd2c0f4 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -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>[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) => { diff --git a/src/auto-reply/reply/reply-utils.test.ts b/src/auto-reply/reply/reply-utils.test.ts index 4963dcdb1bb0..6a58dcc1e6d5 100644 --- a/src/auto-reply/reply/reply-utils.test.ts +++ b/src/auto-reply/reply/reply-utils.test.ts @@ -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(); diff --git a/src/auto-reply/reply/typing-mode.ts b/src/auto-reply/reply/typing-mode.ts index 60ab541c4327..8df0e98dfac1 100644 --- a/src/auto-reply/reply/typing-mode.ts +++ b/src/auto-reply/reply/typing-mode.ts @@ -63,6 +63,7 @@ export type TypingSignaler = { signalTextDelta: (text?: string) => Promise; signalReasoningDelta: () => Promise; signalToolStart: () => Promise; + signalExecutionActivity?: () => Promise; }; /** 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, }; }