mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Extract thread title feature controllers
This commit is contained in:
parent
c516ccee6b
commit
ada4ed5281
14 changed files with 441 additions and 332 deletions
|
|
@ -7,6 +7,7 @@ import type { ChatInboundController } from "./protocol/inbound/controller";
|
|||
import type { GoalActions } from "./threads/goal-actions";
|
||||
import { createChatRuntimeSettingsActions, type ChatRuntimeSettingsActions } from "./runtime/settings-actions";
|
||||
import type { ChatThreadActions } from "./threads/actions";
|
||||
import type { AutoTitleController } from "./threads/auto-title-controller";
|
||||
import type { HistoryController } from "./threads/history-controller";
|
||||
import type { RenameController } from "./threads/rename-controller";
|
||||
import type { ToolbarPanelController } from "./panel/regions/toolbar";
|
||||
|
|
@ -59,6 +60,7 @@ export interface ChatViewControllers {
|
|||
restoration: RestorationController;
|
||||
identity: IdentitySync;
|
||||
rename: RenameController;
|
||||
autoTitle: AutoTitleController;
|
||||
selection: SelectionActions;
|
||||
};
|
||||
runtime: {
|
||||
|
|
@ -182,7 +184,7 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts)
|
|||
connection,
|
||||
},
|
||||
);
|
||||
const { history, actions: threadActions, goals, identity, restoration, resume, rename } = threadControllers;
|
||||
const { history, actions: threadActions, goals, identity, restoration, resume, rename, autoTitle } = threadControllers;
|
||||
const lifecycleActions = {
|
||||
deferredTasks: ports.lifecycle.deferredTasks,
|
||||
resumeWork: ports.lifecycle.resumeWork,
|
||||
|
|
@ -308,7 +310,7 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts)
|
|||
{
|
||||
serverMetadata,
|
||||
serverDiagnostics,
|
||||
rename,
|
||||
autoTitle,
|
||||
respondToServerRequest: (requestId, result) => respondToServerRequest(serverRequestHost, requestId, result),
|
||||
rejectServerRequest: (requestId, code, message) => rejectServerRequest(serverRequestHost, requestId, code, message),
|
||||
},
|
||||
|
|
@ -330,7 +332,7 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts)
|
|||
loadSharedThreadList: ports.thread.loadSharedThreadList,
|
||||
refreshTabHeader: ports.thread.refreshTabHeader,
|
||||
resetTurnPresence: (hadTurns) => {
|
||||
rename.resetThreadTurnPresence(hadTurns);
|
||||
autoTitle.resetThreadTurnPresence(hadTurns);
|
||||
},
|
||||
},
|
||||
status: actions.status,
|
||||
|
|
@ -398,7 +400,7 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts)
|
|||
selectThread: actions.thread.selectThread,
|
||||
notifyIdentityChanged: ports.thread.notifyIdentityChanged,
|
||||
resetTurnPresence: (hadTurns) => {
|
||||
rename.resetThreadTurnPresence(hadTurns);
|
||||
autoTitle.resetThreadTurnPresence(hadTurns);
|
||||
},
|
||||
},
|
||||
status: actions.status,
|
||||
|
|
@ -496,6 +498,7 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts)
|
|||
restoration,
|
||||
identity,
|
||||
rename,
|
||||
autoTitle,
|
||||
selection,
|
||||
},
|
||||
runtime: {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { ChatConnectionController } from "./connection-controller";
|
|||
import { createChatReconnectActions } from "./reconnect-actions";
|
||||
import type { rejectServerRequest, respondToServerRequest } from "../protocol/requests/server-request-responder";
|
||||
import type { GoalActions } from "../threads/goal-actions";
|
||||
import type { RenameController } from "../threads/rename-controller";
|
||||
import type { AutoTitleController } from "../threads/auto-title-controller";
|
||||
import { ChatInboundController } from "../protocol/inbound/controller";
|
||||
import type { ChatConnectionWorkTracker } from "../lifecycle";
|
||||
|
||||
|
|
@ -97,7 +97,7 @@ export function createChatInboundController(
|
|||
refs: {
|
||||
serverMetadata: ChatServerMetadataActions;
|
||||
serverDiagnostics: ChatServerDiagnosticsActions;
|
||||
rename: RenameController;
|
||||
autoTitle: AutoTitleController;
|
||||
respondToServerRequest: (requestId: Parameters<typeof respondToServerRequest>[1], result: unknown) => boolean;
|
||||
rejectServerRequest: (requestId: Parameters<typeof rejectServerRequest>[1], code: number, message: string) => boolean;
|
||||
},
|
||||
|
|
@ -114,7 +114,7 @@ export function createChatInboundController(
|
|||
refreshSkills: (forceReload) => void thread.refreshSkills(forceReload),
|
||||
publishAppServerMetadata: thread.publishAppServerMetadataSnapshot,
|
||||
maybeNameThread: (threadId, turnId, completedSummary) => {
|
||||
refs.rename.maybeAutoNameThread(threadId, turnId, completedSummary);
|
||||
refs.autoTitle.maybeAutoTitleThread(threadId, turnId, completedSummary);
|
||||
},
|
||||
notifyThreadArchived: plugin.notifyThreadArchived.bind(plugin),
|
||||
notifyThreadRenamed: plugin.notifyThreadRenamed.bind(plugin),
|
||||
|
|
|
|||
99
src/features/chat/threads/auto-title-controller.ts
Normal file
99
src/features/chat/threads/auto-title-controller.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import type { AppServerClient } from "../../../app-server/client";
|
||||
import type { Thread } from "../../../domain/threads/model";
|
||||
import type { ThreadConversationSummary } from "../../../domain/threads/transcript";
|
||||
import type { CodexPanelSettings } from "../../../settings/model";
|
||||
import { generateThreadTitleWithCodex } from "../../thread-title/generation";
|
||||
import { threadTitleContextFromConversationSummary, type ThreadTitleContext } from "../../thread-title/model";
|
||||
import type { ChatAction, ChatState, ChatStateStore } from "../state/reducer";
|
||||
import { threadTitleContextFromDisplayItems } from "./title-context";
|
||||
|
||||
export interface AutoTitleControllerHost {
|
||||
stateStore: ChatStateStore;
|
||||
vaultPath: string;
|
||||
settings: () => CodexPanelSettings;
|
||||
currentClient: () => AppServerClient | null;
|
||||
render: () => void;
|
||||
notifyThreadRenamed: (threadId: string, name: string) => void;
|
||||
generateThreadTitle?: (context: ThreadTitleContext) => Promise<string | null>;
|
||||
}
|
||||
|
||||
export class AutoTitleController {
|
||||
private activeThreadHadTurns = false;
|
||||
private readonly attemptedThreadIds = new Set<string>();
|
||||
private readonly inFlightThreadIds = new Set<string>();
|
||||
|
||||
constructor(private readonly host: AutoTitleControllerHost) {}
|
||||
|
||||
private get state(): ChatState {
|
||||
return this.host.stateStore.getState();
|
||||
}
|
||||
|
||||
private dispatch(action: ChatAction): void {
|
||||
this.host.stateStore.dispatch(action);
|
||||
}
|
||||
|
||||
resetThreadTurnPresence(hadTurns: boolean): void {
|
||||
this.activeThreadHadTurns = hadTurns;
|
||||
}
|
||||
|
||||
maybeAutoTitleThread(threadId: string, turnId: string, completedSummary: ThreadConversationSummary | null): void {
|
||||
const hadTurnsBeforeThisCompletion = this.activeThreadHadTurns;
|
||||
this.activeThreadHadTurns = true;
|
||||
|
||||
if (hadTurnsBeforeThisCompletion || !completedSummary) return;
|
||||
if (this.threadHasTitle(threadId)) return;
|
||||
if (this.attemptedThreadIds.has(threadId) || this.inFlightThreadIds.has(threadId)) return;
|
||||
const context =
|
||||
threadTitleContextFromConversationSummary(completedSummary) ??
|
||||
threadTitleContextFromDisplayItems(turnId, this.state.transcript.displayItems);
|
||||
if (!context) return;
|
||||
|
||||
this.attemptedThreadIds.add(threadId);
|
||||
this.inFlightThreadIds.add(threadId);
|
||||
void this.generateAndSetTitle(threadId, context);
|
||||
}
|
||||
|
||||
private async generateAndSetTitle(threadId: string, context: ThreadTitleContext): Promise<void> {
|
||||
try {
|
||||
const title = await this.generateTitle(context);
|
||||
if (!title || !this.threadCanReceiveGeneratedTitle(threadId)) return;
|
||||
|
||||
const client = this.host.currentClient();
|
||||
if (!client) return;
|
||||
await client.setThreadName(threadId, title);
|
||||
if (!this.threadCanReceiveGeneratedTitle(threadId)) return;
|
||||
this.dispatch({
|
||||
type: "thread-list/applied",
|
||||
threads: this.state.threadList.listedThreads.map((thread) => (thread.id === threadId ? { ...thread, name: title } : thread)),
|
||||
});
|
||||
this.host.notifyThreadRenamed(threadId, title);
|
||||
} catch {
|
||||
// Auto-title is best-effort metadata. Leave the thread preview untouched on failure.
|
||||
} finally {
|
||||
this.inFlightThreadIds.delete(threadId);
|
||||
this.host.render();
|
||||
}
|
||||
}
|
||||
|
||||
private async generateTitle(context: ThreadTitleContext): Promise<string | null> {
|
||||
if (this.host.generateThreadTitle) return this.host.generateThreadTitle(context);
|
||||
const settings = this.host.settings();
|
||||
return generateThreadTitleWithCodex(settings.codexPath, this.host.vaultPath, context, {
|
||||
threadNamingModel: settings.threadNamingModel,
|
||||
threadNamingEffort: settings.threadNamingEffort,
|
||||
});
|
||||
}
|
||||
|
||||
private threadHasTitle(threadId: string): boolean {
|
||||
return Boolean(this.thread(threadId)?.name?.trim());
|
||||
}
|
||||
|
||||
private threadCanReceiveGeneratedTitle(threadId: string): boolean {
|
||||
const thread = this.thread(threadId);
|
||||
return Boolean(thread && !thread.name?.trim());
|
||||
}
|
||||
|
||||
private thread(threadId: string): Thread | undefined {
|
||||
return this.state.threadList.listedThreads.find((item) => item.id === threadId);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import type { AppServerClient } from "../../../app-server/client";
|
|||
import { recoverRolloutTokenUsage } from "../../../app-server/rollout-token-usage";
|
||||
import type { ArchiveExportAdapter } from "../../../domain/threads/export";
|
||||
import { createChatThreadActions } from "./actions";
|
||||
import { AutoTitleController } from "./auto-title-controller";
|
||||
import { createGoalActions } from "./goal-actions";
|
||||
import { HistoryController } from "./history-controller";
|
||||
import { createIdentitySync } from "./identity-sync";
|
||||
|
|
@ -86,8 +87,16 @@ export function createThreadControllerGroup(
|
|||
addSystemMessage: status.addSystemMessage,
|
||||
notifyThreadRenamed: plugin.notifyThreadRenamed.bind(plugin),
|
||||
});
|
||||
const autoTitle = new AutoTitleController({
|
||||
stateStore,
|
||||
vaultPath: plugin.vaultPath,
|
||||
settings: () => plugin.settings,
|
||||
currentClient,
|
||||
render: render.now,
|
||||
notifyThreadRenamed: plugin.notifyThreadRenamed.bind(plugin),
|
||||
});
|
||||
const resetThreadTurnPresence = (hadTurns: boolean) => {
|
||||
rename.resetThreadTurnPresence(hadTurns);
|
||||
autoTitle.resetThreadTurnPresence(hadTurns);
|
||||
};
|
||||
const history = new HistoryController({
|
||||
stateStore,
|
||||
|
|
@ -188,6 +197,7 @@ export function createThreadControllerGroup(
|
|||
resume: requireThreadController(resume, "resume controller"),
|
||||
identity,
|
||||
rename,
|
||||
autoTitle,
|
||||
invalidateResumeWork,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,17 @@
|
|||
import type { AppServerClient } from "../../../app-server/client";
|
||||
import { getThreadTitle } from "../../../domain/threads/model";
|
||||
import {
|
||||
findThreadNamingContext,
|
||||
namingContextFromConversationSummary,
|
||||
THREAD_NAMING_CONTEXT_UNAVAILABLE_MESSAGE,
|
||||
type ThreadNamingContext,
|
||||
} from "../../../domain/threads/naming";
|
||||
import type { Thread } from "../../../domain/threads/model";
|
||||
import type { ThreadConversationSummary } from "../../../domain/threads/transcript";
|
||||
import type { CodexPanelSettings } from "../../../settings/model";
|
||||
import type { ChatAction, ChatState, ChatStateStore } from "../state/reducer";
|
||||
import { generateThreadTitleWithCodex } from "../../../app-server/thread-title-generation";
|
||||
import { completedConversationSummaryFromAppServerTurn } from "../../../app-server/turn-model";
|
||||
import { firstNamingContextFromDisplayItems, namingContextFromDisplayItems } from "./naming";
|
||||
import { getThreadTitle } from "../../../domain/threads/model";
|
||||
import type { Thread } from "../../../domain/threads/model";
|
||||
import type { CodexPanelSettings } from "../../../settings/model";
|
||||
import { generateThreadTitleWithCodex } from "../../thread-title/generation";
|
||||
import {
|
||||
findThreadTitleContext,
|
||||
THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE,
|
||||
type ThreadTitleContext,
|
||||
} from "../../thread-title/model";
|
||||
import type { ChatState, ChatStateStore } from "../state/reducer";
|
||||
import { renameConnectedThread } from "./rename-actions";
|
||||
import { firstThreadTitleContextFromDisplayItems } from "./title-context";
|
||||
|
||||
export interface RenameEditState {
|
||||
draft: string;
|
||||
|
|
@ -43,13 +41,10 @@ export interface RenameControllerHost {
|
|||
render: () => void;
|
||||
addSystemMessage: (text: string) => void;
|
||||
notifyThreadRenamed: (threadId: string, name: string) => void;
|
||||
generateThreadTitle?: (context: ThreadNamingContext) => Promise<string | null>;
|
||||
generateThreadTitle?: (context: ThreadTitleContext) => Promise<string | null>;
|
||||
}
|
||||
|
||||
export class RenameController {
|
||||
private activeThreadHadTurns = false;
|
||||
private readonly autoNameAttemptedThreadIds = new Set<string>();
|
||||
private readonly autoNameInFlightThreadIds = new Set<string>();
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private nextRenameGenerationId = 1;
|
||||
private renameState: RenameLifecycleState = { kind: "idle" };
|
||||
|
|
@ -60,14 +55,6 @@ export class RenameController {
|
|||
return this.host.stateStore.getState();
|
||||
}
|
||||
|
||||
private dispatch(action: ChatAction): void {
|
||||
this.host.stateStore.dispatch(action);
|
||||
}
|
||||
|
||||
resetThreadTurnPresence(hadTurns: boolean): void {
|
||||
this.activeThreadHadTurns = hadTurns;
|
||||
}
|
||||
|
||||
editState(threadId: string): RenameEditState | null {
|
||||
if (this.renameState.kind === "idle" || this.renameState.threadId !== threadId) return null;
|
||||
return {
|
||||
|
|
@ -144,7 +131,7 @@ export class RenameController {
|
|||
|
||||
try {
|
||||
const context = await this.resolveNamingContext(threadId);
|
||||
if (!context) throw new Error(THREAD_NAMING_CONTEXT_UNAVAILABLE_MESSAGE);
|
||||
if (!context) throw new Error(THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE);
|
||||
const title = await this.generateTitle(context);
|
||||
if (!title) throw new Error("Codex did not return a usable thread title.");
|
||||
this.setRenameState(
|
||||
|
|
@ -163,48 +150,10 @@ export class RenameController {
|
|||
}
|
||||
}
|
||||
|
||||
maybeAutoNameThread(threadId: string, turnId: string, completedSummary: ThreadConversationSummary | null): void {
|
||||
const hadTurnsBeforeThisCompletion = this.activeThreadHadTurns;
|
||||
this.activeThreadHadTurns = true;
|
||||
|
||||
if (hadTurnsBeforeThisCompletion || !completedSummary) return;
|
||||
if (this.threadHasName(threadId)) return;
|
||||
if (this.autoNameAttemptedThreadIds.has(threadId) || this.autoNameInFlightThreadIds.has(threadId)) return;
|
||||
const context =
|
||||
namingContextFromConversationSummary(completedSummary) ?? namingContextFromDisplayItems(turnId, this.state.transcript.displayItems);
|
||||
if (!context) return;
|
||||
|
||||
this.autoNameAttemptedThreadIds.add(threadId);
|
||||
this.autoNameInFlightThreadIds.add(threadId);
|
||||
void this.generateAndSetName(threadId, context);
|
||||
}
|
||||
|
||||
private async generateAndSetName(threadId: string, context: ThreadNamingContext): Promise<void> {
|
||||
try {
|
||||
const title = await this.generateTitle(context);
|
||||
if (!title || !this.threadCanReceiveGeneratedName(threadId)) return;
|
||||
|
||||
const client = this.host.currentClient();
|
||||
if (!client) return;
|
||||
await client.setThreadName(threadId, title);
|
||||
if (!this.threadCanReceiveGeneratedName(threadId)) return;
|
||||
this.dispatch({
|
||||
type: "thread-list/applied",
|
||||
threads: this.state.threadList.listedThreads.map((thread) => (thread.id === threadId ? { ...thread, name: title } : thread)),
|
||||
});
|
||||
this.host.notifyThreadRenamed(threadId, title);
|
||||
} catch {
|
||||
// Auto-naming is best-effort metadata. Leave the thread preview untouched on failure.
|
||||
} finally {
|
||||
this.autoNameInFlightThreadIds.delete(threadId);
|
||||
this.host.render();
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveNamingContext(threadId: string): Promise<ThreadNamingContext | null> {
|
||||
private async resolveNamingContext(threadId: string): Promise<ThreadTitleContext | null> {
|
||||
const client = this.host.currentClient();
|
||||
if (!client) return null;
|
||||
const context = await findThreadNamingContext({
|
||||
const context = await findThreadTitleContext({
|
||||
threadId,
|
||||
readTurns: async (id, cursor, limit, sortDirection) => {
|
||||
const response = await client.threadTurnsList(id, cursor, limit, sortDirection);
|
||||
|
|
@ -218,11 +167,12 @@ export class RenameController {
|
|||
},
|
||||
});
|
||||
return (
|
||||
context ?? (this.state.activeThread.id === threadId ? firstNamingContextFromDisplayItems(this.state.transcript.displayItems) : null)
|
||||
context ??
|
||||
(this.state.activeThread.id === threadId ? firstThreadTitleContextFromDisplayItems(this.state.transcript.displayItems) : null)
|
||||
);
|
||||
}
|
||||
|
||||
private async generateTitle(context: ThreadNamingContext): Promise<string | null> {
|
||||
private async generateTitle(context: ThreadTitleContext): Promise<string | null> {
|
||||
if (this.host.generateThreadTitle) return this.host.generateThreadTitle(context);
|
||||
const settings = this.host.settings();
|
||||
return generateThreadTitleWithCodex(settings.codexPath, this.host.vaultPath, context, {
|
||||
|
|
@ -251,15 +201,6 @@ export class RenameController {
|
|||
this.setRenameState(next);
|
||||
}
|
||||
|
||||
private threadHasName(threadId: string): boolean {
|
||||
return Boolean(this.thread(threadId)?.name?.trim());
|
||||
}
|
||||
|
||||
private threadCanReceiveGeneratedName(threadId: string): boolean {
|
||||
const thread = this.thread(threadId);
|
||||
return Boolean(thread && !thread.name?.trim());
|
||||
}
|
||||
|
||||
private thread(threadId: string): Thread | undefined {
|
||||
return this.state.threadList.listedThreads.find((item) => item.id === threadId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import type { ThreadNamingContext } from "../../../domain/threads/naming";
|
||||
import type { ThreadTitleContext } from "../../thread-title/model";
|
||||
import { truncate } from "../../../utils";
|
||||
import { isCompletedTurnOutcomeMessage } from "../display/predicates";
|
||||
import type { DisplayItem } from "../display/types";
|
||||
|
||||
const MAX_CONTEXT_CHARS = 4_000;
|
||||
|
||||
export function namingContextFromDisplayItems(turnId: string, items: readonly DisplayItem[]): ThreadNamingContext | null {
|
||||
export function threadTitleContextFromDisplayItems(turnId: string, items: readonly DisplayItem[]): ThreadTitleContext | null {
|
||||
const turnItems = items.filter((item) => item.turnId === turnId);
|
||||
const userRequest =
|
||||
turnItems.find((item) => item.kind === "message" && item.role === "user")?.text.trim() ??
|
||||
precedingUnscopedNamingSeed(turnId, items) ??
|
||||
precedingUnscopedTitleSeed(turnId, items) ??
|
||||
"";
|
||||
const assistantResponse = [...turnItems].reverse().find(isCompletedTurnOutcomeMessage)?.text.trim() ?? "";
|
||||
if (!userRequest || !assistantResponse) return null;
|
||||
|
|
@ -19,18 +19,18 @@ export function namingContextFromDisplayItems(turnId: string, items: readonly Di
|
|||
};
|
||||
}
|
||||
|
||||
export function firstNamingContextFromDisplayItems(items: readonly DisplayItem[]): ThreadNamingContext | null {
|
||||
export function firstThreadTitleContextFromDisplayItems(items: readonly DisplayItem[]): ThreadTitleContext | null {
|
||||
const turnIds = new Set<string>();
|
||||
for (const item of items) {
|
||||
if (!item.turnId || turnIds.has(item.turnId)) continue;
|
||||
turnIds.add(item.turnId);
|
||||
const context = namingContextFromDisplayItems(item.turnId, items);
|
||||
const context = threadTitleContextFromDisplayItems(item.turnId, items);
|
||||
if (context) return context;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function precedingUnscopedNamingSeed(turnId: string, items: readonly DisplayItem[]): string | null {
|
||||
function precedingUnscopedTitleSeed(turnId: string, items: readonly DisplayItem[]): string | null {
|
||||
const firstTurnItemIndex = items.findIndex((item) => item.turnId === turnId);
|
||||
if (firstTurnItemIndex < 1) return null;
|
||||
for (let index = firstTurnItemIndex - 1; index >= 0; index -= 1) {
|
||||
|
|
@ -1,19 +1,18 @@
|
|||
import { modelMetadataFromAppServerModels } from "../../app-server/catalog-model";
|
||||
import {
|
||||
runEphemeralStructuredTurn,
|
||||
type EphemeralStructuredTurnClient,
|
||||
type EphemeralStructuredTurnClientFactory,
|
||||
type EphemeralStructuredTurnRuntimeClient,
|
||||
type StructuredTurnOutputSchema,
|
||||
} from "./ephemeral-structured-turn";
|
||||
import { modelMetadataFromAppServerModels } from "./catalog-model";
|
||||
import type { Turn } from "../generated/app-server/v2/Turn";
|
||||
import type { ModelMetadata, ReasoningEffort } from "../domain/catalog/metadata";
|
||||
import { runtimeOverride, validatedRuntimeOverrideForModelMetadata } from "../domain/catalog/runtime-overrides";
|
||||
import { namingPrompt, titleFromGeneratedText, type ThreadNamingContext } from "../domain/threads/naming";
|
||||
import { conversationSummaryFromAppServerTurn } from "./turn-model";
|
||||
} from "../../app-server/ephemeral-structured-turn";
|
||||
import { conversationSummaryFromAppServerTurn, type AppServerTurn } from "../../app-server/turn-model";
|
||||
import type { ModelMetadata, ReasoningEffort } from "../../domain/catalog/metadata";
|
||||
import { runtimeOverride, validatedRuntimeOverrideForModelMetadata } from "../../domain/catalog/runtime-overrides";
|
||||
import { threadTitleFromGeneratedText, threadTitlePrompt, type ThreadTitleContext } from "./model";
|
||||
|
||||
const NAMING_SERVICE_NAME = "codex-panel-naming";
|
||||
const NAMING_TIMEOUT_MS = 60_000;
|
||||
const THREAD_TITLE_SERVICE_NAME = "codex-panel-naming";
|
||||
const THREAD_TITLE_TIMEOUT_MS = 60_000;
|
||||
const MAX_TITLE_CHARS = 40;
|
||||
|
||||
const TITLE_OUTPUT_SCHEMA: StructuredTurnOutputSchema = {
|
||||
|
|
@ -36,68 +35,68 @@ const TITLE_DEVELOPER_INSTRUCTIONS = [
|
|||
"Do not include Markdown, quotes around the whole response, explanations, or alternatives.",
|
||||
].join("\n");
|
||||
|
||||
export interface ThreadNamingRuntimeSettings {
|
||||
export interface ThreadTitleRuntimeSettings {
|
||||
threadNamingModel: string | null;
|
||||
threadNamingEffort: ReasoningEffort | null;
|
||||
}
|
||||
|
||||
export type ThreadNamingClient = EphemeralStructuredTurnClient;
|
||||
export type ThreadNamingClientFactory = EphemeralStructuredTurnClientFactory;
|
||||
export type ThreadTitleClient = EphemeralStructuredTurnClient;
|
||||
export type ThreadTitleClientFactory = EphemeralStructuredTurnClientFactory;
|
||||
|
||||
export async function generateThreadTitleWithCodex(
|
||||
codexPath: string,
|
||||
cwd: string,
|
||||
context: ThreadNamingContext,
|
||||
runtimeSettings: ThreadNamingRuntimeSettings,
|
||||
clientFactory?: ThreadNamingClientFactory,
|
||||
context: ThreadTitleContext,
|
||||
runtimeSettings: ThreadTitleRuntimeSettings,
|
||||
clientFactory?: ThreadTitleClientFactory,
|
||||
): Promise<string | null> {
|
||||
const turn = await runEphemeralStructuredTurn({
|
||||
codexPath,
|
||||
cwd,
|
||||
serviceName: NAMING_SERVICE_NAME,
|
||||
serviceName: THREAD_TITLE_SERVICE_NAME,
|
||||
developerInstructions: TITLE_DEVELOPER_INSTRUCTIONS,
|
||||
prompt: namingPrompt(context),
|
||||
prompt: threadTitlePrompt(context),
|
||||
outputSchema: TITLE_OUTPUT_SCHEMA,
|
||||
timeoutMs: NAMING_TIMEOUT_MS,
|
||||
timeoutMs: THREAD_TITLE_TIMEOUT_MS,
|
||||
unhandledServerRequestMessage: "Thread title generation does not handle server requests.",
|
||||
exitedMessage: "Codex title generation app-server exited.",
|
||||
timedOutMessage: "Timed out while generating a Codex thread title.",
|
||||
resolveRuntime: (client) => threadNamingRuntimeOverrideForClient(client, runtimeSettings),
|
||||
resolveRuntime: (client) => threadTitleRuntimeOverrideForClient(client, runtimeSettings),
|
||||
clientFactory,
|
||||
});
|
||||
return titleFromNamingTurn(turn);
|
||||
return threadTitleFromGenerationTurn(turn);
|
||||
}
|
||||
|
||||
export interface ThreadNamingRuntimeOverride {
|
||||
export interface ThreadTitleRuntimeOverride {
|
||||
model?: string;
|
||||
effort?: ReasoningEffort;
|
||||
}
|
||||
|
||||
export function titleFromNamingTurn(turn: Turn): string | null {
|
||||
export function threadTitleFromGenerationTurn(turn: AppServerTurn): string | null {
|
||||
const response = conversationSummaryFromAppServerTurn(turn).assistantText;
|
||||
return response ? titleFromGeneratedText(response) : null;
|
||||
return response ? threadTitleFromGeneratedText(response) : null;
|
||||
}
|
||||
|
||||
export function threadNamingRuntimeOverride(settings: ThreadNamingRuntimeSettings): ThreadNamingRuntimeOverride {
|
||||
export function threadTitleRuntimeOverride(settings: ThreadTitleRuntimeSettings): ThreadTitleRuntimeOverride {
|
||||
return runtimeOverride({ model: settings.threadNamingModel, effort: settings.threadNamingEffort });
|
||||
}
|
||||
|
||||
export function validatedThreadNamingRuntimeOverride(
|
||||
settings: ThreadNamingRuntimeSettings,
|
||||
export function validatedThreadTitleRuntimeOverride(
|
||||
settings: ThreadTitleRuntimeSettings,
|
||||
models: readonly ModelMetadata[],
|
||||
): ThreadNamingRuntimeOverride {
|
||||
): ThreadTitleRuntimeOverride {
|
||||
return validatedRuntimeOverrideForModelMetadata({ model: settings.threadNamingModel, effort: settings.threadNamingEffort }, models);
|
||||
}
|
||||
|
||||
async function threadNamingRuntimeOverrideForClient(
|
||||
async function threadTitleRuntimeOverrideForClient(
|
||||
client: EphemeralStructuredTurnRuntimeClient,
|
||||
settings: ThreadNamingRuntimeSettings,
|
||||
): Promise<ThreadNamingRuntimeOverride> {
|
||||
const runtime = threadNamingRuntimeOverride(settings);
|
||||
settings: ThreadTitleRuntimeSettings,
|
||||
): Promise<ThreadTitleRuntimeOverride> {
|
||||
const runtime = threadTitleRuntimeOverride(settings);
|
||||
if (!runtime.model || !runtime.effort) return runtime;
|
||||
try {
|
||||
const response = await client.listModels(false);
|
||||
return validatedThreadNamingRuntimeOverride(settings, modelMetadataFromAppServerModels(response.data));
|
||||
return validatedThreadTitleRuntimeOverride(settings, modelMetadataFromAppServerModels(response.data));
|
||||
} catch {
|
||||
return runtime;
|
||||
}
|
||||
|
|
@ -1,32 +1,32 @@
|
|||
import type { ThreadConversationSummary } from "../../domain/threads/transcript";
|
||||
import { truncate } from "../../utils";
|
||||
import type { ThreadConversationSummary } from "./transcript";
|
||||
|
||||
const MAX_CONTEXT_CHARS = 4_000;
|
||||
const MAX_TITLE_CHARS = 40;
|
||||
const DEFAULT_CONTEXT_PAGE_LIMIT = 20;
|
||||
const DEFAULT_CONTEXT_MAX_PAGES = 5;
|
||||
|
||||
export const THREAD_NAMING_CONTEXT_UNAVAILABLE_MESSAGE =
|
||||
export const THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE =
|
||||
"Auto-name needs completed history or visible resumed history with both user and assistant text.";
|
||||
|
||||
export interface ThreadNamingContext {
|
||||
export interface ThreadTitleContext {
|
||||
userRequest: string;
|
||||
assistantResponse: string;
|
||||
}
|
||||
|
||||
interface ThreadNamingContextPage {
|
||||
interface ThreadTitleContextPage {
|
||||
data: ThreadConversationSummary[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export type ThreadNamingContextPageReader = (
|
||||
export type ThreadTitleContextPageReader = (
|
||||
threadId: string,
|
||||
cursor: string | null,
|
||||
limit: number,
|
||||
sortDirection: "asc" | "desc",
|
||||
) => Promise<ThreadNamingContextPage>;
|
||||
) => Promise<ThreadTitleContextPage>;
|
||||
|
||||
export function namingContextFromConversationSummary(summary: ThreadConversationSummary): ThreadNamingContext | null {
|
||||
export function threadTitleContextFromConversationSummary(summary: ThreadConversationSummary): ThreadTitleContext | null {
|
||||
if (!summary.userText || !summary.assistantText) return null;
|
||||
|
||||
return {
|
||||
|
|
@ -35,12 +35,12 @@ export function namingContextFromConversationSummary(summary: ThreadConversation
|
|||
};
|
||||
}
|
||||
|
||||
export async function findThreadNamingContext(options: {
|
||||
export async function findThreadTitleContext(options: {
|
||||
threadId: string;
|
||||
readTurns: ThreadNamingContextPageReader;
|
||||
readTurns: ThreadTitleContextPageReader;
|
||||
pageLimit?: number;
|
||||
maxPages?: number;
|
||||
}): Promise<ThreadNamingContext | null> {
|
||||
}): Promise<ThreadTitleContext | null> {
|
||||
const pageLimit = options.pageLimit ?? DEFAULT_CONTEXT_PAGE_LIMIT;
|
||||
const maxPages = options.maxPages ?? DEFAULT_CONTEXT_MAX_PAGES;
|
||||
let cursor: string | null = null;
|
||||
|
|
@ -48,7 +48,7 @@ export async function findThreadNamingContext(options: {
|
|||
for (let page = 0; page < maxPages; page += 1) {
|
||||
const response = await options.readTurns(options.threadId, cursor, pageLimit, "asc");
|
||||
for (const summary of response.data) {
|
||||
const context = namingContextFromConversationSummary(summary);
|
||||
const context = threadTitleContextFromConversationSummary(summary);
|
||||
if (context) return context;
|
||||
}
|
||||
if (!response.nextCursor) break;
|
||||
|
|
@ -58,7 +58,7 @@ export async function findThreadNamingContext(options: {
|
|||
return null;
|
||||
}
|
||||
|
||||
export function normalizeGeneratedTitle(value: unknown): string | null {
|
||||
export function normalizeGeneratedThreadTitle(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const title = value
|
||||
.trim()
|
||||
|
|
@ -73,11 +73,11 @@ export function normalizeGeneratedTitle(value: unknown): string | null {
|
|||
return title.length > MAX_TITLE_CHARS ? title.slice(0, MAX_TITLE_CHARS).trimEnd() : title;
|
||||
}
|
||||
|
||||
export function titleFromGeneratedText(text: string): string | null {
|
||||
return normalizeGeneratedTitle(extractTitleFromModelText(text));
|
||||
export function threadTitleFromGeneratedText(text: string): string | null {
|
||||
return normalizeGeneratedThreadTitle(extractTitleFromModelText(text));
|
||||
}
|
||||
|
||||
export function namingPrompt(context: ThreadNamingContext): string {
|
||||
export function threadTitlePrompt(context: ThreadTitleContext): string {
|
||||
return [
|
||||
"Create a thread title for the following Codex thread.",
|
||||
"",
|
||||
|
|
@ -9,9 +9,9 @@ import type { Thread } from "../../domain/threads/model";
|
|||
import type { CodexPanelSettings } from "../../settings/model";
|
||||
import { exportArchivedThreadMarkdown } from "../../domain/threads/export";
|
||||
import type { OpenCodexPanelSnapshot } from "../../workspace/open-panel-snapshot";
|
||||
import { findThreadNamingContext, THREAD_NAMING_CONTEXT_UNAVAILABLE_MESSAGE } from "../../domain/threads/naming";
|
||||
import { generateThreadTitleWithCodex } from "../../app-server/thread-title-generation";
|
||||
import { completedConversationSummaryFromAppServerTurn, transcriptEntriesFromAppServerTurn } from "../../app-server/turn-model";
|
||||
import { generateThreadTitleWithCodex } from "../thread-title/generation";
|
||||
import { findThreadTitleContext, THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE } from "../thread-title/model";
|
||||
import { renderThreadsView, unmountThreadsView } from "./renderer";
|
||||
import {
|
||||
completedThreadAutoNameState,
|
||||
|
|
@ -327,7 +327,7 @@ export class CodexThreadsView extends ItemView {
|
|||
if (this.renameStates.get(threadId) !== generatingState) return;
|
||||
if (!this.client) return;
|
||||
const client = this.client;
|
||||
const context = await findThreadNamingContext({
|
||||
const context = await findThreadTitleContext({
|
||||
threadId,
|
||||
readTurns: async (id, cursor, limit, sortDirection) => {
|
||||
const response = await client.threadTurnsList(id, cursor, limit, sortDirection);
|
||||
|
|
@ -340,7 +340,7 @@ export class CodexThreadsView extends ItemView {
|
|||
};
|
||||
},
|
||||
});
|
||||
if (!context) throw new Error(THREAD_NAMING_CONTEXT_UNAVAILABLE_MESSAGE);
|
||||
if (!context) throw new Error(THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE);
|
||||
const title = await generateThreadTitleWithCodex(this.plugin.settings.codexPath, this.plugin.vaultPath, context, {
|
||||
threadNamingModel: this.plugin.settings.threadNamingModel,
|
||||
threadNamingEffort: this.plugin.settings.threadNamingEffort,
|
||||
|
|
|
|||
89
tests/features/chat/threads/auto-title-controller.test.ts
Normal file
89
tests/features/chat/threads/auto-title-controller.test.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { AppServerClient } from "../../../../src/app-server/client";
|
||||
import { createChatStateStore } from "../../../../src/features/chat/state/reducer";
|
||||
import { AutoTitleController } from "../../../../src/features/chat/threads/auto-title-controller";
|
||||
import type { Thread } from "../../../../src/domain/threads/model";
|
||||
import { DEFAULT_SETTINGS } from "../../../../src/settings/model";
|
||||
import { deferred } from "../../../support/async";
|
||||
|
||||
describe("AutoTitleController", () => {
|
||||
it("does not apply a completed auto-title after the thread leaves the list", async () => {
|
||||
const generatedTitle = deferred<string | null>();
|
||||
const setThreadName = vi.fn().mockResolvedValue({});
|
||||
const { controller, stateStore, notifyThreadRenamed } = controllerFixture({
|
||||
currentClient: () => fakeClient({ setThreadName }),
|
||||
generateThreadTitle: vi.fn(() => generatedTitle.promise),
|
||||
});
|
||||
|
||||
controller.maybeAutoTitleThread("thread", "turn", { userText: "Please name this.", assistantText: "Done." });
|
||||
await flushPromises();
|
||||
|
||||
stateStore.dispatch({ type: "thread-list/applied", threads: [] });
|
||||
generatedTitle.resolve("Generated title");
|
||||
await flushPromises();
|
||||
|
||||
expect(setThreadName).not.toHaveBeenCalled();
|
||||
expect(notifyThreadRenamed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not overwrite a manual name when auto-title save finishes later", async () => {
|
||||
const savedName = deferred<object>();
|
||||
const setThreadName = vi.fn(() => savedName.promise);
|
||||
const { controller, stateStore, notifyThreadRenamed } = controllerFixture({
|
||||
currentClient: () => fakeClient({ setThreadName }),
|
||||
generateThreadTitle: vi.fn().mockResolvedValue("Generated title"),
|
||||
});
|
||||
|
||||
controller.maybeAutoTitleThread("thread", "turn", { userText: "Please name this.", assistantText: "Done." });
|
||||
await flushPromises();
|
||||
expect(setThreadName).toHaveBeenCalledWith("thread", "Generated title");
|
||||
|
||||
stateStore.dispatch({ type: "thread-list/applied", threads: [{ ...threadFixture("thread"), name: "Manual title" }] });
|
||||
savedName.resolve({});
|
||||
await flushPromises();
|
||||
|
||||
expect(stateStore.getState().threadList.listedThreads[0]?.name).toBe("Manual title");
|
||||
expect(notifyThreadRenamed).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function controllerFixture(
|
||||
overrides: Partial<ConstructorParameters<typeof AutoTitleController>[0]> = {},
|
||||
): ConstructorParameters<typeof AutoTitleController>[0] & { controller: AutoTitleController; render: ReturnType<typeof vi.fn> } {
|
||||
const stateStore = createChatStateStore();
|
||||
stateStore.dispatch({ type: "thread-list/applied", threads: [threadFixture("thread")] });
|
||||
const render = vi.fn();
|
||||
const host = {
|
||||
stateStore,
|
||||
vaultPath: "/vault",
|
||||
settings: () => DEFAULT_SETTINGS,
|
||||
currentClient: () => fakeClient(),
|
||||
render,
|
||||
notifyThreadRenamed: vi.fn(),
|
||||
...overrides,
|
||||
} satisfies ConstructorParameters<typeof AutoTitleController>[0];
|
||||
return { ...host, controller: new AutoTitleController(host), render };
|
||||
}
|
||||
|
||||
function fakeClient(options: { setThreadName?: ReturnType<typeof vi.fn> } = {}): AppServerClient {
|
||||
return {
|
||||
setThreadName: options.setThreadName ?? vi.fn().mockResolvedValue({}),
|
||||
} as unknown as AppServerClient;
|
||||
}
|
||||
|
||||
function threadFixture(id: string): Thread {
|
||||
return {
|
||||
id,
|
||||
preview: "Thread preview",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
name: null,
|
||||
archived: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function flushPromises(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
|
@ -148,45 +148,6 @@ describe("RenameController", () => {
|
|||
|
||||
expect(controller.editState("thread")).toEqual({ draft: "New generated title", generating: false });
|
||||
});
|
||||
|
||||
it("does not apply a completed auto-name after the thread leaves the list", async () => {
|
||||
const generatedTitle = deferred<string | null>();
|
||||
const setThreadName = vi.fn().mockResolvedValue({});
|
||||
const { controller, stateStore, notifyThreadRenamed } = controllerFixture({
|
||||
currentClient: () => fakeClient({ setThreadName }),
|
||||
generateThreadTitle: vi.fn(() => generatedTitle.promise),
|
||||
});
|
||||
|
||||
controller.maybeAutoNameThread("thread", "turn", { userText: "Please name this.", assistantText: "Done." });
|
||||
await flushPromises();
|
||||
|
||||
stateStore.dispatch({ type: "thread-list/applied", threads: [] });
|
||||
generatedTitle.resolve("Generated title");
|
||||
await flushPromises();
|
||||
|
||||
expect(setThreadName).not.toHaveBeenCalled();
|
||||
expect(notifyThreadRenamed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not overwrite a manual name when auto-name save finishes later", async () => {
|
||||
const savedName = deferred<object>();
|
||||
const setThreadName = vi.fn(() => savedName.promise);
|
||||
const { controller, stateStore, notifyThreadRenamed } = controllerFixture({
|
||||
currentClient: () => fakeClient({ setThreadName }),
|
||||
generateThreadTitle: vi.fn().mockResolvedValue("Generated title"),
|
||||
});
|
||||
|
||||
controller.maybeAutoNameThread("thread", "turn", { userText: "Please name this.", assistantText: "Done." });
|
||||
await flushPromises();
|
||||
expect(setThreadName).toHaveBeenCalledWith("thread", "Generated title");
|
||||
|
||||
stateStore.dispatch({ type: "thread-list/applied", threads: [{ ...threadFixture("thread"), name: "Manual title" }] });
|
||||
savedName.resolve({});
|
||||
await flushPromises();
|
||||
|
||||
expect(stateStore.getState().threadList.listedThreads[0]?.name).toBe("Manual title");
|
||||
expect(notifyThreadRenamed).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function controllerFixture(
|
||||
|
|
|
|||
107
tests/features/chat/threads/title-context.test.ts
Normal file
107
tests/features/chat/threads/title-context.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
firstThreadTitleContextFromDisplayItems,
|
||||
threadTitleContextFromDisplayItems,
|
||||
} from "../../../../src/features/chat/threads/title-context";
|
||||
|
||||
describe("chat thread title context", () => {
|
||||
it("extracts title context from streamed display items when completed turn items are not loaded", () => {
|
||||
expect(
|
||||
threadTitleContextFromDisplayItems("turn", [
|
||||
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "自動命名を直したい", turnId: "turn" },
|
||||
{
|
||||
id: "a1",
|
||||
kind: "message",
|
||||
role: "assistant",
|
||||
text: "原因を直しました。",
|
||||
turnId: "turn",
|
||||
messageKind: "assistantResponse",
|
||||
messageState: "completed",
|
||||
},
|
||||
]),
|
||||
).toEqual({
|
||||
userRequest: "自動命名を直したい",
|
||||
assistantResponse: "原因を直しました。",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the first usable displayed turn as a resumed-history fallback", () => {
|
||||
expect(
|
||||
firstThreadTitleContextFromDisplayItems([
|
||||
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "本文だけのturn", turnId: "turn-1" },
|
||||
{ id: "u2", kind: "message", messageKind: "user", role: "user", text: "履歴から命名したい", turnId: "turn-2" },
|
||||
{
|
||||
id: "a2",
|
||||
kind: "message",
|
||||
role: "assistant",
|
||||
text: "表示済み履歴から候補を作ります。",
|
||||
turnId: "turn-2",
|
||||
messageKind: "assistantResponse",
|
||||
messageState: "completed",
|
||||
},
|
||||
{ id: "u3", kind: "message", messageKind: "user", role: "user", text: "後続turn", turnId: "turn-3" },
|
||||
{
|
||||
id: "a3",
|
||||
kind: "message",
|
||||
role: "assistant",
|
||||
text: "後続応答",
|
||||
turnId: "turn-3",
|
||||
messageKind: "assistantResponse",
|
||||
messageState: "completed",
|
||||
},
|
||||
]),
|
||||
).toEqual({
|
||||
userRequest: "履歴から命名したい",
|
||||
assistantResponse: "表示済み履歴から候補を作ります。",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a preceding goal event objective when the first completed turn has no user item", () => {
|
||||
expect(
|
||||
threadTitleContextFromDisplayItems("turn", [
|
||||
{
|
||||
id: "goal",
|
||||
kind: "goal",
|
||||
role: "tool",
|
||||
text: "Goal set.",
|
||||
objective: "ゴールから始めたスレッドを命名したい",
|
||||
},
|
||||
{
|
||||
id: "a1",
|
||||
kind: "message",
|
||||
role: "assistant",
|
||||
text: "ゴール内容に基づいて実装しました。",
|
||||
turnId: "turn",
|
||||
messageKind: "assistantResponse",
|
||||
messageState: "completed",
|
||||
},
|
||||
]),
|
||||
).toEqual({
|
||||
userRequest: "ゴールから始めたスレッドを命名したい",
|
||||
assistantResponse: "ゴール内容に基づいて実装しました。",
|
||||
});
|
||||
});
|
||||
|
||||
it("finds the first visible display item title context", () => {
|
||||
expect(
|
||||
firstThreadTitleContextFromDisplayItems([
|
||||
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "表示済み履歴から命名したい", turnId: "visible" },
|
||||
{
|
||||
id: "a1",
|
||||
kind: "message",
|
||||
role: "assistant",
|
||||
text: "表示済み履歴を使います。",
|
||||
turnId: "visible",
|
||||
messageKind: "assistantResponse",
|
||||
messageState: "completed",
|
||||
},
|
||||
]),
|
||||
).toEqual({
|
||||
userRequest: "表示済み履歴から命名したい",
|
||||
assistantResponse: "表示済み履歴を使います。",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,29 +1,26 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
findThreadNamingContext,
|
||||
namingContextFromConversationSummary,
|
||||
namingPrompt,
|
||||
normalizeGeneratedTitle,
|
||||
titleFromGeneratedText,
|
||||
} from "../../../../src/domain/threads/naming";
|
||||
import type { AppServerClient, AppServerClientHandlers, AppServerStartStructuredTurnOptions } from "../../../src/app-server/client";
|
||||
import { modelMetadataFromAppServerModels, type AppServerModel } from "../../../src/app-server/catalog-model";
|
||||
import type { AppServerInitialization } from "../../../src/app-server/initialization";
|
||||
import type { AppServerThread } from "../../../src/app-server/thread-model";
|
||||
import type { AppServerThreadItem, AppServerTurn } from "../../../src/app-server/turn-model";
|
||||
import type { RequestId, ServerNotification } from "../../../src/app-server/types";
|
||||
import {
|
||||
generateThreadTitleWithCodex,
|
||||
titleFromNamingTurn,
|
||||
threadNamingRuntimeOverride,
|
||||
validatedThreadNamingRuntimeOverride,
|
||||
type ThreadNamingClient,
|
||||
type ThreadNamingClientFactory,
|
||||
} from "../../../../src/app-server/thread-title-generation";
|
||||
import { firstNamingContextFromDisplayItems, namingContextFromDisplayItems } from "../../../../src/features/chat/threads/naming";
|
||||
import type { AppServerClient, AppServerClientHandlers, AppServerStartStructuredTurnOptions } from "../../../../src/app-server/client";
|
||||
import type { AppServerInitialization } from "../../../../src/app-server/initialization";
|
||||
import type { RequestId, ServerNotification } from "../../../../src/app-server/types";
|
||||
import type { AppServerThread } from "../../../../src/app-server/thread-model";
|
||||
import type { AppServerThreadItem, AppServerTurn } from "../../../../src/app-server/turn-model";
|
||||
import { modelMetadataFromAppServerModels, type AppServerModel } from "../../../../src/app-server/catalog-model";
|
||||
threadTitleFromGenerationTurn,
|
||||
threadTitleRuntimeOverride,
|
||||
validatedThreadTitleRuntimeOverride,
|
||||
type ThreadTitleClient,
|
||||
type ThreadTitleClientFactory,
|
||||
} from "../../../src/features/thread-title/generation";
|
||||
import {
|
||||
findThreadTitleContext,
|
||||
normalizeGeneratedThreadTitle,
|
||||
threadTitleContextFromConversationSummary,
|
||||
threadTitleFromGeneratedText,
|
||||
threadTitlePrompt,
|
||||
} from "../../../src/features/thread-title/model";
|
||||
|
||||
type InitializeResponse = AppServerInitialization;
|
||||
type ModelListResponse = Awaited<ReturnType<AppServerClient["listModels"]>>;
|
||||
|
|
@ -32,10 +29,10 @@ type ThreadStartResponse = Awaited<ReturnType<AppServerClient["startEphemeralThr
|
|||
type Turn = AppServerTurn;
|
||||
type TurnStartResponse = Awaited<ReturnType<AppServerClient["startStructuredTurn"]>>;
|
||||
|
||||
describe("thread naming", () => {
|
||||
it("builds naming context from a conversation summary", () => {
|
||||
describe("thread title", () => {
|
||||
it("builds title context from a conversation summary", () => {
|
||||
expect(
|
||||
namingContextFromConversationSummary({
|
||||
threadTitleContextFromConversationSummary({
|
||||
userText: "Codex Panelに自動命名を付けたい",
|
||||
assistantText: "実装方針をまとめました。",
|
||||
}),
|
||||
|
|
@ -45,90 +42,13 @@ describe("thread naming", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("does not build naming context for incomplete summaries", () => {
|
||||
expect(namingContextFromConversationSummary({ userText: "hello", assistantText: null })).toBeNull();
|
||||
it("does not build title context for incomplete summaries", () => {
|
||||
expect(threadTitleContextFromConversationSummary({ userText: "hello", assistantText: null })).toBeNull();
|
||||
});
|
||||
|
||||
it("extracts naming context from streamed display items when completed turn items are not loaded", () => {
|
||||
expect(
|
||||
namingContextFromDisplayItems("turn", [
|
||||
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "自動命名を直したい", turnId: "turn" },
|
||||
{
|
||||
id: "a1",
|
||||
kind: "message",
|
||||
role: "assistant",
|
||||
text: "原因を直しました。",
|
||||
turnId: "turn",
|
||||
messageKind: "assistantResponse",
|
||||
messageState: "completed",
|
||||
},
|
||||
]),
|
||||
).toEqual({
|
||||
userRequest: "自動命名を直したい",
|
||||
assistantResponse: "原因を直しました。",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the first usable displayed turn as a resumed-history fallback", () => {
|
||||
expect(
|
||||
firstNamingContextFromDisplayItems([
|
||||
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "本文だけのturn", turnId: "turn-1" },
|
||||
{ id: "u2", kind: "message", messageKind: "user", role: "user", text: "履歴から命名したい", turnId: "turn-2" },
|
||||
{
|
||||
id: "a2",
|
||||
kind: "message",
|
||||
role: "assistant",
|
||||
text: "表示済み履歴から候補を作ります。",
|
||||
turnId: "turn-2",
|
||||
messageKind: "assistantResponse",
|
||||
messageState: "completed",
|
||||
},
|
||||
{ id: "u3", kind: "message", messageKind: "user", role: "user", text: "後続turn", turnId: "turn-3" },
|
||||
{
|
||||
id: "a3",
|
||||
kind: "message",
|
||||
role: "assistant",
|
||||
text: "後続応答",
|
||||
turnId: "turn-3",
|
||||
messageKind: "assistantResponse",
|
||||
messageState: "completed",
|
||||
},
|
||||
]),
|
||||
).toEqual({
|
||||
userRequest: "履歴から命名したい",
|
||||
assistantResponse: "表示済み履歴から候補を作ります。",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a preceding goal event objective when the first completed turn has no user item", () => {
|
||||
expect(
|
||||
namingContextFromDisplayItems("turn", [
|
||||
{
|
||||
id: "goal",
|
||||
kind: "goal",
|
||||
role: "tool",
|
||||
text: "Goal set.",
|
||||
objective: "ゴールから始めたスレッドを命名したい",
|
||||
},
|
||||
{
|
||||
id: "a1",
|
||||
kind: "message",
|
||||
role: "assistant",
|
||||
text: "ゴール内容に基づいて実装しました。",
|
||||
turnId: "turn",
|
||||
messageKind: "assistantResponse",
|
||||
messageState: "completed",
|
||||
},
|
||||
]),
|
||||
).toEqual({
|
||||
userRequest: "ゴールから始めたスレッドを命名したい",
|
||||
assistantResponse: "ゴール内容に基づいて実装しました。",
|
||||
});
|
||||
});
|
||||
|
||||
it("scans older thread pages until it finds a usable naming context", async () => {
|
||||
it("scans older thread pages until it finds a usable title context", async () => {
|
||||
const calls: { cursor: string | null; limit: number; sortDirection: string }[] = [];
|
||||
const context = await findThreadNamingContext({
|
||||
const context = await findThreadTitleContext({
|
||||
threadId: "thread",
|
||||
pageLimit: 2,
|
||||
maxPages: 3,
|
||||
|
|
@ -157,29 +77,9 @@ describe("thread naming", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("finds the first visible display item naming context", () => {
|
||||
expect(
|
||||
firstNamingContextFromDisplayItems([
|
||||
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "表示済み履歴から命名したい", turnId: "visible" },
|
||||
{
|
||||
id: "a1",
|
||||
kind: "message",
|
||||
role: "assistant",
|
||||
text: "表示済み履歴を使います。",
|
||||
turnId: "visible",
|
||||
messageKind: "assistantResponse",
|
||||
messageState: "completed",
|
||||
},
|
||||
]),
|
||||
).toEqual({
|
||||
userRequest: "表示済み履歴から命名したい",
|
||||
assistantResponse: "表示済み履歴を使います。",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses structured title responses", () => {
|
||||
expect(
|
||||
titleFromNamingTurn(
|
||||
threadTitleFromGenerationTurn(
|
||||
turn([
|
||||
{
|
||||
type: "agentMessage",
|
||||
|
|
@ -194,17 +94,17 @@ describe("thread naming", () => {
|
|||
});
|
||||
|
||||
it("normalizes generated titles", () => {
|
||||
expect(normalizeGeneratedTitle(' ## "Codex Panelの自動命名"\n')).toBe("Codex Panelの自動命名");
|
||||
expect(normalizeGeneratedTitle("")).toBeNull();
|
||||
expect(normalizeGeneratedTitle("x".repeat(80))).toHaveLength(40);
|
||||
expect(normalizeGeneratedThreadTitle(' ## "Codex Panelの自動命名"\n')).toBe("Codex Panelの自動命名");
|
||||
expect(normalizeGeneratedThreadTitle("")).toBeNull();
|
||||
expect(normalizeGeneratedThreadTitle("x".repeat(80))).toHaveLength(40);
|
||||
});
|
||||
|
||||
it("parses generated title text", () => {
|
||||
expect(titleFromGeneratedText('```json\n{"title":"Codex Panelの自動命名"}\n```')).toBe("Codex Panelの自動命名");
|
||||
expect(threadTitleFromGeneratedText('```json\n{"title":"Codex Panelの自動命名"}\n```')).toBe("Codex Panelの自動命名");
|
||||
});
|
||||
|
||||
it("asks the model to infer the title language from the initial request", () => {
|
||||
const prompt = namingPrompt({
|
||||
const prompt = threadTitlePrompt({
|
||||
userRequest: "Please fix the automatic thread naming behavior.",
|
||||
assistantResponse: "I found the prompt and adjusted it.",
|
||||
});
|
||||
|
|
@ -218,29 +118,29 @@ describe("thread naming", () => {
|
|||
expect(prompt).not.toContain("English words");
|
||||
});
|
||||
|
||||
it("uses explicit naming runtime overrides", () => {
|
||||
expect(threadNamingRuntimeOverride({ threadNamingModel: "gpt-5.4-mini", threadNamingEffort: "minimal" })).toEqual({
|
||||
it("uses explicit title runtime overrides", () => {
|
||||
expect(threadTitleRuntimeOverride({ threadNamingModel: "gpt-5.4-mini", threadNamingEffort: "minimal" })).toEqual({
|
||||
model: "gpt-5.4-mini",
|
||||
effort: "minimal",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits naming runtime overrides that are set to Codex default", () => {
|
||||
expect(threadNamingRuntimeOverride({ threadNamingModel: null, threadNamingEffort: null })).toEqual({});
|
||||
it("omits title runtime overrides that are set to Codex default", () => {
|
||||
expect(threadTitleRuntimeOverride({ threadNamingModel: null, threadNamingEffort: null })).toEqual({});
|
||||
});
|
||||
|
||||
it("omits an explicit naming effort when the selected model does not support it", () => {
|
||||
it("omits an explicit title effort when the selected model does not support it", () => {
|
||||
expect(
|
||||
validatedThreadNamingRuntimeOverride(
|
||||
validatedThreadTitleRuntimeOverride(
|
||||
{ threadNamingModel: "gpt-5.4-mini", threadNamingEffort: "minimal" },
|
||||
modelMetadataFromAppServerModels([model("gpt-5.4-mini", ["low", "medium", "high", "xhigh"])]),
|
||||
),
|
||||
).toEqual({ model: "gpt-5.4-mini" });
|
||||
});
|
||||
|
||||
it("keeps an explicit naming effort when the selected model supports it", () => {
|
||||
it("keeps an explicit title effort when the selected model supports it", () => {
|
||||
expect(
|
||||
validatedThreadNamingRuntimeOverride(
|
||||
validatedThreadTitleRuntimeOverride(
|
||||
{ threadNamingModel: "gpt-5.4-mini", threadNamingEffort: "low" },
|
||||
modelMetadataFromAppServerModels([model("gpt-5.4-mini", ["low", "medium", "high", "xhigh"])]),
|
||||
),
|
||||
|
|
@ -248,7 +148,7 @@ describe("thread naming", () => {
|
|||
});
|
||||
|
||||
it("keeps pre-acknowledgement completion when title notifications arrive before turn/start resolves", async () => {
|
||||
const { clientFactory } = fakeThreadNamingClientFactory((fake) => {
|
||||
const { clientFactory } = fakeThreadTitleClientFactory((fake) => {
|
||||
fake.startStructuredTurnImpl = async () => {
|
||||
fake.emit(completedItemNotification("thread", "turn", assistantMessage("answer", '{"title":"Early title"}')));
|
||||
fake.emit(turnCompletedNotification("thread", turn([], { id: "turn", status: "completed" })));
|
||||
|
|
@ -256,13 +156,13 @@ describe("thread naming", () => {
|
|||
};
|
||||
});
|
||||
|
||||
await expect(generateThreadTitleWithCodex("/bin/codex", "/vault", namingContext(), runtimeSettings(), clientFactory)).resolves.toBe(
|
||||
await expect(generateThreadTitleWithCodex("/bin/codex", "/vault", titleContext(), runtimeSettings(), clientFactory)).resolves.toBe(
|
||||
"Early title",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function namingContext() {
|
||||
function titleContext() {
|
||||
return {
|
||||
userRequest: "Please name this.",
|
||||
assistantResponse: "Done.",
|
||||
|
|
@ -276,22 +176,22 @@ function runtimeSettings() {
|
|||
};
|
||||
}
|
||||
|
||||
function fakeThreadNamingClientFactory(configure?: (client: FakeThreadNamingClient) => void): {
|
||||
clientFactory: ThreadNamingClientFactory;
|
||||
client: { current: FakeThreadNamingClient | null };
|
||||
function fakeThreadTitleClientFactory(configure?: (client: FakeThreadTitleClient) => void): {
|
||||
clientFactory: ThreadTitleClientFactory;
|
||||
client: { current: FakeThreadTitleClient | null };
|
||||
} {
|
||||
const client: { current: FakeThreadNamingClient | null } = { current: null };
|
||||
const client: { current: FakeThreadTitleClient | null } = { current: null };
|
||||
return {
|
||||
client,
|
||||
clientFactory: (_codexPath, _cwd, handlers) => {
|
||||
client.current = new FakeThreadNamingClient(handlers);
|
||||
client.current = new FakeThreadTitleClient(handlers);
|
||||
configure?.(client.current);
|
||||
return client.current;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class FakeThreadNamingClient implements ThreadNamingClient {
|
||||
class FakeThreadTitleClient implements ThreadTitleClient {
|
||||
startStructuredTurnImpl: (() => Promise<TurnStartResponse>) | null = null;
|
||||
|
||||
constructor(private readonly handlers: AppServerClientHandlers) {}
|
||||
|
|
@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||
|
||||
import { DEFAULT_SETTINGS } from "../../../src/settings/model";
|
||||
import type { AppServerTurn } from "../../../src/app-server/turn-model";
|
||||
import type * as ThreadTitleGeneratorModule from "../../../src/app-server/thread-title-generation";
|
||||
import type * as ThreadTitleGeneratorModule from "../../../src/features/thread-title/generation";
|
||||
import { deferred, waitForAsyncWork } from "../../support/async";
|
||||
import { changeInputValue, installObsidianDomShims } from "../../support/dom";
|
||||
|
||||
|
|
@ -66,7 +66,7 @@ vi.mock("../../../src/app-server/connection-manager", () => {
|
|||
return { ConnectionManager, StaleConnectionError };
|
||||
});
|
||||
|
||||
vi.mock("../../../src/app-server/thread-title-generation", async (importOriginal) => {
|
||||
vi.mock("../../../src/features/thread-title/generation", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof ThreadTitleGeneratorModule>();
|
||||
return {
|
||||
...actual,
|
||||
|
|
|
|||
Loading…
Reference in a new issue