217 lines
6.5 KiB
TypeScript
217 lines
6.5 KiB
TypeScript
import { describe, expect, it } from "@rstest/core";
|
|
|
|
import type { SubtaskStep } from "@/core/tasks/steps";
|
|
import {
|
|
computeNextSubtask,
|
|
isTerminalSubtaskStatus,
|
|
subtaskNotification,
|
|
} from "@/core/tasks/subtask-update";
|
|
import type { Subtask } from "@/core/tasks/types";
|
|
|
|
function baseTask(overrides: Partial<Subtask> = {}): Subtask {
|
|
return {
|
|
id: "t1",
|
|
status: "in_progress",
|
|
subagent_type: "general-purpose",
|
|
description: "research",
|
|
prompt: "do it",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function step(message_index: number): SubtaskStep {
|
|
return {
|
|
kind: "tool",
|
|
message_index,
|
|
text: `step ${message_index}`,
|
|
truncated: false,
|
|
};
|
|
}
|
|
|
|
describe("computeNextSubtask", () => {
|
|
it("merges step deltas into the provided previous, preserving both", () => {
|
|
const previous = baseTask({ steps: [step(1), step(2)] });
|
|
|
|
const { next } = computeNextSubtask(previous, {
|
|
id: "t1",
|
|
steps: [step(3)],
|
|
});
|
|
|
|
// Regression for #3779 stale-closure race: next is derived from whatever
|
|
// `previous` is passed (the functional-update latest state), so concurrently
|
|
// arrived steps are kept, not clobbered by a backfill resolving late.
|
|
expect(next.steps?.map((s) => s.message_index)).toEqual([1, 2, 3]);
|
|
});
|
|
|
|
it("does not drop steps present only on the latest previous", () => {
|
|
// Simulates a backfill that fired when previous had 0 steps, but by the time
|
|
// it resolves the latest previous already has SSE steps 1..2. The backfill
|
|
// brings historical steps 1..3; the merge must retain all, not reset to [].
|
|
const latestPrevious = baseTask({ steps: [step(1), step(2)] });
|
|
|
|
const { next } = computeNextSubtask(latestPrevious, {
|
|
id: "t1",
|
|
steps: [step(1), step(2), step(3)],
|
|
});
|
|
|
|
expect(next.steps?.map((s) => s.message_index)).toEqual([1, 2, 3]);
|
|
});
|
|
|
|
it("keeps the latest cumulative token snapshot when an older event arrives late", () => {
|
|
const previous = baseTask({
|
|
usage: { inputTokens: 200, outputTokens: 40, totalTokens: 240 },
|
|
});
|
|
|
|
const { next } = computeNextSubtask(previous, {
|
|
id: "t1",
|
|
usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 },
|
|
});
|
|
|
|
expect(next.usage).toEqual({
|
|
inputTokens: 200,
|
|
outputTokens: 40,
|
|
totalTokens: 240,
|
|
});
|
|
});
|
|
|
|
it("keeps a terminal status stable against a late in_progress write", () => {
|
|
const previous = baseTask({ status: "completed" });
|
|
|
|
const { next, becameTerminal } = computeNextSubtask(previous, {
|
|
id: "t1",
|
|
status: "in_progress",
|
|
});
|
|
|
|
expect(next.status).toBe("completed");
|
|
expect(becameTerminal).toBe(false);
|
|
});
|
|
|
|
it("flags becameTerminal on the first transition to a terminal status", () => {
|
|
const previous = baseTask({ status: "in_progress" });
|
|
|
|
const { next, becameTerminal } = computeNextSubtask(previous, {
|
|
id: "t1",
|
|
status: "completed",
|
|
result: "done",
|
|
});
|
|
|
|
expect(next.status).toBe("completed");
|
|
expect(next.result).toBe("done");
|
|
expect(becameTerminal).toBe(true);
|
|
});
|
|
|
|
it("handles an undefined previous (first write for a task)", () => {
|
|
const { next, becameTerminal } = computeNextSubtask(undefined, {
|
|
id: "t1",
|
|
status: "in_progress",
|
|
subagent_type: "bash",
|
|
description: "run",
|
|
prompt: "p",
|
|
steps: [step(1)],
|
|
});
|
|
|
|
expect(next.id).toBe("t1");
|
|
expect(next.steps?.map((s) => s.message_index)).toEqual([1]);
|
|
expect(becameTerminal).toBe(false);
|
|
});
|
|
|
|
it("reports changed=false when a terminal update carries identical runtime metadata", () => {
|
|
// The terminal ToolMessage is re-parsed on every MessageList render, so the
|
|
// same modelName/usage arrive again as a *new* object each time. Value-equal
|
|
// re-application must not be flagged as a change or the card loops forever.
|
|
const previous = baseTask({
|
|
status: "completed",
|
|
result: "done",
|
|
modelName: "opus",
|
|
usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 },
|
|
});
|
|
|
|
const { changed } = computeNextSubtask(previous, {
|
|
id: "t1",
|
|
status: "completed",
|
|
result: "done",
|
|
modelName: "opus",
|
|
usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 },
|
|
});
|
|
|
|
expect(changed).toBe(false);
|
|
});
|
|
|
|
it("reports changed=true when runtime metadata actually differs", () => {
|
|
const previous = baseTask({
|
|
status: "completed",
|
|
modelName: "opus",
|
|
usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 },
|
|
});
|
|
|
|
const { changed } = computeNextSubtask(previous, {
|
|
id: "t1",
|
|
status: "completed",
|
|
modelName: "opus",
|
|
usage: { inputTokens: 300, outputTokens: 60, totalTokens: 360 },
|
|
});
|
|
|
|
expect(changed).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("subtaskNotification", () => {
|
|
it("defers a terminal transition (arrives during render, must not setState mid-render)", () => {
|
|
expect(
|
|
subtaskNotification(
|
|
{ id: "t1", status: "completed", modelName: "opus" },
|
|
{ becameTerminal: true, changed: true },
|
|
),
|
|
).toBe("deferred");
|
|
});
|
|
|
|
it("eagerly reflects a live SSE update that actually changed", () => {
|
|
expect(
|
|
subtaskNotification(
|
|
{
|
|
id: "t1",
|
|
usage: { inputTokens: 300, outputTokens: 60, totalTokens: 360 },
|
|
},
|
|
{ becameTerminal: false, changed: true },
|
|
),
|
|
).toBe("eager");
|
|
});
|
|
|
|
it("does nothing when a re-parsed terminal result carries unchanged metadata", () => {
|
|
// Regression: modelName/usage are present on every terminal re-parse, but the
|
|
// state did not change. Firing setTasks here is the render loop (P1).
|
|
expect(
|
|
subtaskNotification(
|
|
{
|
|
id: "t1",
|
|
status: "completed",
|
|
modelName: "opus",
|
|
usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 },
|
|
},
|
|
{ becameTerminal: false, changed: false },
|
|
),
|
|
).toBe("none");
|
|
});
|
|
|
|
it("does nothing when a replayed SSE usage snapshot did not change state", () => {
|
|
expect(
|
|
subtaskNotification(
|
|
{
|
|
id: "t1",
|
|
usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 },
|
|
},
|
|
{ becameTerminal: false, changed: false },
|
|
),
|
|
).toBe("none");
|
|
});
|
|
});
|
|
|
|
describe("isTerminalSubtaskStatus", () => {
|
|
it("recognizes terminal statuses only", () => {
|
|
expect(isTerminalSubtaskStatus("completed")).toBe(true);
|
|
expect(isTerminalSubtaskStatus("failed")).toBe(true);
|
|
expect(isTerminalSubtaskStatus("in_progress")).toBe(false);
|
|
expect(isTerminalSubtaskStatus(undefined)).toBe(false);
|
|
});
|
|
});
|