Auto-reply: delay onAgentRunStart until real activity

This commit is contained in:
Shakker
2026-02-19 18:47:07 +00:00
committed by Shakker
parent 4b7d89100e
commit 7579e9511e
2 changed files with 123 additions and 1 deletions

View File

@@ -88,7 +88,14 @@ export async function runAgentTurnWithFallback(params: {
const directlySentBlockKeys = new Set<string>();
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") {

View File

@@ -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(