Extract thread title feature controllers

This commit is contained in:
murashit 2026-06-12 13:58:18 +09:00
parent c516ccee6b
commit ada4ed5281
14 changed files with 441 additions and 332 deletions

View file

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

View file

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

View 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);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,

View 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();
}

View file

@ -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(

View 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: "表示済み履歴を使います。",
});
});
});

View file

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

View file

@ -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,