359 lines
12 KiB
TypeScript
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);
|
|
});
|
|
}
|
|
});
|