Skip to content

Commit 9b2934b

Browse files
authored
fix(scheduled-task): scheduled task falsely reports empty assistant response due to race condition in executor polling (#127)
1 parent e80cbcf commit 9b2934b

2 files changed

Lines changed: 122 additions & 11 deletions

File tree

src/scheduled-task/executor.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ type MessagePartSnapshot = {
4444
text?: string;
4545
ignored?: boolean;
4646
tool?: string;
47+
reason?: string;
4748
state?: { status?: string };
4849
};
4950

@@ -54,6 +55,7 @@ type AssistantMessageSnapshot = {
5455
id?: string;
5556
role: string;
5657
summary?: unknown;
58+
finish?: string;
5759
time?: { completed?: number };
5860
error?: unknown;
5961
};
@@ -164,7 +166,10 @@ function sleep(ms: number): Promise<void> {
164166
}
165167

166168
function findLatestAssistantMessage(
167-
messages: Array<{ info: { role: string; summary?: unknown }; parts: MessagePartSnapshot[] }>,
169+
messages: Array<{
170+
info: { role: string; summary?: unknown; finish?: string };
171+
parts: MessagePartSnapshot[];
172+
}>,
168173
): AssistantMessageSnapshot | null {
169174
for (let index = messages.length - 1; index >= 0; index -= 1) {
170175
const message = messages[index];
@@ -176,14 +181,34 @@ function findLatestAssistantMessage(
176181
return null;
177182
}
178183

184+
function getAssistantFinishReason(message: AssistantMessageSnapshot): string | null {
185+
for (let index = message.parts.length - 1; index >= 0; index -= 1) {
186+
const part = message.parts[index];
187+
if (part?.type === "step-finish" && typeof part.reason === "string" && part.reason.trim()) {
188+
return part.reason.trim();
189+
}
190+
}
191+
192+
if (typeof message.info.finish === "string" && message.info.finish.trim()) {
193+
return message.info.finish.trim();
194+
}
195+
196+
return null;
197+
}
198+
179199
function extractAssistantResult(message: AssistantMessageSnapshot | null): {
180200
resultText: string | null;
181201
errorMessage: string | null;
182202
completed: boolean;
183203
message: AssistantMessageSnapshot | null;
184204
} {
185205
if (!message) {
186-
return { resultText: null, errorMessage: null, completed: false, message: null };
206+
return {
207+
resultText: null,
208+
errorMessage: null,
209+
completed: false,
210+
message: null,
211+
};
187212
}
188213

189214
const errorMessage = extractErrorMessage(message.info.error);
@@ -197,10 +222,14 @@ function extractAssistantResult(message: AssistantMessageSnapshot | null): {
197222
}
198223

199224
const resultText = collectResponseText(message.parts);
225+
const completed = Boolean(message.info.time?.completed);
226+
const finishReason = getAssistantFinishReason(message);
227+
const awaitingToolCalls = completed && finishReason === "tool-calls";
228+
200229
return {
201-
resultText,
230+
resultText: awaitingToolCalls ? null : resultText,
202231
errorMessage: null,
203-
completed: Boolean(message.info.time?.completed),
232+
completed: completed && !awaitingToolCalls,
204233
message,
205234
};
206235
}
@@ -217,6 +246,7 @@ function summarizeAssistantParts(parts: MessagePartSnapshot[]): Array<{
217246
id: part.id,
218247
type: part.type,
219248
ignored: part.ignored,
249+
reason: part.reason,
220250
...(typeof part.text === "string" ? { textLength: part.text.length } : {}),
221251
...(part.tool ? { tool: part.tool } : {}),
222252
...(part.state?.status ? { status: part.state.status } : {}),
@@ -240,6 +270,7 @@ function logEmptyAssistantResponseDiagnostics(
240270
id: message.info.id,
241271
completed: Boolean(message.info.time?.completed),
242272
summary: Boolean(message.info.summary),
273+
finish: getAssistantFinishReason(message),
243274
errorMessage: extractErrorMessage(message.info.error),
244275
parts: summarizeAssistantParts(message.parts),
245276
}

tests/scheduled-task/executor.test.ts

Lines changed: 87 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,38 @@ function createAssistantMessage(
9292
completed?: boolean;
9393
error?: unknown;
9494
summary?: boolean;
95+
finish?: string;
96+
stepFinishReason?: string;
9597
parts?: Array<Record<string, unknown>>;
9698
} = {},
9799
) {
100+
const generatedParts: Array<Record<string, unknown>> = [];
101+
if (text) {
102+
generatedParts.push({
103+
id: "part-1",
104+
sessionID: "session-1",
105+
messageID: "assistant-1",
106+
type: "text",
107+
text,
108+
});
109+
}
110+
if (options.stepFinishReason) {
111+
generatedParts.push({
112+
id: "finish-1",
113+
sessionID: "session-1",
114+
messageID: "assistant-1",
115+
type: "step-finish",
116+
reason: options.stepFinishReason,
117+
cost: 0,
118+
tokens: {
119+
input: 0,
120+
output: 0,
121+
reasoning: 0,
122+
cache: { read: 0, write: 0 },
123+
},
124+
});
125+
}
126+
98127
return {
99128
info: {
100129
id: "assistant-1",
@@ -118,12 +147,9 @@ function createAssistantMessage(
118147
},
119148
error: options.error,
120149
summary: options.summary,
150+
finish: options.finish,
121151
},
122-
parts:
123-
options.parts ??
124-
(text
125-
? [{ id: "part-1", sessionID: "session-1", messageID: "assistant-1", type: "text", text }]
126-
: []),
152+
parts: options.parts ?? generatedParts,
127153
};
128154
}
129155

@@ -314,7 +340,7 @@ describe("scheduled-task/executor", () => {
314340
});
315341
mocked.promptAsyncMock.mockResolvedValueOnce({ data: undefined, error: null });
316342
mocked.messagesMock.mockResolvedValue({
317-
data: [createAssistantMessage("", { completed: true })],
343+
data: [createAssistantMessage("", { completed: true, stepFinishReason: "stop" })],
318344
error: null,
319345
});
320346

@@ -341,7 +367,8 @@ describe("scheduled-task/executor", () => {
341367
assistantMessage: expect.objectContaining({
342368
completed: true,
343369
summary: false,
344-
parts: [],
370+
finish: "stop",
371+
parts: [expect.objectContaining({ type: "step-finish", reason: "stop" })],
345372
}),
346373
}),
347374
);
@@ -391,6 +418,59 @@ describe("scheduled-task/executor", () => {
391418
expect(mocked.deleteMock).toHaveBeenCalledWith({ sessionID: "session-1" });
392419
});
393420

421+
it("waits for the final assistant response after completed tool-call turns", async () => {
422+
const { executeScheduledTask } = await import("../../src/scheduled-task/executor.js");
423+
424+
const toolCallTurn = createAssistantMessage("", {
425+
completed: true,
426+
stepFinishReason: "tool-calls",
427+
});
428+
429+
mocked.createMock.mockResolvedValueOnce({
430+
data: { id: "session-1", directory: "D:\\Projects\\Repo", title: "Scheduled task run" },
431+
error: null,
432+
});
433+
mocked.promptAsyncMock.mockResolvedValueOnce({ data: undefined, error: null });
434+
mocked.messagesMock
435+
.mockResolvedValueOnce({ data: [toolCallTurn], error: null })
436+
.mockResolvedValueOnce({ data: [toolCallTurn], error: null })
437+
.mockResolvedValueOnce({ data: [toolCallTurn], error: null })
438+
.mockResolvedValueOnce({ data: [toolCallTurn], error: null })
439+
.mockResolvedValueOnce({
440+
data: [
441+
toolCallTurn,
442+
createAssistantMessage("SCHEDULED_TASK_FINAL_OK", {
443+
completed: true,
444+
stepFinishReason: "stop",
445+
}),
446+
],
447+
error: null,
448+
});
449+
mocked.statusMock.mockResolvedValue({
450+
data: { "session-1": { type: "busy" } },
451+
error: null,
452+
});
453+
454+
vi.useFakeTimers();
455+
456+
const resultPromise = executeScheduledTask(createTask());
457+
458+
await vi.advanceTimersByTimeAsync(8000);
459+
460+
await expect(resultPromise).resolves.toMatchObject({
461+
status: "success",
462+
resultText: "SCHEDULED_TASK_FINAL_OK",
463+
errorMessage: null,
464+
});
465+
expect(mocked.messagesMock).toHaveBeenCalledTimes(5);
466+
expect(mocked.statusMock).toHaveBeenCalledTimes(4);
467+
expect(mocked.deleteMock).toHaveBeenCalledWith({ sessionID: "session-1" });
468+
expect(mocked.loggerWarnMock).not.toHaveBeenCalledWith(
469+
"[ScheduledTaskExecutor] Empty completed assistant response diagnostics",
470+
expect.anything(),
471+
);
472+
});
473+
394474
it("ignores technical summary assistant messages when finding the scheduled task result", async () => {
395475
const { executeScheduledTask } = await import("../../src/scheduled-task/executor.js");
396476

0 commit comments

Comments
 (0)