mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 17:30:31 +00:00
Add shared turn transcript projection
This commit is contained in:
parent
ecb54c4f91
commit
0c79036c48
6 changed files with 216 additions and 89 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
83
src/domain/threads/transcript.ts
Normal file
83
src/domain/threads/transcript.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
97
tests/domain/threads/transcript.test.ts
Normal file
97
tests/domain/threads/transcript.test.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue