From 5cd898fabeb2b2275ffedc8df1b2ff780d5162e1 Mon Sep 17 00:00:00 2001 From: murashit Date: Sat, 18 Jul 2026 18:02:21 +0900 Subject: [PATCH] test(chat): harden stream behavior contracts --- .../chat/domain/thread-stream/updates.ts | 5 +- tests/domain/threads/archive-markdown.test.ts | 17 ++ .../semantics/classification.test.ts | 76 ++++++++ .../chat/domain/thread-stream/updates.test.ts | 172 +++++++++++++++--- 4 files changed, 242 insertions(+), 28 deletions(-) diff --git a/src/features/chat/domain/thread-stream/updates.ts b/src/features/chat/domain/thread-stream/updates.ts index 7d62e9bf..f930293b 100644 --- a/src/features/chat/domain/thread-stream/updates.ts +++ b/src/features/chat/domain/thread-stream/updates.ts @@ -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 { diff --git a/tests/domain/threads/archive-markdown.test.ts b/tests/domain/threads/archive-markdown.test.ts index 0ff7d1c2..d8d86747 100644 --- a/tests/domain/threads/archive-markdown.test.ts +++ b/tests/domain/threads/archive-markdown.test.ts @@ -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された回答"; diff --git a/tests/features/chat/domain/thread-stream/semantics/classification.test.ts b/tests/features/chat/domain/thread-stream/semantics/classification.test.ts index 0d4afd92..fc008aa6 100644 --- a/tests/features/chat/domain/thread-stream/semantics/classification.test.ts +++ b/tests/features/chat/domain/thread-stream/semantics/classification.test.ts @@ -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, diff --git a/tests/features/chat/domain/thread-stream/updates.test.ts b/tests/features/chat/domain/thread-stream/updates.test.ts index 9b9ec3cb..da800b4f 100644 --- a/tests/features/chat/domain/thread-stream/updates.test.ts +++ b/tests/features/chat/domain/thread-stream/updates.test.ts @@ -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 { + 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["changes"], +): Extract { + return { id, kind: "fileChange", role: "tool", status, changes }; +} + +function reasoningItem(id: string, turnId: string): Extract { + return { id, kind: "reasoning", role: "tool", text: id, turnId, status: "running", executionState: "running" }; +} + +function hookItem(id: string): Extract { + return { id, kind: "hook", role: "tool", text: id }; +}