deer-flow/frontend/tests/e2e/goal-status.spec.ts
Totoro c48447a4ae
fix(runtime): present outputs before a loop wrap-up and explain step-limit stops (#7588)
* fix(runtime): present outputs before a loop wrap-up and explain step-limit stops

Loop-detection warnings now ask the agent to present the files it already
created in /mnt/user-data/outputs before its final answer, when
present_files is available.

A run that exhausts recursion_limit now ends with stop_reason
recursion_capped and a plain message that lists outputs it created but
did not present. An active goal stands down without an evaluator call.
The /wait error envelope keeps the GraphRecursionError name, and IM
channels reply with the step-limit message instead of a generic failure.

* docs(changelog): note run-end delivery and step-limit stops (#7588)

* test(runtime): stop pinning LangGraph's step count in the step-limit test

The produced paths and the message already follow the observed model
calls, so the exact count only pinned LangGraph's per-step accounting.
Also note on the step-limit goal branch that its caller passes no
evaluator factory, so it has to stay ahead of the evaluator section.

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-27 10:34:34 +08:00

712 lines
24 KiB
TypeScript

import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { expect, test, type Page, type Route } from "@playwright/test";
import type { GoalState, ThreadGoalOutcome } from "@/core/threads/types";
import {
MOCK_RUN_ID,
mockGoal,
mockGoalOutcome,
mockLangGraphAPI,
runStreamThreadId,
visibleRunInputMessages,
type MockAPIOptions,
type MockThread,
} from "./utils/mock-api";
import { expectNoRawIdentifiers } from "./utils/readable";
/**
* A Gateway-like run stream. The response names the created run
* (`Content-Location`, which moves a new chat to its own URL), and each
* `values` frame is written `gapMs` after the previous one, so the goal bar
* renders every state a live goal run passes through. As elsewhere in these
* mocks, messages ride the `values` frames. The Gateway streams a chat run's
* messages as message events and sends `values` only for goal writes, so a
* frame that adds messages and leaves the goal as it was stands in for those
* events. `saved` runs before the last frame: by then the backend has stored
* the run's final state for the post-run history refetch.
*/
async function startRunStream(gapMs: number) {
let run: {
threadId: string;
steps: Record<string, unknown>[];
saved: () => void;
} = { threadId: "", steps: [], saved: () => undefined };
const server = createServer((_request, response) => {
const { threadId, steps, saved } = run;
response.writeHead(200, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Expose-Headers": "Content-Location",
"Cache-Control": "no-cache",
"Content-Location": `/threads/${threadId}/runs/${MOCK_RUN_ID}`,
"Content-Type": "text/event-stream",
});
const frame = (event: string, data: unknown) =>
`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
response.write(
frame("metadata", { run_id: MOCK_RUN_ID, thread_id: threadId }),
);
const timers = steps.map((values, index) =>
setTimeout(() => {
if (index < steps.length - 1) {
response.write(frame("values", values));
return;
}
saved();
response.end(frame("values", values) + frame("end", {}));
}, index * gapMs),
);
response.once("close", () => timers.forEach(clearTimeout));
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
const { port } = server.address() as AddressInfo;
return {
/** Answer the run `route` starts with these `values` frames. */
serve(route: Route, steps: Record<string, unknown>[], saved: () => void) {
run = { threadId: runStreamThreadId(route), steps, saved };
return route.continue({ url: `http://127.0.0.1:${port}/runs/stream` });
},
async close() {
server.closeAllConnections();
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
},
};
}
// Both chat pages wire the goal bar and the edit lock themselves, so every
// case runs on each of them.
const CHAT_PAGES: {
name: string;
path: string;
options: MockAPIOptions;
agentName?: string;
}[] = [
{ name: "chat", path: "/workspace/chats", options: {} },
{
name: "agent chat",
path: "/workspace/agents/test-agent/chats",
options: {
agents: [
{
name: "test-agent",
description: "A test agent for E2E tests",
system_prompt: "You are a test agent.",
},
],
},
agentName: "test-agent",
},
];
const THREAD_ID = "00000000-0000-0000-0000-000000000601";
const BUSY = "A run is still going in this chat. Try again when it finishes.";
function goalBar(page: Page) {
return page.getByRole("region", { name: "Goal status" });
}
function composer(page: Page) {
return page.getByPlaceholder(/how can i assist you/i);
}
/** A question card the agent left open (`ask_clarification`). */
const OPEN_QUESTION = [
{
type: "ai",
id: "goal-ask",
content: "",
tool_calls: [
{
id: "call-room",
name: "ask_clarification",
args: {
question: "Which time works for you?",
clarification_type: "missing_info",
options: ["10:00", "14:00"],
},
},
],
},
{
type: "tool",
id: "goal-question",
name: "ask_clarification",
tool_call_id: "call-room",
content: "Which time works for you?",
artifact: {
human_input: {
version: 1,
kind: "human_input_request",
source: "ask_clarification",
request_id: "clarification:call-room",
tool_call_id: "call-room",
clarification_type: "missing_info",
question: "Which time works for you?",
input_mode: "choice_with_other",
options: [
{ id: "option-1", label: "10:00", value: "10:00" },
{ id: "option-2", label: "14:00", value: "14:00" },
],
},
},
},
];
const STOOD_DOWN = [
{
name: "a loop safety stop",
objective: "Finish the report",
goal: {
continuation_count: 0,
last_evaluation: {
satisfied: false,
blocker: "goal_not_met_yet",
reason: "The report is unfinished.",
stand_down_reason: "loop_capped",
},
},
reply: [
{
type: "ai",
id: "goal-ai",
content: "Repeated inspections stopped before the report was finished.",
},
],
chip: "Stopped",
detail:
"Repeated tool calls triggered a safety stop. Adjust the approach and send a new message to retry; your goal stays active.",
note: "The report is unfinished.",
},
{
name: "a step-limit stop",
objective: "Write the report in six parts",
goal: {
continuation_count: 0,
// The host writes this reason itself; it is not the checker's note.
last_evaluation: {
satisfied: false,
blocker: "run_failed",
reason: "The run reached its step limit before finishing.",
stand_down_reason: "recursion_capped",
},
},
reply: [
{ type: "ai", id: "goal-ai", content: "Writing part 3 of the report." },
],
chip: "Stopped",
detail:
"The run reached its step limit before finishing. Send a new message to continue; your goal stays active.",
note: null,
},
{
name: "a goal that used every automatic follow-up",
objective: "Make every test in the suite pass",
goal: {
continuation_count: 8,
last_evaluation: {
satisfied: false,
blocker: "goal_not_met_yet",
reason: "Two tests still fail.",
stand_down_reason: "max_continuations_reached",
},
},
reply: [
{
type: "ai",
id: "goal-ai",
content: "Six of the eight tests pass now.",
},
],
chip: "Stopped",
detail:
"Continuation limit reached · 8/8. It won't auto-continue again. Reply to keep going, or set the goal again with /goal <condition> to start a fresh count (this also starts a new run).",
note: "Two tests still fail.",
},
{
name: "a goal that needs details before any follow-up",
objective: "Book a meeting room for Friday",
goal: {
last_evaluation: {
satisfied: false,
blocker: "needs_user_input",
reason: "The user has to pick a time first.",
stand_down_reason: "blocked:needs_user_input",
},
},
reply: [
{ type: "ai", id: "goal-ai", content: "Which time works for you?" },
],
chip: "Waiting for you",
detail: "Reply with the missing details to continue.",
note: "The user has to pick a time first.",
},
{
name: "a goal waiting on an open question",
objective: "Book a meeting room for Friday",
goal: {
// The host writes this reason itself; it is not the checker's note.
last_evaluation: {
satisfied: false,
blocker: "needs_user_input",
reason:
"The turn ended on a question to the user that has not been answered.",
stand_down_reason: "blocked:needs_user_input",
},
},
reply: OPEN_QUESTION,
chip: "Waiting for you",
detail: "Answer the question above to continue.",
note: null,
},
] satisfies {
name: string;
objective: string;
goal: Partial<GoalState>;
reply: unknown[];
chip: string;
detail: string;
note: string | null;
}[];
for (const chatPage of CHAT_PAGES) {
const threadUrl = `${chatPage.path}/${THREAD_ID}`;
/** A chat with one finished turn, optionally under a stood-down goal. */
function chatThread({
objective = "Summarize the README",
goal,
reply = [
{ type: "ai", id: "goal-ai", content: "The README covers setup." },
],
}: {
objective?: string;
goal?: Partial<GoalState>;
reply?: unknown[];
} = {}): MockThread {
return {
thread_id: THREAD_ID,
title: "Goal chat",
agent_name: chatPage.agentName,
messages: [
{
type: "human",
id: "goal-human",
content: [{ type: "text", text: objective }],
},
...reply,
],
...(goal ? { goal: mockGoal(objective, goal) } : {}),
};
}
test.describe(`Goal bar on the ${chatPage.name} page`, () => {
for (const scenario of STOOD_DOWN) {
test(`explains ${scenario.name}, also after a reload`, async ({
page,
}) => {
mockLangGraphAPI(page, {
...chatPage.options,
threads: [chatThread(scenario)],
});
await page.goto(threadUrl);
const bar = goalBar(page);
for (const reload of [false, true]) {
if (reload) {
await page.reload();
}
await expect(bar.getByTestId("goal-status-chip")).toHaveText(
scenario.chip,
{ timeout: 15_000 },
);
await expect(bar.getByTestId("goal-status-detail")).toHaveText(
scenario.detail,
);
await expect(bar).not.toContainText("Continuing");
await expectNoRawIdentifiers(bar);
}
await bar.getByRole("button", { name: "Details" }).click();
const details = bar.getByTestId("goal-status-details");
await expect(details).toContainText(scenario.objective);
if (scenario.note) {
await expect(bar.getByTestId("goal-status-note")).toHaveText(
scenario.note,
);
} else {
await expect(details).not.toContainText("Goal check note");
}
});
}
test("keeps the locked edit pencil visible to the keyboard, and hides it during an upload", async ({
page,
}) => {
mockLangGraphAPI(page, {
...chatPage.options,
threads: [chatThread(STOOD_DOWN[0])],
});
await page.goto(threadUrl);
await expect(goalBar(page).getByTestId("goal-status-chip")).toHaveText(
"Stopped",
{ timeout: 15_000 },
);
// No pointer over the message: only keyboard focus can show its toolbar.
await page.mouse.move(0, 0);
const toolbar = page
.getByTestId("message-toolbar")
.filter({ has: page.getByTestId("message-edit-locked") });
const pencil = toolbar.getByRole("button", { name: "Edit and rerun" });
await toolbar.getByRole("button", { name: "Copy to clipboard" }).focus();
await page.keyboard.press("Tab");
await expect(pencil).toBeFocused();
await expect(pencil).toBeDisabled();
await expect(toolbar).toHaveCSS("opacity", "1");
await expect(
page.getByRole("tooltip").filter({
hasText:
"Editing is off while a goal is set. Run /goal clear to edit.",
}),
).toBeVisible();
await page.keyboard.press("Enter");
await expect(
page.getByRole("button", { name: "Update and rerun" }),
).toBeHidden();
// No turn can be edited while a file uploads, so no pencil shows, not
// even the goal's locked one.
let releaseUpload!: () => void;
const uploadHeld = new Promise<void>((resolve) => {
releaseUpload = resolve;
});
await page.route("**/api/threads/*/uploads", async (route) => {
await uploadHeld;
return route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ detail: "Upload failed." }),
});
});
await page.getByLabel("Upload files").setInputFiles({
name: "notes.txt",
mimeType: "text/plain",
buffer: Buffer.from("notes"),
});
await composer(page).fill("Summarize these notes");
await composer(page).press("Enter");
await expect(page.getByTestId("message-edit-locked")).toHaveCount(0);
releaseUpload();
await expect(page.getByTestId("message-edit-locked")).toHaveCount(1);
});
test("shows Goal met after one automatic follow-up until the chat moves on", async ({
page,
}) => {
const objective = "Write a three-line summary of the README";
const stream = await startRunStream(600);
let runs = 0;
const api = mockLangGraphAPI(page, {
...chatPage.options,
threads: [chatThread()],
runStreamHandler: async (route) => {
runs += 1;
const thread = api.getThread(runStreamThreadId(route))!;
const turn = [
...(thread.messages ?? []),
...visibleRunInputMessages(route),
];
if (runs > 1) {
// A later turn: the backend keeps the record and adds the turn.
const messages = [
...turn,
{ type: "ai", id: "goal-ai-3", content: "Glad it helps." },
];
return stream.serve(
route,
[{ messages, goal_outcome: thread.goal_outcome }],
() => api.upsertThread({ ...thread, messages }),
);
}
const goal = thread.goal as GoalState;
const draft = {
type: "ai",
id: "goal-ai-1",
content: "Draft: the summary has two lines so far.",
};
const continuation = {
type: "human",
id: "goal-continuation-1",
content: `<goal_continuation>\nActive goal: ${objective}\n</goal_continuation>`,
additional_kwargs: {
hide_from_ui: true,
deerflow_goal_continuation: true,
},
};
const reply = {
type: "ai",
id: "goal-ai-2",
content: "Done: the summary now has three lines.",
};
const continuing: GoalState = {
...goal,
continuation_count: 1,
last_evaluation: {
satisfied: false,
blocker: "goal_not_met_yet",
reason: "The summary has two lines, not three.",
run_id: MOCK_RUN_ID,
},
};
const record = mockGoalOutcome(continuing, reply.id, {
reason: "The summary has three lines.",
});
const messages = [...turn, draft, continuation, reply];
// The checkpoint has the submitted turn while the run goes on.
api.upsertThread({ ...thread, messages: turn });
return stream.serve(
route,
[
{ messages: [...turn, draft], goal },
{ messages: [...turn, draft, continuation], goal: continuing },
{ messages, goal: continuing },
// The clear of the met goal: the record, and no `goal` key.
{ messages, goal_outcome: record },
],
() =>
api.upsertThread({
...thread,
goal: null,
goal_outcome: record,
messages,
}),
);
},
});
try {
await page.goto(threadUrl);
const textarea = composer(page);
await expect(page.getByText("The README covers setup.")).toBeVisible({
timeout: 15_000,
});
await textarea.fill(`/goal ${objective}`);
await textarea.press("Enter");
const bar = goalBar(page);
await expect(bar).toContainText("In progress");
await expect(bar).toContainText("Continuing 1/8");
await expect(bar).toHaveAttribute("data-goal-state", "met");
await expect(bar).toContainText(`Goal met${objective}`);
await expect(bar).toContainText("Auto-continued once");
await expect(bar).not.toContainText("Continuing");
await expect(
page.getByText("Done: the summary now has three lines."),
).toBeVisible();
// The goal is no longer active, so edit-and-rerun is back.
await expect(
page.getByRole("button", { name: "Edit and rerun" }),
).toBeEnabled();
await expect(page.getByTestId("message-edit-locked")).toHaveCount(0);
await expectNoRawIdentifiers(bar);
await page.reload();
await expect(bar).toContainText("Goal met", { timeout: 15_000 });
await expect(bar).toContainText("Auto-continued once");
await textarea.fill("Thanks, that works.");
await textarea.press("Enter");
await expect(page.getByText("Glad it helps.")).toBeVisible();
await expect(bar).toBeHidden();
await page.reload();
await expect(page.getByText("Glad it helps.")).toBeVisible({
timeout: 15_000,
});
await expect(bar).toBeHidden();
expect(runs).toBe(2);
} finally {
await stream.close();
}
});
test("replaces the set goal with Goal met when the first run meets it, and Goal met with a new goal", async ({
page,
}) => {
const objective = "Summarize the README in three lines";
const next = "List the README's sections";
const nextReply = "Sections: Setup, Usage, License.";
const stream = await startRunStream(600);
let runs = 0;
let lastRecord: ThreadGoalOutcome | undefined;
const api = mockLangGraphAPI(page, {
...chatPage.options,
// The goal PUT and the run share the chat's own id, as on the Gateway.
honorRequestedThreadId: true,
createdThreadMessages: [],
// Each goal is met on its own first run.
runStreamHandler: async (route) => {
runs += 1;
const thread = api.getThread(runStreamThreadId(route))!;
const turn = [
...(thread.messages ?? []),
...visibleRunInputMessages(route),
];
const reply = {
type: "ai",
id: `goal-ai-${runs}`,
content:
runs === 1 ? "README: setup, usage and license." : nextReply,
};
const messages = [...turn, reply];
const record = mockGoalOutcome(thread.goal as GoalState, reply.id);
const earlier = lastRecord;
lastRecord = record;
return stream.serve(
route,
[
// The turn's message events: the tab keeps the values it had,
// an earlier goal's record included.
{ messages: turn, ...(earlier ? { goal_outcome: earlier } : {}) },
// The clear of the met goal: the record, and no `goal` key at all.
{ messages, goal_outcome: record },
],
() =>
api.upsertThread({
...thread,
goal: null,
goal_outcome: record,
messages,
}),
);
},
});
try {
await page.goto(`${chatPage.path}/new`);
const textarea = composer(page);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill(`/goal ${objective}`);
await textarea.press("Enter");
const bar = goalBar(page);
await expect(bar).toHaveAttribute("data-goal-state", "met");
await expect(bar).toContainText(`Goal met${objective}`);
await expect(bar).not.toContainText("Auto-continued");
await expect(
page.getByText("README: setup, usage and license."),
).toBeVisible();
await expect(page).not.toHaveURL(/\/new$/);
await expect(
page.getByRole("button", { name: "Edit and rerun" }),
).toBeEnabled();
await page.reload();
await expect(bar).toHaveAttribute("data-goal-state", "met", {
timeout: 15_000,
});
await expect(bar).toContainText(`Goal met${objective}`);
// A new goal replaces Goal met at once, and its own run meets it.
await textarea.fill(`/goal ${next}`);
await textarea.press("Enter");
await expect(bar).toHaveAttribute("data-goal-state", "inProgress");
await expect(bar).toContainText(next);
await expect(bar).toHaveAttribute("data-goal-state", "met");
await expect(bar).toContainText(`Goal met${next}`);
await expect(bar).not.toContainText(objective);
await textarea.fill("/goal clear");
await textarea.press("Enter");
await expect(page.getByText("Goal cleared.")).toBeVisible();
await expect(bar).toBeHidden();
await page.reload();
await expect(page.getByText(nextReply)).toBeVisible({
timeout: 15_000,
});
await expect(bar).toBeHidden();
expect(runs).toBe(2);
} finally {
await stream.close();
}
});
test("keeps the /goal draft when a run is still going", async ({
page,
}) => {
const objective = "Finish the quarterly report";
const api = mockLangGraphAPI(page, {
...chatPage.options,
threads: [chatThread()],
});
let runStreams = 0;
page.on("request", (request) => {
if (
request.method() === "POST" &&
request.url().includes("/runs/stream")
) {
runStreams += 1;
}
});
await page.goto(threadUrl);
const textarea = composer(page);
const bar = goalBar(page);
await expect(page.getByText("The README covers setup.")).toBeVisible({
timeout: 15_000,
});
// Setting the goal: the Gateway's 409 keeps the draft for a retry.
api.setGoalWritesBusy(true);
await textarea.fill(`/goal ${objective}`);
await textarea.press("Enter");
await expect(page.getByText(BUSY)).toBeVisible();
await expect(textarea).toHaveValue(`/goal ${objective}`);
await expect(bar).toBeHidden();
expect(runStreams).toBe(0);
api.setGoalWritesBusy(false);
await textarea.press("Enter");
await expect(bar).toContainText(objective);
await expect(textarea).toHaveValue("");
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible();
expect(runStreams).toBe(1);
// Clearing it: the same. The set half's toast closes first, so the busy
// toast below can only be the clear's own.
await expect(page.getByText(BUSY)).toHaveCount(0, { timeout: 10_000 });
api.setGoalWritesBusy(true);
await textarea.fill("/goal clear");
const refused = page.waitForResponse(
(response) =>
response.request().method() === "DELETE" &&
response.url().endsWith("/goal"),
);
await textarea.press("Enter");
expect((await refused).status()).toBe(409);
await expect(page.getByText(BUSY)).toBeVisible();
await expect(textarea).toHaveValue("/goal clear");
await expect(bar).toContainText(objective);
api.setGoalWritesBusy(false);
await textarea.press("Enter");
await expect(page.getByText("Goal cleared.")).toBeVisible();
await expect(bar).toBeHidden();
await expect(textarea).toHaveValue("");
expect(runStreams).toBe(1);
});
});
}