mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 17:30:31 +00:00
159 lines
7 KiB
TypeScript
159 lines
7 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items";
|
|
import {
|
|
attachHookRunsToTurn,
|
|
completeReasoningItems,
|
|
upsertThreadStreamItemById,
|
|
} from "../../../../../src/features/chat/domain/thread-stream/updates";
|
|
|
|
describe("thread stream item updates", () => {
|
|
it("appends new items and replaces matching items in place", () => {
|
|
const first = reasoningItem("r1", "turn");
|
|
const previous = commandItem("c1", "running");
|
|
const completed = { ...previous, status: "completed", executionState: "completed" } satisfies ThreadStreamItem;
|
|
|
|
expect(upsertThreadStreamItemById([first], previous)).toEqual([first, previous]);
|
|
|
|
const replaced = upsertThreadStreamItemById([first, previous], completed);
|
|
expect(replaced.map((item) => item.id)).toEqual(["r1", "c1"]);
|
|
expect(replaced[0]).toBe(first);
|
|
expect(replaced[1]).toMatchObject({ status: "completed", executionState: "completed" });
|
|
});
|
|
|
|
it("does not overwrite streamed output with an empty completed item", () => {
|
|
const streamed = { ...commandItem("c1", "running"), output: "partial output" } satisfies ThreadStreamItem;
|
|
const completed = { ...commandItem("c1", "completed"), output: "" } satisfies ThreadStreamItem;
|
|
|
|
expect(upsertThreadStreamItemById([streamed], completed)[0]).toMatchObject({
|
|
output: "partial output",
|
|
status: "completed",
|
|
});
|
|
});
|
|
|
|
it("uses non-empty replacement output and preserves streamed file changes when completion omits them", () => {
|
|
const streamedCommand = { ...commandItem("c1", "running"), output: "partial" } satisfies ThreadStreamItem;
|
|
const completedCommand = { ...commandItem("c1", "completed"), output: "complete" } satisfies ThreadStreamItem;
|
|
const streamedChange = fileChangeItem("f1", "running", [{ kind: "update", path: "src/a.ts", diff: "@@ streamed" }]);
|
|
const completedChange = fileChangeItem("f1", "completed", []);
|
|
|
|
expect(upsertThreadStreamItemById([streamedCommand], completedCommand)[0]).toMatchObject({ output: "complete" });
|
|
expect(upsertThreadStreamItemById([streamedChange], completedChange)[0]).toMatchObject({
|
|
status: "completed",
|
|
changes: streamedChange.changes,
|
|
});
|
|
|
|
const finalChanges = [{ kind: "update", path: "src/a.ts", diff: "@@ complete" }];
|
|
expect(upsertThreadStreamItemById([streamedChange], fileChangeItem("f1", "completed", finalChanges))[0]).toMatchObject({
|
|
changes: finalChanges,
|
|
});
|
|
});
|
|
|
|
it("completes reasoning only for the selected turn while preserving unrelated item references", () => {
|
|
const selected = reasoningItem("selected", "turn");
|
|
const otherTurn = reasoningItem("other", "other-turn");
|
|
const command = { ...commandItem("command", "running"), turnId: "turn" } satisfies ThreadStreamItem;
|
|
|
|
const result = completeReasoningItems([selected, otherTurn, command], "turn");
|
|
|
|
expect(result[0]).toEqual({ ...selected, status: "completed", executionState: "completed" });
|
|
expect(result[1]).toBe(otherTurn);
|
|
expect(result[2]).toBe(command);
|
|
});
|
|
|
|
it("returns the original collection when there is no reasoning for the selected turn", () => {
|
|
const items = [reasoningItem("other", "other-turn"), commandItem("command", "running")];
|
|
|
|
expect(completeReasoningItems(items, "turn")).toBe(items);
|
|
});
|
|
|
|
it("attaches selected hooks to a turn immediately after an explicit anchor", () => {
|
|
const first = userMessage("user", "turn");
|
|
const anchor = commandItem("anchor", "completed");
|
|
const firstHook = hookItem("hook-1");
|
|
const last = commandItem("last", "completed");
|
|
const secondHook = hookItem("hook-2");
|
|
|
|
const result = attachHookRunsToTurn([first, firstHook, anchor, last, secondHook], "turn", ["hook-1", "hook-2"], "anchor");
|
|
|
|
expect(result.map((item) => item.id)).toEqual(["user", "anchor", "hook-1", "hook-2", "last"]);
|
|
expect(result.filter((item) => item.kind === "hook").map((item) => item.turnId)).toEqual(["turn", "turn"]);
|
|
});
|
|
|
|
it("falls back to the target turn initiator rather than a steer or another turn", () => {
|
|
const targetPrompt = userMessage("target-prompt", "turn");
|
|
const targetSteer = {
|
|
...userMessage("target-steer", "turn"),
|
|
provenance: { source: "localUser", channel: "optimistic", interaction: "steer", sourceId: "target-steer" },
|
|
} satisfies ThreadStreamItem;
|
|
const otherPrompt = userMessage("other-prompt", "other-turn");
|
|
const hook = hookItem("hook");
|
|
|
|
const result = attachHookRunsToTurn([targetPrompt, targetSteer, otherPrompt, hook], "turn", ["hook"]);
|
|
|
|
expect(result.map((item) => item.id)).toEqual(["target-prompt", "hook", "target-steer", "other-prompt"]);
|
|
expect(result[1]).toMatchObject({ id: "hook", turnId: "turn" });
|
|
});
|
|
|
|
it("uses the latest eligible user message as the fallback anchor", () => {
|
|
const earlier = userMessage("earlier");
|
|
const later = userMessage("later");
|
|
const hook = hookItem("hook");
|
|
|
|
expect(attachHookRunsToTurn([earlier, later, hook], "turn", ["hook"]).map((item) => item.id)).toEqual(["earlier", "later", "hook"]);
|
|
});
|
|
|
|
it("appends attached hooks when no user-message anchor exists", () => {
|
|
const command = commandItem("command", "completed");
|
|
const hook = hookItem("hook");
|
|
|
|
const result = attachHookRunsToTurn([hook, command], "turn", ["hook"]);
|
|
|
|
expect(result.map((item) => item.id)).toEqual(["command", "hook"]);
|
|
expect(result[1]).toMatchObject({ id: "hook", turnId: "turn" });
|
|
});
|
|
|
|
it("appends attached hooks when an explicit anchor is missing and leaves items unchanged when hooks are missing", () => {
|
|
const prompt = userMessage("prompt", "turn");
|
|
const hook = hookItem("hook");
|
|
|
|
expect(attachHookRunsToTurn([hook, prompt], "turn", ["hook"], "missing").map((item) => item.id)).toEqual(["prompt", "hook"]);
|
|
|
|
const items = [prompt, hook];
|
|
const withoutMatchingHooks = attachHookRunsToTurn(items, "turn", ["missing"]);
|
|
expect(withoutMatchingHooks).toEqual(items);
|
|
});
|
|
});
|
|
|
|
function userMessage(id: string, turnId?: string): ThreadStreamItem {
|
|
return { id, kind: "dialogue", dialogueKind: "user", role: "user", text: id, ...(turnId ? { turnId } : {}) };
|
|
}
|
|
|
|
function commandItem(id: string, status: string): Extract<ThreadStreamItem, { kind: "command" }> {
|
|
return {
|
|
id,
|
|
sourceItemId: id,
|
|
kind: "command",
|
|
role: "tool",
|
|
commandAction: "command",
|
|
commandTarget: { kind: "command", commandLine: "npm test" },
|
|
command: "npm test",
|
|
cwd: "/vault",
|
|
status,
|
|
};
|
|
}
|
|
|
|
function fileChangeItem(
|
|
id: string,
|
|
status: string,
|
|
changes: Extract<ThreadStreamItem, { kind: "fileChange" }>["changes"],
|
|
): Extract<ThreadStreamItem, { kind: "fileChange" }> {
|
|
return { id, kind: "fileChange", role: "tool", status, changes };
|
|
}
|
|
|
|
function reasoningItem(id: string, turnId: string): Extract<ThreadStreamItem, { kind: "reasoning" }> {
|
|
return { id, kind: "reasoning", role: "tool", text: id, turnId, status: "running", executionState: "running" };
|
|
}
|
|
|
|
function hookItem(id: string): Extract<ThreadStreamItem, { kind: "hook" }> {
|
|
return { id, kind: "hook", role: "tool", text: id };
|
|
}
|