mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* fix(frontend): stop stalled subtask pagination without losing steps * docs(frontend): note the non-advancing page stop in the subtask backfill The AGENTS.md backfill sentence only said the loop pages until a short page. It also stops on a full page that fails to advance the after_seq cursor, so record that stop condition as well. The file sits at 49,149 of its 49,152-byte hard limit, so this is the minimal wording change that fits. --------- Co-authored-by: TYY-2005 <334029581+TYY-2005@users.noreply.github.com>
151 lines
4.6 KiB
TypeScript
151 lines
4.6 KiB
TypeScript
import { beforeEach, describe, expect, rs, test } from "@rstest/core";
|
|
|
|
rs.mock("@/core/api/fetcher", () => ({
|
|
fetch: rs.fn(),
|
|
}));
|
|
|
|
rs.mock("@/core/config", () => ({
|
|
getBackendBaseURL: () => "/backend",
|
|
}));
|
|
|
|
import { fetch as fetcher } from "@/core/api/fetcher";
|
|
import { fetchSubtaskSteps } from "@/core/tasks/api";
|
|
|
|
const mockedFetch = rs.mocked(fetcher);
|
|
|
|
function jsonResponse(status: number, body: unknown): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
statusText: status >= 400 ? "Error" : "OK",
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
function stepEvent(seq: number, messageIndex: number, toolName: string) {
|
|
return {
|
|
event_type: "subagent.step",
|
|
seq,
|
|
content: {
|
|
task_id: "A",
|
|
message_index: messageIndex,
|
|
kind: "tool",
|
|
text: "",
|
|
tool_name: toolName,
|
|
},
|
|
metadata: { task_id: "A", message_index: messageIndex },
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mockedFetch.mockReset();
|
|
});
|
|
|
|
describe("fetchSubtaskSteps", () => {
|
|
test("scopes the request to the task and only fetches subagent.step", async () => {
|
|
mockedFetch.mockResolvedValueOnce(jsonResponse(200, []));
|
|
|
|
await fetchSubtaskSteps("thread 1", "run/1", "task-A");
|
|
|
|
const url = mockedFetch.mock.calls[0]![0] as string;
|
|
expect(url).toContain(
|
|
"/backend/api/threads/thread%201/runs/run%2F1/events",
|
|
);
|
|
expect(url).toContain("task_id=task-A");
|
|
expect(url).toContain("event_types=subagent.step");
|
|
expect(url).toContain("limit=");
|
|
expect(url).not.toContain("after_seq");
|
|
});
|
|
|
|
test("pages forward with after_seq until a short page, accumulating in order", async () => {
|
|
mockedFetch
|
|
.mockResolvedValueOnce(
|
|
jsonResponse(200, [
|
|
stepEvent(10, 0, "web_search"),
|
|
stepEvent(11, 1, "read_file"),
|
|
]),
|
|
)
|
|
.mockResolvedValueOnce(jsonResponse(200, [stepEvent(12, 2, "bash")]));
|
|
|
|
const steps = await fetchSubtaskSteps("t", "r", "A", 2);
|
|
|
|
expect(steps.map((s) => s.message_index)).toEqual([0, 1, 2]);
|
|
expect(steps.map((s) => s.tool_name)).toEqual([
|
|
"web_search",
|
|
"read_file",
|
|
"bash",
|
|
]);
|
|
expect(mockedFetch).toHaveBeenCalledTimes(2);
|
|
expect(mockedFetch.mock.calls[0]![0] as string).not.toContain("after_seq");
|
|
expect(mockedFetch.mock.calls[1]![0] as string).toContain("after_seq=11");
|
|
});
|
|
|
|
test("stops after a single page when it is shorter than the page size", async () => {
|
|
mockedFetch.mockResolvedValueOnce(
|
|
jsonResponse(200, [stepEvent(10, 0, "web_search")]),
|
|
);
|
|
|
|
const steps = await fetchSubtaskSteps("t", "r", "A", 500);
|
|
|
|
expect(steps).toHaveLength(1);
|
|
expect(mockedFetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("stops and keeps the accumulated prefix when a full page does not advance the cursor", async () => {
|
|
mockedFetch.mockImplementation(() =>
|
|
Promise.resolve(
|
|
jsonResponse(200, [
|
|
stepEvent(10, 0, "web_search"),
|
|
stepEvent(11, 1, "read_file"),
|
|
]),
|
|
),
|
|
);
|
|
|
|
const steps = await fetchSubtaskSteps("t", "r", "A", 2);
|
|
|
|
// The repeated page is dropped instead of being re-requested up to the
|
|
// 100-page bound, and the valid first page still reaches the caller.
|
|
expect(steps.map((s) => s.message_index)).toEqual([0, 1]);
|
|
expect(mockedFetch).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
test.each([12, 13])(
|
|
"keeps all prior pages when a later full page ends at non-advancing seq %s",
|
|
async (lastSeq) => {
|
|
mockedFetch
|
|
.mockResolvedValueOnce(
|
|
jsonResponse(200, [
|
|
stepEvent(10, 0, "web_search"),
|
|
stepEvent(11, 1, "read_file"),
|
|
]),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse(200, [
|
|
stepEvent(12, 2, "bash"),
|
|
stepEvent(13, 3, "write_file"),
|
|
]),
|
|
)
|
|
.mockImplementation(() =>
|
|
Promise.resolve(
|
|
jsonResponse(200, [
|
|
stepEvent(lastSeq - 1, 4, "web_search"),
|
|
stepEvent(lastSeq, 5, "read_file"),
|
|
]),
|
|
),
|
|
);
|
|
|
|
const steps = await fetchSubtaskSteps("t", "r", "A", 2);
|
|
|
|
expect(steps.map((s) => s.message_index)).toEqual([0, 1, 2, 3]);
|
|
expect(mockedFetch).toHaveBeenCalledTimes(3);
|
|
expect(mockedFetch.mock.calls[1]![0] as string).toContain("after_seq=11");
|
|
expect(mockedFetch.mock.calls[2]![0] as string).toContain("after_seq=13");
|
|
},
|
|
);
|
|
|
|
test("throws when a page request fails", async () => {
|
|
mockedFetch.mockResolvedValueOnce(jsonResponse(500, { detail: "boom" }));
|
|
|
|
await expect(fetchSubtaskSteps("t", "r", "A")).rejects.toThrow();
|
|
});
|
|
});
|