diff --git a/src/features/chat/composition.ts b/src/features/chat/composition.ts index f9b6c096..c50ba756 100644 --- a/src/features/chat/composition.ts +++ b/src/features/chat/composition.ts @@ -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: { diff --git a/src/features/chat/connection/composition.ts b/src/features/chat/connection/composition.ts index c6386e2b..7a95c617 100644 --- a/src/features/chat/connection/composition.ts +++ b/src/features/chat/connection/composition.ts @@ -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[1], result: unknown) => boolean; rejectServerRequest: (requestId: Parameters[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), diff --git a/src/features/chat/threads/auto-title-controller.ts b/src/features/chat/threads/auto-title-controller.ts new file mode 100644 index 00000000..27f00d30 --- /dev/null +++ b/src/features/chat/threads/auto-title-controller.ts @@ -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; +} + +export class AutoTitleController { + private activeThreadHadTurns = false; + private readonly attemptedThreadIds = new Set(); + private readonly inFlightThreadIds = new Set(); + + 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 { + 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 { + 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); + } +} diff --git a/src/features/chat/threads/composition.ts b/src/features/chat/threads/composition.ts index f86abcdc..66e2995d 100644 --- a/src/features/chat/threads/composition.ts +++ b/src/features/chat/threads/composition.ts @@ -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, }; } diff --git a/src/features/chat/threads/rename-controller.ts b/src/features/chat/threads/rename-controller.ts index 07fe67ad..112108b0 100644 --- a/src/features/chat/threads/rename-controller.ts +++ b/src/features/chat/threads/rename-controller.ts @@ -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; + generateThreadTitle?: (context: ThreadTitleContext) => Promise; } export class RenameController { - private activeThreadHadTurns = false; - private readonly autoNameAttemptedThreadIds = new Set(); - private readonly autoNameInFlightThreadIds = new Set(); 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 { - 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 { + private async resolveNamingContext(threadId: string): Promise { 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 { + private async generateTitle(context: ThreadTitleContext): Promise { 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); } diff --git a/src/features/chat/threads/naming.ts b/src/features/chat/threads/title-context.ts similarity index 73% rename from src/features/chat/threads/naming.ts rename to src/features/chat/threads/title-context.ts index 6e4bcad0..ea0ef283 100644 --- a/src/features/chat/threads/naming.ts +++ b/src/features/chat/threads/title-context.ts @@ -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(); 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) { diff --git a/src/app-server/thread-title-generation.ts b/src/features/thread-title/generation.ts similarity index 53% rename from src/app-server/thread-title-generation.ts rename to src/features/thread-title/generation.ts index cecf9867..03faef10 100644 --- a/src/app-server/thread-title-generation.ts +++ b/src/features/thread-title/generation.ts @@ -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 { 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 { - const runtime = threadNamingRuntimeOverride(settings); + settings: ThreadTitleRuntimeSettings, +): Promise { + 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; } diff --git a/src/domain/threads/naming.ts b/src/features/thread-title/model.ts similarity index 79% rename from src/domain/threads/naming.ts rename to src/features/thread-title/model.ts index e2513dc2..6cb06048 100644 --- a/src/domain/threads/naming.ts +++ b/src/features/thread-title/model.ts @@ -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; +) => Promise; -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 { +}): Promise { 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.", "", diff --git a/src/features/threads-view/view.ts b/src/features/threads-view/view.ts index 774cdbcd..d65ed492 100644 --- a/src/features/threads-view/view.ts +++ b/src/features/threads-view/view.ts @@ -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, diff --git a/tests/features/chat/threads/auto-title-controller.test.ts b/tests/features/chat/threads/auto-title-controller.test.ts new file mode 100644 index 00000000..94badfd0 --- /dev/null +++ b/tests/features/chat/threads/auto-title-controller.test.ts @@ -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(); + 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(); + 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[0]> = {}, +): ConstructorParameters[0] & { controller: AutoTitleController; render: ReturnType } { + 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[0]; + return { ...host, controller: new AutoTitleController(host), render }; +} + +function fakeClient(options: { setThreadName?: ReturnType } = {}): 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 { + await Promise.resolve(); + await Promise.resolve(); +} diff --git a/tests/features/chat/threads/rename-controller.test.ts b/tests/features/chat/threads/rename-controller.test.ts index f35479e5..4e24ea6d 100644 --- a/tests/features/chat/threads/rename-controller.test.ts +++ b/tests/features/chat/threads/rename-controller.test.ts @@ -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(); - 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(); - 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( diff --git a/tests/features/chat/threads/title-context.test.ts b/tests/features/chat/threads/title-context.test.ts new file mode 100644 index 00000000..facb4b79 --- /dev/null +++ b/tests/features/chat/threads/title-context.test.ts @@ -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: "表示済み履歴を使います。", + }); + }); +}); diff --git a/tests/features/chat/threads/naming.test.ts b/tests/features/thread-title/thread-title.test.ts similarity index 55% rename from tests/features/chat/threads/naming.test.ts rename to tests/features/thread-title/thread-title.test.ts index efd29416..59067907 100644 --- a/tests/features/chat/threads/naming.test.ts +++ b/tests/features/thread-title/thread-title.test.ts @@ -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>; @@ -32,10 +29,10 @@ type ThreadStartResponse = Awaited>; -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) | null = null; constructor(private readonly handlers: AppServerClientHandlers) {} diff --git a/tests/features/threads-view/view.test.ts b/tests/features/threads-view/view.test.ts index 2db912a8..00fbb19b 100644 --- a/tests/features/threads-view/view.test.ts +++ b/tests/features/threads-view/view.test.ts @@ -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(); return { ...actual,