mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-13 16:28:38 +00:00
* feat: add composer input polishing * Revert "Merge branch 'main' into feat/input-polish" This reverts commit 5b6ceccf0db3092bc62fde3b05e7816829601756, reversing changes made to 45fbc57fef5fa5fd878cf0176c37f3e3bc7ebef6. * Merge main into feat/input-polish * style(frontend): format input helper polish guard * fix(input-polish): address composer polish review findings Frontend - Add a cancel affordance to the in-flight polish status pill that calls abortInputPolishRequest(), so a slow/hung provider no longer hard-locks the composer for up to stream_chunk_timeout with a page reload (and draft loss) as the only escape. - Reset promptHistoryIndexRef/promptHistoryDraftRef when a rewrite is applied (and on undo), so a stale history-browse index can no longer let the next ArrowDown silently overwrite the polished draft. - Disable polishing while an open human-input card is present, matching the frontend/AGENTS.md rule that composer entry points defer to the card so card-reply metadata is preserved. - canPolishInput now reuses parseGoalCommand/parseCompactCommand instead of a third hardcoded reserved-command regex, and drops the phantom /help entry (no /help parser exists in the composer), so future builtins only need to be taught to the existing parsers. Backend - Extract the non-graph one-shot LLM path (build model + inject Langfuse metadata + system/user invoke + text extract) into deerflow.utils.oneshot_llm.run_oneshot_llm, shared by the input-polish and suggestions routers so tracing-metadata and invocation shape cannot drift between the two copies. - strip_think_blocks gains truncate_unclosed (default True, preserving the suggestions/goal JSON-prep behavior); input polish passes False so a draft that legitimately contains a literal <think> substring is no longer truncated into a partial rewrite or a spurious 503. - Validate the empty-check and max_chars boundary against the same stripped view of the draft that is sent to the model, so the user-facing length boundary and the model input can no longer disagree. Tests / docs - Backend: literal-<think> preservation, whitespace-only rejection, and normalized-length/model-input agreement cases; suggestions tests repoint the create_chat_model patch to the shared helper module. - Frontend: helper unit tests updated for the /help/reserved-command change; a new Playwright case covers cancelling an in-flight polish request. - backend/AGENTS.md documents the shared one-shot helper and the polish normalization/think-tag behavior. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
345 lines
10 KiB
TypeScript
345 lines
10 KiB
TypeScript
import { describe, expect, it } from "@rstest/core";
|
|
|
|
import {
|
|
abortGoalRequest,
|
|
beginGoalRequest,
|
|
canPolishInput,
|
|
createGoalRequestState,
|
|
findSuggestionTemplatePlaceholder,
|
|
finishGoalRequest,
|
|
getInputSubmitAction,
|
|
getLeadingSlashSkillQuery,
|
|
getMatchingSkillSuggestions,
|
|
isAbortError,
|
|
isCurrentGoalRequest,
|
|
parseCompactCommand,
|
|
parseGoalCommand,
|
|
readGoalResponseError,
|
|
type SlashSuggestion,
|
|
} from "@/components/workspace/input-box-helpers";
|
|
import type { Skill } from "@/core/skills";
|
|
|
|
function makeSkill(name: string, enabled = true): Skill {
|
|
return {
|
|
name,
|
|
description: `${name} description`,
|
|
enabled,
|
|
} as Skill;
|
|
}
|
|
|
|
// Builtin command names are bare (no leading slash); the composer renders them
|
|
// as `/${name}`. Mirror that shape here.
|
|
const builtins: SlashSuggestion[] = [
|
|
{
|
|
name: "goal",
|
|
description: "Set, show, or clear an active goal",
|
|
kind: "builtin",
|
|
},
|
|
{ name: "new", description: "Start a new thread", kind: "builtin" },
|
|
];
|
|
|
|
describe("parseGoalCommand", () => {
|
|
it("returns status for a bare /goal", () => {
|
|
expect(parseGoalCommand("/goal")).toEqual({ kind: "status" });
|
|
expect(parseGoalCommand(" /goal ")).toEqual({ kind: "status" });
|
|
});
|
|
|
|
it("treats clear/reset/off as clear (case-insensitive)", () => {
|
|
expect(parseGoalCommand("/goal clear")).toEqual({ kind: "clear" });
|
|
expect(parseGoalCommand("/GOAL Reset")).toEqual({ kind: "clear" });
|
|
expect(parseGoalCommand("/goal off")).toEqual({ kind: "clear" });
|
|
});
|
|
|
|
it("captures the objective for /goal <text>", () => {
|
|
expect(parseGoalCommand("/goal ship the feature")).toEqual({
|
|
kind: "set",
|
|
objective: "ship the feature",
|
|
});
|
|
});
|
|
|
|
it("returns null when the input is not a /goal command", () => {
|
|
expect(parseGoalCommand("/goalkeeper do thing")).toBeNull();
|
|
expect(parseGoalCommand("hello")).toBeNull();
|
|
expect(parseGoalCommand("/new")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("parseCompactCommand", () => {
|
|
it("matches compact commands", () => {
|
|
expect(parseCompactCommand("/compact")).toBe(true);
|
|
expect(parseCompactCommand(" /context compact ")).toBe(true);
|
|
expect(parseCompactCommand("/CONTEXT COMPACT")).toBe(true);
|
|
});
|
|
|
|
it("rejects non-compact commands", () => {
|
|
expect(parseCompactCommand("/compact now")).toBe(false);
|
|
expect(parseCompactCommand("/context")).toBe(false);
|
|
expect(parseCompactCommand("compact")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("getInputSubmitAction", () => {
|
|
it("handles /goal commands before the streaming stop shortcut", () => {
|
|
expect(
|
|
getInputSubmitAction({
|
|
text: "/goal ",
|
|
fileCount: 0,
|
|
status: "streaming",
|
|
}),
|
|
).toEqual({ kind: "goal", command: { kind: "status" } });
|
|
});
|
|
|
|
it("handles /goal set commands before the streaming stop shortcut", () => {
|
|
expect(
|
|
getInputSubmitAction({
|
|
text: "/goal finish the work",
|
|
fileCount: 0,
|
|
status: "streaming",
|
|
}),
|
|
).toEqual({
|
|
kind: "goal",
|
|
command: { kind: "set", objective: "finish the work" },
|
|
});
|
|
});
|
|
|
|
it("keeps ordinary streaming submits as stop", () => {
|
|
expect(
|
|
getInputSubmitAction({
|
|
text: "hello",
|
|
fileCount: 0,
|
|
status: "streaming",
|
|
}),
|
|
).toEqual({ kind: "stop" });
|
|
});
|
|
|
|
it("does not treat /goal text with attachments as a goal command", () => {
|
|
expect(
|
|
getInputSubmitAction({
|
|
text: "/goal ",
|
|
fileCount: 1,
|
|
status: "ready",
|
|
}),
|
|
).toEqual({ kind: "message" });
|
|
});
|
|
|
|
it("handles compact commands", () => {
|
|
expect(
|
|
getInputSubmitAction({
|
|
text: "/compact",
|
|
fileCount: 0,
|
|
status: "ready",
|
|
}),
|
|
).toEqual({ kind: "compact" });
|
|
expect(
|
|
getInputSubmitAction({
|
|
text: "/context compact",
|
|
fileCount: 0,
|
|
status: "ready",
|
|
}),
|
|
).toEqual({ kind: "compact" });
|
|
});
|
|
|
|
it("does not treat compact commands with attachments as compact", () => {
|
|
expect(
|
|
getInputSubmitAction({
|
|
text: "/compact",
|
|
fileCount: 1,
|
|
status: "ready",
|
|
}),
|
|
).toEqual({ kind: "message" });
|
|
});
|
|
|
|
it("ignores empty ready submits", () => {
|
|
expect(
|
|
getInputSubmitAction({
|
|
text: " ",
|
|
fileCount: 0,
|
|
status: "ready",
|
|
}),
|
|
).toEqual({ kind: "empty" });
|
|
});
|
|
});
|
|
|
|
describe("canPolishInput", () => {
|
|
it("requires non-empty input", () => {
|
|
expect(canPolishInput("")).toBe(false);
|
|
expect(canPolishInput(" ")).toBe(false);
|
|
});
|
|
|
|
it("allows ordinary text and slash skill prompts", () => {
|
|
expect(canPolishInput("make this clearer")).toBe(true);
|
|
expect(canPolishInput("/web-dev build a polished page")).toBe(true);
|
|
expect(canPolishInput("/goalkeeper do thing")).toBe(true);
|
|
expect(canPolishInput("/helper explain this")).toBe(true);
|
|
// `/help` is not a real builtin command in the composer, so it stays
|
|
// eligible like any other slash skill prompt.
|
|
expect(canPolishInput("/help")).toBe(true);
|
|
expect(canPolishInput("/help me")).toBe(true);
|
|
});
|
|
|
|
it("blocks reserved builtin commands", () => {
|
|
expect(canPolishInput("/goal")).toBe(false);
|
|
expect(canPolishInput("/goal ship this feature")).toBe(false);
|
|
expect(canPolishInput("/goal clear")).toBe(false);
|
|
expect(canPolishInput("/compact")).toBe(false);
|
|
expect(canPolishInput("/context compact")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("getLeadingSlashSkillQuery", () => {
|
|
it("returns the query for a leading slash token", () => {
|
|
expect(getLeadingSlashSkillQuery("/rev")).toBe("rev");
|
|
expect(getLeadingSlashSkillQuery("/")).toBe("");
|
|
});
|
|
|
|
it("returns null when there is no leading slash or the token is not bare", () => {
|
|
expect(getLeadingSlashSkillQuery("rev")).toBeNull();
|
|
expect(getLeadingSlashSkillQuery("/rev now")).toBeNull();
|
|
expect(getLeadingSlashSkillQuery("/a/b")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("getMatchingSkillSuggestions", () => {
|
|
it("excludes disabled skills and ranks prefix matches first", () => {
|
|
const skills = [
|
|
makeSkill("deep-research"),
|
|
makeSkill("review"),
|
|
makeSkill("reviewer-disabled", false),
|
|
];
|
|
|
|
const result = getMatchingSkillSuggestions(skills, "rev", []);
|
|
|
|
expect(result.map((s) => s.name)).toEqual(["review"]);
|
|
expect(result.every((s) => s.kind === "skill")).toBe(true);
|
|
});
|
|
|
|
it("includes matching builtin commands after skills", () => {
|
|
const result = getMatchingSkillSuggestions(
|
|
[makeSkill("goal-helper")],
|
|
"goal",
|
|
builtins,
|
|
);
|
|
|
|
expect(result.map((s) => s.name)).toContain("goal-helper");
|
|
expect(result.map((s) => s.name)).toContain("goal");
|
|
});
|
|
|
|
it("excludes skills that collide with builtin command names", () => {
|
|
const result = getMatchingSkillSuggestions(
|
|
[makeSkill("goal"), makeSkill("goal-helper")],
|
|
"goal",
|
|
builtins,
|
|
);
|
|
|
|
expect(result.map((s) => `${s.kind}:${s.name}`)).toEqual([
|
|
"skill:goal-helper",
|
|
"builtin:goal",
|
|
]);
|
|
});
|
|
|
|
it("caps the number of suggestions", () => {
|
|
const skills = Array.from({ length: 10 }, (_, i) =>
|
|
makeSkill(`skill-${i}`),
|
|
);
|
|
const result = getMatchingSkillSuggestions(skills, "", []);
|
|
expect(result.length).toBeLessThanOrEqual(6);
|
|
});
|
|
});
|
|
|
|
describe("readGoalResponseError", () => {
|
|
it("returns the detail string when present", async () => {
|
|
const response = {
|
|
status: 422,
|
|
json: async () => ({ detail: "Goal objective must not be empty." }),
|
|
} as unknown as Response;
|
|
expect(await readGoalResponseError(response)).toBe(
|
|
"Goal objective must not be empty.",
|
|
);
|
|
});
|
|
|
|
it("falls back to the HTTP status when detail is missing or unparseable", async () => {
|
|
const noDetail = {
|
|
status: 500,
|
|
json: async () => ({}),
|
|
} as unknown as Response;
|
|
expect(await readGoalResponseError(noDetail)).toBe("HTTP 500");
|
|
|
|
const broken = {
|
|
status: 503,
|
|
json: async () => {
|
|
throw new Error("not json");
|
|
},
|
|
} as unknown as Response;
|
|
expect(await readGoalResponseError(broken)).toBe("HTTP 503");
|
|
});
|
|
});
|
|
|
|
describe("goal request lifecycle", () => {
|
|
it("aborts a pending goal request when the thread changes and blocks stale updates", () => {
|
|
const state = createGoalRequestState();
|
|
const first = beginGoalRequest(state, "thread-1");
|
|
const updates: string[] = [];
|
|
|
|
abortGoalRequest(state);
|
|
const second = beginGoalRequest(state, "thread-2");
|
|
|
|
if (isCurrentGoalRequest(state, first, "thread-1")) {
|
|
updates.push("thread-1");
|
|
}
|
|
if (isCurrentGoalRequest(state, second, "thread-2")) {
|
|
updates.push("thread-2");
|
|
}
|
|
|
|
expect(first.controller.signal.aborted).toBe(true);
|
|
expect(second.controller.signal.aborted).toBe(false);
|
|
expect(updates).toEqual(["thread-2"]);
|
|
});
|
|
|
|
it("does not let an older request finish a newer one", () => {
|
|
const state = createGoalRequestState();
|
|
const first = beginGoalRequest(state, "thread-1");
|
|
const second = beginGoalRequest(state, "thread-1");
|
|
|
|
finishGoalRequest(state, first);
|
|
|
|
expect(isCurrentGoalRequest(state, second, "thread-1")).toBe(true);
|
|
});
|
|
|
|
it("recognizes abort-shaped errors", () => {
|
|
expect(isAbortError(new DOMException("aborted", "AbortError"))).toBe(true);
|
|
expect(
|
|
isAbortError(Object.assign(new Error("aborted"), { name: "AbortError" })),
|
|
).toBe(true);
|
|
expect(isAbortError(new Error("other"))).toBe(false);
|
|
});
|
|
|
|
it("supports compact request staleness guards with the same lifecycle", () => {
|
|
const state = createGoalRequestState();
|
|
const compact = beginGoalRequest(state, "thread-1");
|
|
|
|
const replacement = beginGoalRequest(state, "thread-1");
|
|
|
|
expect(compact.controller.signal.aborted).toBe(true);
|
|
expect(isCurrentGoalRequest(state, compact, "thread-1")).toBe(false);
|
|
expect(isCurrentGoalRequest(state, replacement, "thread-1")).toBe(true);
|
|
|
|
finishGoalRequest(state, replacement);
|
|
|
|
expect(isCurrentGoalRequest(state, replacement, "thread-1")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("findSuggestionTemplatePlaceholder", () => {
|
|
it("locates a topic/source placeholder", () => {
|
|
const found = findSuggestionTemplatePlaceholder("Research [topic] deeply");
|
|
expect(found).not.toBeNull();
|
|
expect(
|
|
found && "Research [topic] deeply".slice(found.start, found.end),
|
|
).toBe("[topic]");
|
|
});
|
|
|
|
it("returns null when no placeholder is present", () => {
|
|
expect(findSuggestionTemplatePlaceholder("no placeholder here")).toBeNull();
|
|
});
|
|
});
|