From 0c79036c48b4775eb41c1f8695fd18cfe8ce99c8 Mon Sep 17 00:00:00 2001 From: murashit Date: Mon, 1 Jun 2026 10:26:27 +0900 Subject: [PATCH] Add shared turn transcript projection --- src/domain/threads/export.ts | 51 ++++++------ src/domain/threads/naming.ts | 37 ++------- src/domain/threads/reference.ts | 32 +------- src/domain/threads/transcript.ts | 83 +++++++++++++++++++ src/features/chat/display/thread-items.ts | 5 +- tests/domain/threads/transcript.test.ts | 97 +++++++++++++++++++++++ 6 files changed, 216 insertions(+), 89 deletions(-) create mode 100644 src/domain/threads/transcript.ts create mode 100644 tests/domain/threads/transcript.test.ts diff --git a/src/domain/threads/export.ts b/src/domain/threads/export.ts index 1bfdf905..753cf0c4 100644 --- a/src/domain/threads/export.ts +++ b/src/domain/threads/export.ts @@ -1,9 +1,9 @@ import type { Thread } from "../../generated/app-server/v2/Thread"; -import type { ThreadItem } from "../../generated/app-server/v2/ThreadItem"; import type { Turn } from "../../generated/app-server/v2/Turn"; -import { inputToText, shortThreadId } from "../../utils"; +import { shortThreadId } from "../../utils"; import { getThreadTitle } from "./model"; import { referencedThreadDisplayFromPrompt } from "./reference"; +import { turnTranscriptEntries, type TurnTranscriptEntry } from "./transcript"; export interface ArchiveExportAdapter { exists(path: string): Promise; @@ -118,36 +118,31 @@ export function normalizedArchiveTags(value: string): string[] { function turnMarkdownLines(turns: Turn[]): string[] { return [...turns] .sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0)) - .flatMap((turn) => turn.items.flatMap((item) => markdownLinesFromItem(item, turn))); + .flatMap((turn) => turnTranscriptEntries(turn).flatMap(markdownLinesFromTranscriptEntry)); } -function markdownLinesFromItem(item: ThreadItem, turn: Turn): string[] { - if (item.type === "userMessage") { - const text = inputToText(item.content).trim(); - if (!text) return []; - const heading = timestampedHeading("User", turn.startedAt); - const referenced = referencedThreadDisplayFromPrompt(text); - if (referenced) { - return [ - heading, - "", - referenced.text, - "", - `> Referenced: ${referenced.reference.title} (${String(referenced.reference.includedTurns)}/${String(referenced.reference.turnLimit)} turns, ${referenced.reference.threadId})`, - "", - ]; +function markdownLinesFromTranscriptEntry(entry: TurnTranscriptEntry): string[] { + switch (entry.kind) { + case "user": { + const heading = timestampedHeading("User", entry.timestamp); + const referenced = referencedThreadDisplayFromPrompt(entry.text); + if (referenced) { + return [ + heading, + "", + referenced.text, + "", + `> Referenced: ${referenced.reference.title} (${String(referenced.reference.includedTurns)}/${String(referenced.reference.turnLimit)} turns, ${referenced.reference.threadId})`, + "", + ]; + } + return [heading, "", entry.text, ""]; } - return [heading, "", text, ""]; + case "assistant": + return [timestampedHeading("Codex", entry.timestamp), "", entry.text, ""]; + case "plan": + return [timestampedHeading("Proposed plan", entry.timestamp), "", entry.text, ""]; } - if (item.type === "agentMessage") { - const text = item.text.trim(); - return text ? [timestampedHeading("Codex", turn.completedAt ?? turn.startedAt), "", text, ""] : []; - } - if (item.type === "plan") { - const text = item.text.trim(); - return text ? [timestampedHeading("Proposed plan", turn.completedAt ?? turn.startedAt), "", text, ""] : []; - } - return []; } function timestampedHeading(label: string, unixSeconds: number | null): string { diff --git a/src/domain/threads/naming.ts b/src/domain/threads/naming.ts index 784b3c32..0695a1bd 100644 --- a/src/domain/threads/naming.ts +++ b/src/domain/threads/naming.ts @@ -1,7 +1,7 @@ import type { SortDirection } from "../../generated/app-server/v2/SortDirection"; -import type { ThreadItem } from "../../generated/app-server/v2/ThreadItem"; import type { Turn } from "../../generated/app-server/v2/Turn"; -import { inputToText, truncate } from "../../utils"; +import { truncate } from "../../utils"; +import { completedTurnConversationSummary, turnConversationSummary } from "./transcript"; const MAX_CONTEXT_CHARS = 4_000; const MAX_TITLE_CHARS = 40; @@ -29,15 +29,12 @@ export type ThreadNamingContextPageReader = ( ) => Promise; export function namingContextFromTurn(turn: Turn): ThreadNamingContext | null { - if (turn.status !== "completed") return null; - - const userRequest = firstUserMessage(turn.items); - const assistantResponse = lastAssistantMessage(turn.items); - if (!userRequest || !assistantResponse) return null; + const summary = completedTurnConversationSummary(turn); + if (!summary?.userText || !summary.assistantText) return null; return { - userRequest: truncateForPrompt(userRequest), - assistantResponse: truncateForPrompt(assistantResponse), + userRequest: truncateForPrompt(summary.userText), + assistantResponse: truncateForPrompt(summary.assistantText), }; } @@ -65,7 +62,7 @@ export async function findThreadNamingContext(options: { } export function titleFromNamingTurn(turn: Turn): string | null { - const response = lastAssistantMessage(turn.items); + const response = turnConversationSummary(turn).assistantText; if (!response) return null; return normalizeGeneratedTitle(extractTitleFromModelText(response)); } @@ -85,26 +82,6 @@ export function normalizeGeneratedTitle(value: unknown): string | null { return title.length > MAX_TITLE_CHARS ? title.slice(0, MAX_TITLE_CHARS).trimEnd() : title; } -function firstUserMessage(items: ThreadItem[]): string | null { - for (const item of items) { - if (item.type !== "userMessage") continue; - const text = inputToText(item.content).trim(); - if (text) return text; - } - return null; -} - -function lastAssistantMessage(items: ThreadItem[]): string | null { - for (let index = items.length - 1; index >= 0; index -= 1) { - const item = items[index]; - if (item === undefined) continue; - if (item.type !== "agentMessage" && item.type !== "plan") continue; - const text = item.text.trim(); - if (text) return text; - } - return null; -} - export function namingPrompt(context: ThreadNamingContext): string { return [ "Create a thread title for the following Codex thread.", diff --git a/src/domain/threads/reference.ts b/src/domain/threads/reference.ts index ffaab660..9f6757c7 100644 --- a/src/domain/threads/reference.ts +++ b/src/domain/threads/reference.ts @@ -1,9 +1,9 @@ import type { Thread } from "../../generated/app-server/v2/Thread"; -import type { ThreadItem } from "../../generated/app-server/v2/ThreadItem"; import type { Turn } from "../../generated/app-server/v2/Turn"; import type { UserInput } from "../../generated/app-server/v2/UserInput"; -import { inputToText, shortThreadId } from "../../utils"; +import { shortThreadId } from "../../utils"; import { getThreadTitle } from "./model"; +import { chronologicalTurnConversationSummaries } from "./transcript"; export const REFERENCED_THREAD_TURN_LIMIT = 20; @@ -26,13 +26,7 @@ export interface ReferencedThreadInput { } export function referencedThreadTurns(turns: Turn[]): ReferencedThreadTurn[] { - return [...turns] - .sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0)) - .map((turn) => ({ - userText: firstUserMessage(turn.items), - assistantText: lastAssistantMessage(turn.items), - })) - .filter((turn) => turn.userText !== null || turn.assistantText !== null); + return chronologicalTurnConversationSummaries(turns); } export function referencedThreadPrompt(thread: Thread, turns: ReferencedThreadTurn[], userRequest: string): string { @@ -126,23 +120,3 @@ function lineValue(text: string, key: string): string | null { .trim(); return line && line.length > 0 ? line : null; } - -function firstUserMessage(items: ThreadItem[]): string | null { - for (const item of items) { - if (item.type !== "userMessage") continue; - const text = inputToText(item.content).trim(); - if (text) return text; - } - return null; -} - -function lastAssistantMessage(items: ThreadItem[]): string | null { - for (let index = items.length - 1; index >= 0; index -= 1) { - const item = items[index]; - if (item === undefined) continue; - if (item.type !== "agentMessage" && item.type !== "plan") continue; - const text = item.text.trim(); - if (text) return text; - } - return null; -} diff --git a/src/domain/threads/transcript.ts b/src/domain/threads/transcript.ts new file mode 100644 index 00000000..f3fd8dd3 --- /dev/null +++ b/src/domain/threads/transcript.ts @@ -0,0 +1,83 @@ +import type { ThreadItem } from "../../generated/app-server/v2/ThreadItem"; +import type { Turn } from "../../generated/app-server/v2/Turn"; +import { inputToText } from "../../utils"; + +export type TranscriptEntryKind = "user" | "assistant" | "plan"; + +export interface TurnTranscriptEntry { + kind: TranscriptEntryKind; + text: string; + timestamp: number | null; +} + +export interface TurnConversationSummary { + userText: string | null; + assistantText: string | null; +} + +export function turnTranscriptEntries(turn: Turn): TurnTranscriptEntry[] { + return turn.items.flatMap((item) => transcriptEntriesFromItem(item, turn)); +} + +export function chronologicalTurnConversationSummaries(turns: readonly Turn[]): TurnConversationSummary[] { + return chronologicalTurns(turns) + .map(turnConversationSummary) + .filter((summary) => summary.userText !== null || summary.assistantText !== null); +} + +export function completedTurnConversationSummary(turn: Turn): TurnConversationSummary | null { + if (turn.status !== "completed") return null; + const summary = turnConversationSummary(turn); + return summary.userText && summary.assistantText ? summary : null; +} + +export function turnConversationSummary(turn: Turn): TurnConversationSummary { + return { + userText: firstUserText(turn.items), + assistantText: lastAssistantText(turn.items), + }; +} + +export function userItemText(item: Extract): string { + return inputToText(item.content); +} + +function transcriptEntriesFromItem(item: ThreadItem, turn: Turn): TurnTranscriptEntry[] { + if (item.type === "userMessage") { + const text = userItemText(item).trim(); + return text ? [{ kind: "user", text, timestamp: turn.startedAt }] : []; + } + if (item.type === "agentMessage") { + const text = item.text.trim(); + return text ? [{ kind: "assistant", text, timestamp: turn.completedAt ?? turn.startedAt }] : []; + } + if (item.type === "plan") { + const text = item.text.trim(); + return text ? [{ kind: "plan", text, timestamp: turn.completedAt ?? turn.startedAt }] : []; + } + return []; +} + +function chronologicalTurns(turns: readonly Turn[]): Turn[] { + return [...turns].sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0)); +} + +function firstUserText(items: readonly ThreadItem[]): string | null { + for (const item of items) { + if (item.type !== "userMessage") continue; + const text = userItemText(item).trim(); + if (text) return text; + } + return null; +} + +function lastAssistantText(items: readonly ThreadItem[]): string | null { + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index]; + if (item === undefined) continue; + if (item.type !== "agentMessage" && item.type !== "plan") continue; + const text = item.text.trim(); + if (text) return text; + } + return null; +} diff --git a/src/features/chat/display/thread-items.ts b/src/features/chat/display/thread-items.ts index ffe0bab8..d33d65bd 100644 --- a/src/features/chat/display/thread-items.ts +++ b/src/features/chat/display/thread-items.ts @@ -3,8 +3,9 @@ import type { FileUpdateChange } from "../../../generated/app-server/v2/FileUpda import type { ThreadItem } from "../../../generated/app-server/v2/ThreadItem"; import type { Turn } from "../../../generated/app-server/v2/Turn"; import type { UserInput } from "../../../generated/app-server/v2/UserInput"; -import { definedProp, inputToText, truncate } from "../../../utils"; +import { definedProp, truncate } from "../../../utils"; import { referencedThreadDisplayFromPrompt } from "../../../domain/threads/reference"; +import { userItemText } from "../../../domain/threads/transcript"; import { agentDisplayItem } from "./agent"; import { pathRelativeToRoot } from "./paths"; import { normalizeProposedPlanMarkdown } from "./plan"; @@ -86,7 +87,7 @@ export function displayItemFromThreadItem(item: ThreadItem, turnId?: string): Di } function userMessageDisplayItem(item: UserMessageItem, turnId?: string): DisplayItem { - const text = inputToText(item.content); + const text = userItemText(item); const mentionedFiles = fileMentionsFromInput(item.content); const referencedThread = referencedThreadDisplayFromPrompt(text); if (referencedThread) { diff --git a/tests/domain/threads/transcript.test.ts b/tests/domain/threads/transcript.test.ts new file mode 100644 index 00000000..7cbd6bf7 --- /dev/null +++ b/tests/domain/threads/transcript.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; + +import { + chronologicalTurnConversationSummaries, + completedTurnConversationSummary, + turnTranscriptEntries, +} from "../../../src/domain/threads/transcript"; +import type { ThreadItem } from "../../../src/generated/app-server/v2/ThreadItem"; +import type { Turn } from "../../../src/generated/app-server/v2/Turn"; + +describe("turn transcript projection", () => { + it("projects readable transcript entries without command log items", () => { + expect( + turnTranscriptEntries( + turn([userMessage("u1", " 依頼です "), commandItem("cmd"), assistantMessage("a1", "回答です"), planItem("p1", "計画です")]), + ), + ).toEqual([ + { kind: "user", text: "依頼です", timestamp: 10 }, + { kind: "assistant", text: "回答です", timestamp: 12 }, + { kind: "plan", text: "計画です", timestamp: 12 }, + ]); + }); + + it("builds completed turn summaries from the first user message and last assistant-like item", () => { + expect( + completedTurnConversationSummary( + turn([ + assistantMessage("draft", "途中経過"), + userMessage("u1", "最初の依頼"), + userMessage("u2", "補足"), + assistantMessage("a1", "最終回答"), + ]), + ), + ).toEqual({ userText: "最初の依頼", assistantText: "最終回答" }); + }); + + it("does not build completed summaries for failed turns", () => { + expect( + completedTurnConversationSummary(turn([userMessage("u1", "依頼"), assistantMessage("a1", "回答")], { status: "failed" })), + ).toBeNull(); + }); + + it("returns chronological summaries and drops turns without conversation text", () => { + expect( + chronologicalTurnConversationSummaries([ + turn([userMessage("u2", "後の依頼"), assistantMessage("a2", "後の回答")], { id: "turn-2", startedAt: 20 }), + turn([commandItem("cmd")], { id: "turn-empty", startedAt: 15 }), + turn([userMessage("u1", "先の依頼"), assistantMessage("a1", "先の回答")], { id: "turn-1", startedAt: 10 }), + ]), + ).toEqual([ + { userText: "先の依頼", assistantText: "先の回答" }, + { userText: "後の依頼", assistantText: "後の回答" }, + ]); + }); +}); + +function turn(items: ThreadItem[], overrides: Partial = {}): Turn { + return { + id: "turn", + items, + itemsView: "full", + status: "completed", + error: null, + startedAt: 10, + completedAt: 12, + durationMs: 2, + ...overrides, + }; +} + +function userMessage(id: string, text: string): ThreadItem { + return { type: "userMessage", id, content: [{ type: "text", text, text_elements: [] }] }; +} + +function assistantMessage(id: string, text: string): ThreadItem { + return { type: "agentMessage", id, text, phase: "final_answer", memoryCitation: null }; +} + +function planItem(id: string, text: string): ThreadItem { + return { type: "plan", id, text }; +} + +function commandItem(id: string): ThreadItem { + return { + type: "commandExecution", + id, + command: "npm test", + cwd: "/vault", + processId: null, + source: "agent", + status: "completed", + commandActions: [], + aggregatedOutput: "ignored", + exitCode: 0, + durationMs: 1, + }; +}