diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 92c97d829e..b9cf70b17c 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -88,7 +88,14 @@ export async function runAgentTurnWithFallback(params: { const directlySentBlockKeys = new Set(); const runId = params.opts?.runId ?? crypto.randomUUID(); - params.opts?.onAgentRunStart?.(runId); + let didNotifyAgentRunStart = false; + const notifyAgentRunStart = () => { + if (didNotifyAgentRunStart) { + return; + } + didNotifyAgentRunStart = true; + params.opts?.onAgentRunStart?.(runId); + }; if (params.sessionKey) { registerAgentRunContext(runId, { sessionKey: params.sessionKey, @@ -160,6 +167,7 @@ export async function runAgentTurnWithFallback(params: { if (isCliProvider(provider, params.followupRun.run.config)) { const startedAt = Date.now(); + notifyAgentRunStart(); emitAgentEvent({ runId, stream: "lifecycle", @@ -310,6 +318,12 @@ export async function runAgentTurnWithFallback(params: { : undefined, onReasoningEnd: params.opts?.onReasoningEnd, onAgentEvent: async (evt) => { + // Signal run start only after the embedded agent emits real activity. + const hasLifecyclePhase = + evt.stream === "lifecycle" && typeof evt.data.phase === "string"; + if (evt.stream !== "lifecycle" || hasLifecyclePhase) { + notifyAgentRunStart(); + } // Trigger typing when tools start executing. // Must await to ensure typing indicator starts before tool summaries are emitted. if (evt.stream === "tool") { diff --git a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts index 191fd5766a..a1ad2d0a91 100644 --- a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts @@ -94,6 +94,114 @@ afterEach(() => { vi.useRealTimers(); }); +describe("runReplyAgent onAgentRunStart", () => { + function createRun(params?: { + provider?: string; + model?: string; + opts?: { + runId?: string; + onAgentRunStart?: (runId: string) => void; + }; + }) { + const provider = params?.provider ?? "anthropic"; + const model = params?.model ?? "claude"; + const typing = createMockTypingController(); + const sessionCtx = { + Provider: "webchat", + OriginatingTo: "session:1", + AccountId: "primary", + MessageSid: "msg", + } as unknown as TemplateContext; + const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings; + const followupRun = { + prompt: "hello", + summaryLine: "hello", + enqueuedAt: Date.now(), + run: { + sessionId: "session", + sessionKey: "main", + messageProvider: "webchat", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + config: {}, + skillsSnapshot: {}, + provider, + model, + thinkLevel: "low", + verboseLevel: "off", + elevatedLevel: "off", + bashElevated: { + enabled: false, + allowed: false, + defaultLevel: "off", + }, + timeoutMs: 1_000, + blockReplyBreak: "message_end", + }, + } as unknown as FollowupRun; + + return runReplyAgent({ + commandBody: "hello", + followupRun, + queueKey: "main", + resolvedQueue, + shouldSteer: false, + shouldFollowup: false, + isActive: false, + isStreaming: false, + opts: params?.opts, + typing, + sessionCtx, + defaultModel: `${provider}/${model}`, + resolvedVerboseLevel: "off", + isNewSession: false, + blockStreamingEnabled: false, + resolvedBlockStreamingBreak: "message_end", + shouldInjectGroupIntro: false, + typingMode: "instant", + }); + } + + it("does not emit start callback when fallback fails before run start", async () => { + runWithModelFallbackMock.mockRejectedValueOnce( + new Error('No API key found for provider "anthropic".'), + ); + const onAgentRunStart = vi.fn(); + + const result = await createRun({ + opts: { runId: "run-no-start", onAgentRunStart }, + }); + + expect(onAgentRunStart).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + text: expect.stringContaining('No API key found for provider "anthropic".'), + }); + }); + + it("emits start callback when cli runner starts", async () => { + runCliAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "ok" }], + meta: { + agentMeta: { + provider: "claude-cli", + model: "opus-4.5", + }, + }, + }); + const onAgentRunStart = vi.fn(); + + const result = await createRun({ + provider: "claude-cli", + model: "opus-4.5", + opts: { runId: "run-started", onAgentRunStart }, + }); + + expect(onAgentRunStart).toHaveBeenCalledTimes(1); + expect(onAgentRunStart).toHaveBeenCalledWith("run-started"); + expect(result).toMatchObject({ text: "ok" }); + }); +}); + describe("runReplyAgent authProfileId fallback scoping", () => { it("drops authProfileId when provider changes during fallback", async () => { runWithModelFallbackMock.mockImplementationOnce(