Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 35 additions & 4 deletions src/scheduled-task/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ type MessagePartSnapshot = {
text?: string;
ignored?: boolean;
tool?: string;
reason?: string;
state?: { status?: string };
};

Expand All @@ -54,6 +55,7 @@ type AssistantMessageSnapshot = {
id?: string;
role: string;
summary?: unknown;
finish?: string;
time?: { completed?: number };
error?: unknown;
};
Expand Down Expand Up @@ -164,7 +166,10 @@ function sleep(ms: number): Promise<void> {
}

function findLatestAssistantMessage(
messages: Array<{ info: { role: string; summary?: unknown }; parts: MessagePartSnapshot[] }>,
messages: Array<{
info: { role: string; summary?: unknown; finish?: string };
parts: MessagePartSnapshot[];
}>,
): AssistantMessageSnapshot | null {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
Expand All @@ -176,14 +181,34 @@ function findLatestAssistantMessage(
return null;
}

function getAssistantFinishReason(message: AssistantMessageSnapshot): string | null {
for (let index = message.parts.length - 1; index >= 0; index -= 1) {
const part = message.parts[index];
if (part?.type === "step-finish" && typeof part.reason === "string" && part.reason.trim()) {
return part.reason.trim();
}
}

if (typeof message.info.finish === "string" && message.info.finish.trim()) {
return message.info.finish.trim();
}

return null;
}

function extractAssistantResult(message: AssistantMessageSnapshot | null): {
resultText: string | null;
errorMessage: string | null;
completed: boolean;
message: AssistantMessageSnapshot | null;
} {
if (!message) {
return { resultText: null, errorMessage: null, completed: false, message: null };
return {
resultText: null,
errorMessage: null,
completed: false,
message: null,
};
}

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

const resultText = collectResponseText(message.parts);
const completed = Boolean(message.info.time?.completed);
const finishReason = getAssistantFinishReason(message);
const awaitingToolCalls = completed && finishReason === "tool-calls";

return {
resultText,
resultText: awaitingToolCalls ? null : resultText,
errorMessage: null,
completed: Boolean(message.info.time?.completed),
completed: completed && !awaitingToolCalls,
message,
};
}
Expand All @@ -217,6 +246,7 @@ function summarizeAssistantParts(parts: MessagePartSnapshot[]): Array<{
id: part.id,
type: part.type,
ignored: part.ignored,
reason: part.reason,
...(typeof part.text === "string" ? { textLength: part.text.length } : {}),
...(part.tool ? { tool: part.tool } : {}),
...(part.state?.status ? { status: part.state.status } : {}),
Expand All @@ -240,6 +270,7 @@ function logEmptyAssistantResponseDiagnostics(
id: message.info.id,
completed: Boolean(message.info.time?.completed),
summary: Boolean(message.info.summary),
finish: getAssistantFinishReason(message),
errorMessage: extractErrorMessage(message.info.error),
parts: summarizeAssistantParts(message.parts),
}
Expand Down
94 changes: 87 additions & 7 deletions tests/scheduled-task/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,38 @@ function createAssistantMessage(
completed?: boolean;
error?: unknown;
summary?: boolean;
finish?: string;
stepFinishReason?: string;
parts?: Array<Record<string, unknown>>;
} = {},
) {
const generatedParts: Array<Record<string, unknown>> = [];
if (text) {
generatedParts.push({
id: "part-1",
sessionID: "session-1",
messageID: "assistant-1",
type: "text",
text,
});
}
if (options.stepFinishReason) {
generatedParts.push({
id: "finish-1",
sessionID: "session-1",
messageID: "assistant-1",
type: "step-finish",
reason: options.stepFinishReason,
cost: 0,
tokens: {
input: 0,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
});
}

return {
info: {
id: "assistant-1",
Expand All @@ -118,12 +147,9 @@ function createAssistantMessage(
},
error: options.error,
summary: options.summary,
finish: options.finish,
},
parts:
options.parts ??
(text
? [{ id: "part-1", sessionID: "session-1", messageID: "assistant-1", type: "text", text }]
: []),
parts: options.parts ?? generatedParts,
};
}

Expand Down Expand Up @@ -314,7 +340,7 @@ describe("scheduled-task/executor", () => {
});
mocked.promptAsyncMock.mockResolvedValueOnce({ data: undefined, error: null });
mocked.messagesMock.mockResolvedValue({
data: [createAssistantMessage("", { completed: true })],
data: [createAssistantMessage("", { completed: true, stepFinishReason: "stop" })],
error: null,
});

Expand All @@ -341,7 +367,8 @@ describe("scheduled-task/executor", () => {
assistantMessage: expect.objectContaining({
completed: true,
summary: false,
parts: [],
finish: "stop",
parts: [expect.objectContaining({ type: "step-finish", reason: "stop" })],
}),
}),
);
Expand Down Expand Up @@ -391,6 +418,59 @@ describe("scheduled-task/executor", () => {
expect(mocked.deleteMock).toHaveBeenCalledWith({ sessionID: "session-1" });
});

it("waits for the final assistant response after completed tool-call turns", async () => {
const { executeScheduledTask } = await import("../../src/scheduled-task/executor.js");

const toolCallTurn = createAssistantMessage("", {
completed: true,
stepFinishReason: "tool-calls",
});

mocked.createMock.mockResolvedValueOnce({
data: { id: "session-1", directory: "D:\\Projects\\Repo", title: "Scheduled task run" },
error: null,
});
mocked.promptAsyncMock.mockResolvedValueOnce({ data: undefined, error: null });
mocked.messagesMock
.mockResolvedValueOnce({ data: [toolCallTurn], error: null })
.mockResolvedValueOnce({ data: [toolCallTurn], error: null })
.mockResolvedValueOnce({ data: [toolCallTurn], error: null })
.mockResolvedValueOnce({ data: [toolCallTurn], error: null })
.mockResolvedValueOnce({
data: [
toolCallTurn,
createAssistantMessage("SCHEDULED_TASK_FINAL_OK", {
completed: true,
stepFinishReason: "stop",
}),
],
error: null,
});
mocked.statusMock.mockResolvedValue({
data: { "session-1": { type: "busy" } },
error: null,
});

vi.useFakeTimers();

const resultPromise = executeScheduledTask(createTask());

await vi.advanceTimersByTimeAsync(8000);

await expect(resultPromise).resolves.toMatchObject({
status: "success",
resultText: "SCHEDULED_TASK_FINAL_OK",
errorMessage: null,
});
expect(mocked.messagesMock).toHaveBeenCalledTimes(5);
expect(mocked.statusMock).toHaveBeenCalledTimes(4);
expect(mocked.deleteMock).toHaveBeenCalledWith({ sessionID: "session-1" });
expect(mocked.loggerWarnMock).not.toHaveBeenCalledWith(
"[ScheduledTaskExecutor] Empty completed assistant response diagnostics",
expect.anything(),
);
});

it("ignores technical summary assistant messages when finding the scheduled task result", async () => {
const { executeScheduledTask } = await import("../../src/scheduled-task/executor.js");

Expand Down
Loading