Files
2026-07-13 11:59:58 +08:00

359 lines
12 KiB
TypeScript

import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import type { Message } from "@langchain/langgraph-sdk";
import { describe, expect, it } from "@rstest/core";
import {
SUBAGENT_ERROR_KEY,
SUBAGENT_MODEL_NAME_KEY,
SUBAGENT_RESULT_BRIEF_KEY,
SUBAGENT_STATUS_KEY,
SUBAGENT_STOP_REASON_KEY,
SUBAGENT_TOKEN_USAGE_KEY,
derivePendingSubtaskStatus,
hasSubtaskToolResult,
parseSubtaskResult,
} from "@/core/tasks/subtask-result";
interface ContractFile {
valid_status_values: string[];
valid_stop_reason_values: string[];
}
const CONTRACT_PATH = resolve(
__dirname,
"../../../../../contracts/subagent_status_contract.json",
);
const CONTRACT: ContractFile = JSON.parse(
readFileSync(CONTRACT_PATH, "utf-8"),
) as ContractFile;
describe("parseSubtaskResult", () => {
it("uses legacy task result text when structured metadata is absent", () => {
expect(
parseSubtaskResult(
"Task Succeeded. Result: investigated and produced a 3-page report",
),
).toEqual({
status: "completed",
result: "investigated and produced a 3-page report",
});
expect(
parseSubtaskResult(
"Task failed. Error: underlying tool raised RuntimeError",
),
).toEqual({
status: "failed",
error: "Error: underlying tool raised RuntimeError",
});
expect(parseSubtaskResult("Task cancelled by user.")).toEqual({
status: "failed",
error: "Task cancelled by user.",
});
expect(parseSubtaskResult("Task timed out. Error: 900 seconds")).toEqual({
status: "failed",
error: "Task timed out. Error: 900 seconds",
});
expect(
parseSubtaskResult(
"Task polling timed out after 15 minutes. Status: RUNNING",
),
).toEqual({
status: "failed",
error: "Task polling timed out after 15 minutes. Status: RUNNING",
});
expect(
parseSubtaskResult("Error: Tool 'task' failed with TypeError: boom"),
).toEqual({
status: "failed",
error: "Error: Tool 'task' failed with TypeError: boom",
});
});
it("keeps unknown content-only task results in progress", () => {
const parsed = parseSubtaskResult("partial streaming chunk");
expect(parsed.status).toBe("in_progress");
expect(parsed.error).toBeUndefined();
expect(parsed.result).toBeUndefined();
});
});
describe("hasSubtaskToolResult", () => {
it("matches a task tool call to its ToolMessage", () => {
const messages = [
{ type: "ai" },
{ type: "tool", tool_call_id: "call_task_1" },
] as Message[];
expect(hasSubtaskToolResult("call_task_1", messages)).toBe(true);
});
it("returns false when a task tool call has no ToolMessage", () => {
const messages = [
{ type: "ai" },
{ type: "tool", tool_call_id: "call_other" },
] as Message[];
expect(hasSubtaskToolResult("call_task_1", messages)).toBe(false);
});
});
describe("derivePendingSubtaskStatus", () => {
it("keeps a task in progress while its own assistant turn is loading", () => {
const messages = [{ type: "ai" }] as Message[];
expect(derivePendingSubtaskStatus("call_task_1", messages, true)).toBe(
"in_progress",
);
});
it("does not revive an earlier unfinished task during a later turn", () => {
const messages = [{ type: "ai" }] as Message[];
expect(derivePendingSubtaskStatus("call_task_1", messages, false)).toBe(
"failed",
);
});
it("leaves result parsing to the ToolMessage path when a result exists", () => {
const messages = [
{ type: "ai" },
{ type: "tool", tool_call_id: "call_task_1" },
] as Message[];
expect(derivePendingSubtaskStatus("call_task_1", messages, false)).toBe(
"in_progress",
);
});
});
/**
* Structured-status path (bytedance/deer-flow#3146).
*
* The backend stamps `ToolMessage.additional_kwargs.subagent_status`
* directly. The frontend should prefer that over reverse-engineering it
* from the content string.
*/
describe("parseSubtaskResult — structured additional_kwargs (preferred path)", () => {
it("uses additional_kwargs.subagent_status when present", () => {
const parsed = parseSubtaskResult("Task Succeeded. Result: foo", {
[SUBAGENT_STATUS_KEY]: "completed",
});
expect(parsed.status).toBe("completed");
});
it("restores terminal model and token usage metadata", () => {
expect(
parseSubtaskResult("Task Succeeded. Result: done", {
[SUBAGENT_STATUS_KEY]: "completed",
[SUBAGENT_MODEL_NAME_KEY]: "claude-3-7-sonnet",
[SUBAGENT_TOKEN_USAGE_KEY]: {
input_tokens: 100,
output_tokens: 20,
total_tokens: 120,
},
}),
).toMatchObject({
status: "completed",
modelName: "claude-3-7-sonnet",
usage: {
inputTokens: 100,
outputTokens: 20,
totalTokens: 120,
},
});
});
it("collapses cancelled / timed_out / polling_timed_out to failed for the card UI", () => {
for (const backendStatus of [
"cancelled",
"timed_out",
"polling_timed_out",
]) {
const parsed = parseSubtaskResult("anything at all", {
[SUBAGENT_STATUS_KEY]: backendStatus,
});
expect(parsed.status).toBe("failed");
}
});
it("renders legacy max_turns_reached (checkpointed under #3949) as a terminal failed pill, not spinning in_progress", () => {
// Phase 1 wrote `subagent_status: "max_turns_reached"` into ToolMessage
// additional_kwargs, which is checkpointed in thread history. Phase 2 (#3980)
// stopped producing it, but old turns still carry it. Without the deprecated
// alias, readStructuredStatus returns null while hasStructuredSubagentMetadata
// stays true (sibling keys present) -> parseSubtaskResult returns
// { status: "in_progress" } and the card spins forever. The alias keeps it
// terminal, matching how Phase 1 itself rendered the value.
const parsed = parseSubtaskResult("ignored content", {
[SUBAGENT_STATUS_KEY]: "max_turns_reached",
[SUBAGENT_ERROR_KEY]: "Reached max_turns=150",
[SUBAGENT_RESULT_BRIEF_KEY]: "investigated 3 of 5 sources",
});
expect(parsed.status).toBe("failed");
expect(parsed.error).toBe("Reached max_turns=150");
// result only attaches for the completed pill; legacy data renders as failed.
expect(parsed.result).toBeUndefined();
});
it("surfaces stop_reason on a capped run while keeping a normal pill status", () => {
// bytedance/deer-flow#3875 Phase 2: a token-capped run produced a final
// answer, so it is `completed` with the cap on the additive
// `subagent_stop_reason` field. The card stays green; stopReason carries
// the cap detail for a future badge, and the recovered partial result
// lives on subagent_result_brief.
const parsed = parseSubtaskResult("ignored content", {
[SUBAGENT_STATUS_KEY]: "completed",
[SUBAGENT_RESULT_BRIEF_KEY]: "investigated 3 of 5 sources",
[SUBAGENT_STOP_REASON_KEY]: "token_capped",
});
expect(parsed.status).toBe("completed");
expect(parsed.result).toBe("investigated 3 of 5 sources");
expect(parsed.stopReason).toBe("token_capped");
});
it("surfaces stop_reason on a turn-capped run that produced no usable result", () => {
// No usable partial -> the backend stamps `failed` + turn_capped. The card
// goes red; stopReason still carries the cap so a future badge can say so.
const parsed = parseSubtaskResult("ignored content", {
[SUBAGENT_STATUS_KEY]: "failed",
[SUBAGENT_ERROR_KEY]: "Reached max_turns=150",
[SUBAGENT_STOP_REASON_KEY]: "turn_capped",
});
expect(parsed.status).toBe("failed");
expect(parsed.error).toBe("Reached max_turns=150");
expect(parsed.stopReason).toBe("turn_capped");
});
it("ignores an unknown subagent_stop_reason value", () => {
// An unrecognized stop_reason is dropped so a stale frontend never renders
// a bogus cap badge.
const parsed = parseSubtaskResult("ignored content", {
[SUBAGENT_STATUS_KEY]: "completed",
[SUBAGENT_STOP_REASON_KEY]: "future_cap_kind",
});
expect(parsed.status).toBe("completed");
expect(parsed.stopReason).toBeUndefined();
});
it("uses subagent_error when supplied", () => {
const parsed = parseSubtaskResult("ignored content", {
[SUBAGENT_STATUS_KEY]: "failed",
[SUBAGENT_ERROR_KEY]: "boom from backend",
});
expect(parsed.status).toBe("failed");
expect(parsed.error).toBe("boom from backend");
});
it("ignores empty / non-string subagent_error", () => {
const parsed = parseSubtaskResult("ignored content", {
[SUBAGENT_STATUS_KEY]: "failed",
[SUBAGENT_ERROR_KEY]: "",
});
expect(parsed.status).toBe("failed");
expect(parsed.error).toBeUndefined();
});
it("ignores terminal-looking content when partial structured metadata is present", () => {
const parsed = parseSubtaskResult("Task Succeeded. Result: foo", {
[SUBAGENT_RESULT_BRIEF_KEY]: "structured result without status",
});
expect(parsed.status).toBe("in_progress");
expect(parsed.result).toBeUndefined();
});
it("ignores terminal-looking content when the structured status is unknown", () => {
const parsed = parseSubtaskResult("Task Succeeded. Result: foo", {
[SUBAGENT_STATUS_KEY]: "renamed_in_v3",
});
expect(parsed.status).toBe("in_progress");
});
it("structured status overrides misleading content", () => {
const parsed = parseSubtaskResult("Task Succeeded. Result: this is a lie", {
[SUBAGENT_STATUS_KEY]: "failed",
});
expect(parsed.status).toBe("failed");
expect(parsed.result).toBeUndefined();
expect(parsed.error).toBeUndefined();
});
it("does not back-fill result from content when structured result metadata is missing", () => {
const parsed = parseSubtaskResult("Task Succeeded. Result: text-only", {
[SUBAGENT_STATUS_KEY]: "completed",
});
expect(parsed.status).toBe("completed");
expect(parsed.result).toBeUndefined();
});
it("uses bounded structured result metadata when present for completed task", () => {
const parsed = parseSubtaskResult("Task Succeeded. Result: text body", {
[SUBAGENT_STATUS_KEY]: "completed",
subagent_result_brief: "structured",
subagent_result_sha256: "a".repeat(64),
});
expect(parsed.status).toBe("completed");
expect(parsed.result).toBe("structured");
});
it("does not back-fill error from content when structured error metadata is missing", () => {
const parsed = parseSubtaskResult(
"Error: Tool 'task' failed with TypeError: boom",
{
[SUBAGENT_STATUS_KEY]: "failed",
},
);
expect(parsed.status).toBe("failed");
expect(parsed.error).toBeUndefined();
});
it("leaves `error` undefined when structured says failed with no error and unrecognised text", () => {
// Don't dump arbitrary content into the error field — better to render
// an empty `failed` pill than to surface noise.
const parsed = parseSubtaskResult("partial streaming chunk", {
[SUBAGENT_STATUS_KEY]: "failed",
});
expect(parsed.status).toBe("failed");
expect(parsed.error).toBeUndefined();
});
});
/**
* Cross-language contract test for the structured subagent status field.
* The backend and frontend share the enum values, but task result text is
* no longer part of the wire contract.
*/
describe("parseSubtaskResult — shared contract fixture", () => {
const expectedCardStatus = (backendStatus: string): string => {
if (backendStatus === "completed") return "completed";
return "failed";
};
for (const status of CONTRACT.valid_status_values) {
it(`maps structured status: ${status}`, () => {
const parsed = parseSubtaskResult("ignored content", {
[SUBAGENT_STATUS_KEY]: status,
});
expect(parsed.status).toBe(expectedCardStatus(status));
});
}
for (const stopReason of CONTRACT.valid_stop_reason_values) {
it(`carries stop_reason through unchanged: ${stopReason}`, () => {
const parsed = parseSubtaskResult("ignored content", {
[SUBAGENT_STATUS_KEY]: "completed",
[SUBAGENT_RESULT_BRIEF_KEY]: "partial work",
[SUBAGENT_STOP_REASON_KEY]: stopReason,
});
expect(parsed.stopReason).toBe(stopReason);
});
}
});