mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
test(chat): harden stream behavior contracts
This commit is contained in:
parent
8351156589
commit
5cd898fabe
4 changed files with 242 additions and 28 deletions
|
|
@ -6,8 +6,7 @@ export function upsertThreadStreamItemById(items: readonly ThreadStreamItem[], n
|
|||
const index = items.findIndex((item) => item.id === next.id);
|
||||
if (index === -1) return [...items, next];
|
||||
const copy = [...items];
|
||||
const previous = copy[index];
|
||||
if (previous === undefined) return [...items];
|
||||
const previous = items[index] as ThreadStreamItem;
|
||||
copy[index] = {
|
||||
...previous,
|
||||
...next,
|
||||
|
|
@ -20,7 +19,7 @@ export function upsertThreadStreamItemById(items: readonly ThreadStreamItem[], n
|
|||
function mergeOutput(previous: ThreadStreamItem, next: ThreadStreamItem): string | undefined {
|
||||
const previousOutput = "output" in previous ? previous.output : undefined;
|
||||
const nextOutput = "output" in next ? next.output : undefined;
|
||||
return nextOutput && nextOutput.length > 0 ? nextOutput : previousOutput;
|
||||
return nextOutput || previousOutput;
|
||||
}
|
||||
|
||||
function mergeChanges(previous: ThreadStreamItem, next: ThreadStreamItem): readonly ThreadStreamFileChange[] | undefined {
|
||||
|
|
|
|||
|
|
@ -77,6 +77,23 @@ describe("thread archive export", () => {
|
|||
expect(output).toContain("## Codex - 2026-05-18 10:01\n\n途中の回答");
|
||||
});
|
||||
|
||||
it("orders transcript entries chronologically while preserving source order for equal timestamps", () => {
|
||||
const output = exportedMarkdown(
|
||||
thread({
|
||||
transcriptEntries: [
|
||||
transcriptEntry("assistant", "newest", timestamp(2026, 5, 18, 10, 0)),
|
||||
transcriptEntry("assistant", "equal-first", timestamp(2026, 5, 18, 9, 0)),
|
||||
transcriptEntry("user", "oldest", timestamp(2026, 5, 18, 8, 0)),
|
||||
transcriptEntry("plan", "equal-second", timestamp(2026, 5, 18, 9, 0)),
|
||||
],
|
||||
}),
|
||||
new Date(2026, 4, 18),
|
||||
);
|
||||
|
||||
const positions = ["oldest", "equal-first", "equal-second", "newest"].map((text) => output.indexOf(text));
|
||||
expect(positions).toEqual([...positions].sort((a, b) => a - b));
|
||||
});
|
||||
|
||||
it("exports only the thread history remaining after rollback", () => {
|
||||
const rolledBackUserText = "rollbackされた依頼";
|
||||
const rolledBackAssistantText = "rollbackされた回答";
|
||||
|
|
|
|||
|
|
@ -197,6 +197,65 @@ describe("thread stream semantic classification", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("grants turn outcome capabilities only to completed assistant dialogue outcomes in a turn", () => {
|
||||
const cases: readonly [label: string, item: ThreadStreamItem, expected: boolean][] = [
|
||||
["completed response", assistantDialogue("completed-response", "assistantResponse", "completed", "turn"), true],
|
||||
["completed plan", assistantDialogue("completed-plan", "proposedPlan", "completed", "turn"), true],
|
||||
["streaming response", assistantDialogue("streaming", "assistantResponse", "streaming", "turn"), false],
|
||||
["response outside a turn", assistantDialogue("no-turn", "assistantResponse", "completed"), false],
|
||||
["user request", userMessage("user", "request", "turn"), false],
|
||||
["turn detail", { ...commandItem("command"), turnId: "turn" }, false],
|
||||
];
|
||||
|
||||
for (const [label, item, expected] of cases) {
|
||||
const [classification] = threadStreamSemanticClassifications([item]);
|
||||
expect(classification?.capabilities, label).toMatchObject({
|
||||
canForkFromHere: expected,
|
||||
isTurnOutcome: expected,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("allows rollback only from turn and pending-turn initiators", () => {
|
||||
const first = userMessage("first", "first", "turn");
|
||||
const steer = {
|
||||
...userMessage("steer", "steer", "turn"),
|
||||
provenance: { source: "localUser", channel: "optimistic", interaction: "steer", sourceId: "steer" },
|
||||
} satisfies ThreadStreamItem;
|
||||
const pending: ThreadStreamItem = {
|
||||
id: "pending",
|
||||
kind: "dialogue",
|
||||
dialogueKind: "user",
|
||||
role: "user",
|
||||
text: "pending",
|
||||
};
|
||||
const cases: readonly [label: string, items: readonly ThreadStreamItem[], index: number, expected: boolean][] = [
|
||||
["turn initiator", [first], 0, true],
|
||||
["pending-turn initiator", [pending], 0, true],
|
||||
["explicit steer", [first, steer], 1, false],
|
||||
["turn outcome", [assistantDialogue("response", "assistantResponse", "completed", "turn")], 0, false],
|
||||
["turn detail", [{ ...commandItem("command"), turnId: "turn" }], 0, false],
|
||||
];
|
||||
|
||||
for (const [label, items, index, expected] of cases) {
|
||||
expect(threadStreamSemanticClassifications(items)[index]?.capabilities.canRollbackToPrompt, label).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("allows implementation only for completed proposed plans", () => {
|
||||
const cases: readonly [label: string, item: ThreadStreamItem, expected: boolean][] = [
|
||||
["completed plan", assistantDialogue("plan", "proposedPlan", "completed", "turn"), true],
|
||||
["completed plan awaiting turn attachment", assistantDialogue("pending-plan", "proposedPlan", "completed"), true],
|
||||
["streaming plan", assistantDialogue("draft", "proposedPlan", "streaming", "turn"), false],
|
||||
["completed response", assistantDialogue("response", "assistantResponse", "completed", "turn"), false],
|
||||
["non-dialogue completion", commandItem("command"), false],
|
||||
];
|
||||
|
||||
for (const [label, item, expected] of cases) {
|
||||
expect(threadStreamSemanticClassifications([item])[0]?.capabilities.canImplementPlan, label).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("classifies sub-agent execution summaries as coordination progress", () => {
|
||||
const item = threadStreamItemFromTurnItem(collabAgentToolCall(), "turn");
|
||||
const [classification] = threadStreamSemanticClassifications(item ? [item] : []);
|
||||
|
|
@ -226,6 +285,23 @@ function userMessage(id: string, text: string, turnId: string, clientId?: string
|
|||
return { id, kind: "dialogue", dialogueKind: "user", role: "user", text, turnId, ...(clientId ? { clientId } : {}) };
|
||||
}
|
||||
|
||||
function assistantDialogue(
|
||||
id: string,
|
||||
dialogueKind: "assistantResponse" | "proposedPlan",
|
||||
dialogueState: "streaming" | "completed",
|
||||
turnId?: string,
|
||||
): ThreadStreamItem {
|
||||
return {
|
||||
id,
|
||||
kind: "dialogue",
|
||||
dialogueKind,
|
||||
dialogueState,
|
||||
role: "assistant",
|
||||
text: id,
|
||||
...(turnId ? { turnId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function commandItem(id: string): ThreadStreamItem {
|
||||
return {
|
||||
id,
|
||||
|
|
|
|||
|
|
@ -1,37 +1,159 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items";
|
||||
import { upsertThreadStreamItemById } from "../../../../../src/features/chat/domain/thread-stream/updates";
|
||||
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: ThreadStreamItem = {
|
||||
id: "c1",
|
||||
sourceItemId: "c1",
|
||||
kind: "command",
|
||||
role: "tool",
|
||||
commandAction: "command",
|
||||
commandTarget: { kind: "command", commandLine: "Command running" },
|
||||
command: "npm test",
|
||||
cwd: "/vault",
|
||||
status: "running",
|
||||
output: "partial output",
|
||||
};
|
||||
const completed: ThreadStreamItem = {
|
||||
id: "c1",
|
||||
sourceItemId: "c1",
|
||||
kind: "command",
|
||||
role: "tool",
|
||||
commandAction: "command",
|
||||
commandTarget: { kind: "command", commandLine: "npm test" },
|
||||
command: "npm test",
|
||||
cwd: "/vault",
|
||||
status: "completed",
|
||||
output: "",
|
||||
};
|
||||
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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue