Add shared turn transcript projection

This commit is contained in:
murashit 2026-06-01 10:26:27 +09:00
parent ecb54c4f91
commit 0c79036c48
6 changed files with 216 additions and 89 deletions

View file

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

View file

@ -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<ThreadNamingContextPage>;
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.",

View file

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

View file

@ -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<ThreadItem, { type: "userMessage" }>): 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;
}

View file

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

View file

@ -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> = {}): 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,
};
}