test(chat): harden stream behavior contracts

This commit is contained in:
murashit 2026-07-18 18:02:21 +09:00
parent 8351156589
commit 5cd898fabe
4 changed files with 242 additions and 28 deletions

View file

@ -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 {

View file

@ -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された回答";

View file

@ -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,

View file

@ -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 };
}