Replace stateless thread services with factories

This commit is contained in:
murashit 2026-06-16 11:32:07 +09:00
parent 0de3db212e
commit 16413a52df
7 changed files with 140 additions and 99 deletions

View file

@ -13,8 +13,8 @@ import { getThreadTitle } from "../../../domain/threads/model";
import type { SharedServerMetadata } from "../../../domain/server/metadata";
import { ConnectionWorkTracker } from "../../../shared/lifecycle/connection-work";
import { shortThreadId } from "../../../utils";
import { ThreadOperations } from "../../threads/thread-operations";
import { ThreadTitleService } from "../../threads/thread-title-service";
import { createThreadOperations, type ThreadOperations } from "../../threads/thread-operations";
import { createThreadTitleService, type ThreadTitleService } from "../../threads/thread-title-service";
import { PendingRequestController } from "../application/pending-requests/controller";
import type { MessageStreamItem, MessageStreamNoticeSection } from "../domain/message-stream/items";
import { createStructuredSystemItem, createSystemItem } from "../domain/message-stream/factories/system-items";
@ -619,7 +619,7 @@ export class ChatPanelSession implements ChatSurfaceHandle {
private createThreadTitleService(currentClient: CurrentAppServerClient): ThreadTitleService {
const environment = this.environment;
return new ThreadTitleService({
return createThreadTitleService({
settings: {
current: () => environment.plugin.settingsRef.settings,
vaultPath: environment.plugin.settingsRef.vaultPath,
@ -669,7 +669,7 @@ export class ChatPanelSession implements ChatSurfaceHandle {
private createThreadOperations(currentClient: CurrentAppServerClient, ensureConnected: () => Promise<void>): ThreadOperations {
const environment = this.environment;
return new ThreadOperations({
return createThreadOperations({
connection: {
ensureConnected,
currentClient,

View file

@ -10,8 +10,8 @@ import type { OpenCodexPanelSnapshot } from "../../workspace/open-panel-snapshot
import type { SharedThreadCatalog } from "../../workspace/shared-thread-catalog";
import { ConnectionWorkTracker } from "../../shared/lifecycle/connection-work";
import type { ArchiveExportAdapter } from "../../app-server/services/thread-archive-markdown";
import { ThreadOperations } from "../threads/thread-operations";
import { ThreadTitleService } from "../threads/thread-title-service";
import { createThreadOperations, type ThreadOperations } from "../threads/thread-operations";
import { createThreadTitleService, type ThreadTitleService } from "../threads/thread-title-service";
import { renderThreadsView, unmountThreadsView } from "./renderer";
import {
completedThreadAutoNameState,
@ -77,7 +77,7 @@ export class CodexThreadsSession {
constructor(private readonly environment: CodexThreadsSessionEnvironment) {
this.deferredTasks = createThreadsViewDeferredTasks(() => this.viewWindow());
this.connection = new ConnectionManager(() => this.host.settings.codexPath, this.host.vaultPath);
this.operations = new ThreadOperations({
this.operations = createThreadOperations({
connection: {
ensureConnected: () => this.ensureConnected(),
currentClient: () => this.client,
@ -92,7 +92,7 @@ export class CodexThreadsSession {
new Notice(message);
},
});
this.titleService = new ThreadTitleService({
this.titleService = createThreadTitleService({
settings: {
current: () => this.host.settings,
vaultPath: this.host.vaultPath,

View file

@ -21,57 +21,79 @@ export interface ThreadOperationsHost {
notice(message: string): void;
}
export interface ArchiveThreadOptions {
interface ArchiveThreadOptions {
saveMarkdown?: boolean;
closeOpenPanels?: boolean;
}
export interface RenameThreadOptions {
interface RenameThreadOptions {
shouldPublish?: () => boolean;
}
export class ThreadOperations {
constructor(private readonly host: ThreadOperationsHost) {}
async renameThread(threadId: string, value: string, options: RenameThreadOptions = {}): Promise<boolean> {
const rename = threadRenameFromValue(value);
if (!rename) return false;
await this.host.connection.ensureConnected();
return this.renameConnectedThread(threadId, rename, options);
}
private async renameConnectedThread(threadId: string, rename: ThreadRename, options: RenameThreadOptions = {}): Promise<boolean> {
const client = this.host.connection.currentClient();
if (!client) return false;
const result = await renameThreadOnAppServer(client, threadId, rename);
if (options.shouldPublish?.() ?? true) {
this.host.catalog.renameThreadInCatalog(threadId, result.name);
}
return true;
}
async archiveThread(threadId: string, options: ArchiveThreadOptions = {}): Promise<ArchiveThreadResult | null> {
await this.host.connection.ensureConnected();
const client = this.host.connection.currentClient();
if (!client) return null;
const settings = this.host.settings.current();
const result = await archiveThreadOnAppServer(client, threadId, {
settings,
vaultPath: this.host.settings.vaultPath,
archiveAdapter: () => this.host.archiveAdapter(),
saveMarkdown: options.saveMarkdown ?? settings.archiveExportEnabled,
});
if (result.exportedPath) {
this.host.notice(`Saved archived thread to ${result.exportedPath}.`);
}
if (options.closeOpenPanels === undefined) {
this.host.catalog.archiveThreadInCatalog(threadId);
} else {
this.host.catalog.archiveThreadInCatalog(threadId, { closeOpenPanels: options.closeOpenPanels });
}
return result;
}
export interface ThreadOperations {
renameThread(threadId: string, value: string, options?: RenameThreadOptions): Promise<boolean>;
archiveThread(threadId: string, options?: ArchiveThreadOptions): Promise<ArchiveThreadResult | null>;
}
export function createThreadOperations(host: ThreadOperationsHost): ThreadOperations {
return {
renameThread: (threadId, value, options) => renameThread(host, threadId, value, options),
archiveThread: (threadId, options) => archiveThread(host, threadId, options),
};
}
async function renameThread(
host: ThreadOperationsHost,
threadId: string,
value: string,
options: RenameThreadOptions = {},
): Promise<boolean> {
const rename = threadRenameFromValue(value);
if (!rename) return false;
await host.connection.ensureConnected();
return renameConnectedThread(host, threadId, rename, options);
}
async function renameConnectedThread(
host: ThreadOperationsHost,
threadId: string,
rename: ThreadRename,
options: RenameThreadOptions = {},
): Promise<boolean> {
const client = host.connection.currentClient();
if (!client) return false;
const result = await renameThreadOnAppServer(client, threadId, rename);
if (options.shouldPublish?.() ?? true) {
host.catalog.renameThreadInCatalog(threadId, result.name);
}
return true;
}
async function archiveThread(
host: ThreadOperationsHost,
threadId: string,
options: ArchiveThreadOptions = {},
): Promise<ArchiveThreadResult | null> {
await host.connection.ensureConnected();
const client = host.connection.currentClient();
if (!client) return null;
const settings = host.settings.current();
const result = await archiveThreadOnAppServer(client, threadId, {
settings,
vaultPath: host.settings.vaultPath,
archiveAdapter: () => host.archiveAdapter(),
saveMarkdown: options.saveMarkdown ?? settings.archiveExportEnabled,
});
if (result.exportedPath) {
host.notice(`Saved archived thread to ${result.exportedPath}.`);
}
if (options.closeOpenPanels === undefined) {
host.catalog.archiveThreadInCatalog(threadId);
} else {
host.catalog.archiveThreadInCatalog(threadId, { closeOpenPanels: options.closeOpenPanels });
}
return result;
}

View file

@ -21,42 +21,57 @@ export interface ThreadTitleServiceHost {
generateThreadTitle?(context: ThreadTitleContext): Promise<string | null>;
}
export class ThreadTitleService {
constructor(private readonly host: ThreadTitleServiceHost) {}
async generateTitle(threadId: string): Promise<string> {
const context = await this.resolveContext(threadId);
if (!context) throw new Error(THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE);
const title = await this.generate(context);
if (!title) throw new Error("Codex did not return a usable thread title.");
return title;
}
async resolveContext(threadId: string): Promise<ThreadTitleContext | null> {
const client = this.host.currentClient();
const persistedContext = client
? await findThreadTitleContext({
threadId,
readTurns: (id, cursor, limit, sortDirection) => readCompletedConversationSummariesPage(client, id, cursor, limit, sortDirection),
})
: null;
return persistedContext ?? this.host.visibleContext?.(threadId) ?? null;
}
completedTurnContext(turnId: string, completedSummary: ThreadConversationSummary | null): ThreadTitleContext | null {
return (
this.host.visibleCompletedTurnContext?.(turnId) ??
(completedSummary ? threadTitleContextFromConversationSummary(completedSummary) : null)
);
}
async generate(context: ThreadTitleContext): Promise<string | null> {
if (this.host.generateThreadTitle) return this.host.generateThreadTitle(context);
const settings = this.host.settings.current();
return generateThreadTitleWithCodex(settings.codexPath, this.host.settings.vaultPath, context, {
threadNamingModel: settings.threadNamingModel,
threadNamingEffort: settings.threadNamingEffort,
});
}
export interface ThreadTitleService {
generateTitle(threadId: string): Promise<string>;
resolveContext(threadId: string): Promise<ThreadTitleContext | null>;
completedTurnContext(turnId: string, completedSummary: ThreadConversationSummary | null): ThreadTitleContext | null;
generate(context: ThreadTitleContext): Promise<string | null>;
}
export function createThreadTitleService(host: ThreadTitleServiceHost): ThreadTitleService {
return {
generateTitle: (threadId) => generateTitle(host, threadId),
resolveContext: (threadId) => resolveThreadTitleContext(host, threadId),
completedTurnContext: (turnId, completedSummary) => completedTurnContext(host, turnId, completedSummary),
generate: (context) => generateTitleFromContext(host, context),
};
}
async function generateTitle(host: ThreadTitleServiceHost, threadId: string): Promise<string> {
const context = await resolveThreadTitleContext(host, threadId);
if (!context) throw new Error(THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE);
const title = await generateTitleFromContext(host, context);
if (!title) throw new Error("Codex did not return a usable thread title.");
return title;
}
async function resolveThreadTitleContext(host: ThreadTitleServiceHost, threadId: string): Promise<ThreadTitleContext | null> {
const client = host.currentClient();
const persistedContext = client
? await findThreadTitleContext({
threadId,
readTurns: (id, cursor, limit, sortDirection) => readCompletedConversationSummariesPage(client, id, cursor, limit, sortDirection),
})
: null;
return persistedContext ?? host.visibleContext?.(threadId) ?? null;
}
function completedTurnContext(
host: ThreadTitleServiceHost,
turnId: string,
completedSummary: ThreadConversationSummary | null,
): ThreadTitleContext | null {
return (
host.visibleCompletedTurnContext?.(turnId) ?? (completedSummary ? threadTitleContextFromConversationSummary(completedSummary) : null)
);
}
async function generateTitleFromContext(host: ThreadTitleServiceHost, context: ThreadTitleContext): Promise<string | null> {
if (host.generateThreadTitle) return host.generateThreadTitle(context);
const settings = host.settings.current();
return generateThreadTitleWithCodex(settings.codexPath, host.settings.vaultPath, context, {
threadNamingModel: settings.threadNamingModel,
threadNamingEffort: settings.threadNamingEffort,
});
}

View file

@ -5,7 +5,7 @@ import { createChatStateStore } from "../../../../src/features/chat/application/
import { messageStreamItems } from "../../../../src/features/chat/application/state/message-stream";
import { AutoTitleController } from "../../../../src/features/chat/application/threads/auto-title-controller";
import { threadTitleContextFromMessageStreamItems } from "../../../../src/features/chat/application/threads/title-context";
import { ThreadTitleService } from "../../../../src/features/threads/thread-title-service";
import { createThreadTitleService } from "../../../../src/features/threads/thread-title-service";
import type { Thread } from "../../../../src/domain/threads/model";
import type { ThreadTitleContext } from "../../../../src/domain/threads/title-generation-model";
import { DEFAULT_SETTINGS } from "../../../../src/settings/model";
@ -134,7 +134,7 @@ function controllerFixture(
stateStore.dispatch({ type: "thread-list/applied", threads: [threadFixture("thread")] });
const currentClient = overrides.currentClient ?? (() => fakeClient());
const notifyThreadRenamed = vi.fn();
const titleService = new ThreadTitleService({
const titleService = createThreadTitleService({
settings: {
current: () => ({ ...DEFAULT_SETTINGS, codexPath: "codex" }),
vaultPath: "/vault",

View file

@ -4,7 +4,7 @@ import type { AppServerClient } from "../../../src/app-server/connection/client"
import type { ArchiveThreadResult } from "../../../src/app-server/services/thread-archive";
import type { ArchiveExportAdapter } from "../../../src/app-server/services/thread-archive-markdown";
import { DEFAULT_SETTINGS } from "../../../src/settings/model";
import { ThreadOperations, type ThreadOperationsHost } from "../../../src/features/threads/thread-operations";
import { createThreadOperations, type ThreadOperationsHost } from "../../../src/features/threads/thread-operations";
const archiveMock = vi.hoisted(() => ({
archiveThreadOnAppServer: vi.fn(),
@ -77,7 +77,7 @@ function operationsFixture(options: { client?: MockClient | null } = {}) {
catalog,
notice,
};
return { operations: new ThreadOperations(host), client, catalog, notice };
return { operations: createThreadOperations(host), client, catalog, notice };
}
type MockClient = ReturnType<typeof clientMock>;

View file

@ -3,7 +3,11 @@ import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../../src/app-server/connection/client";
import { THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE, type ThreadTitleContext } from "../../../src/domain/threads/title-generation-model";
import { DEFAULT_SETTINGS } from "../../../src/settings/model";
import { ThreadTitleService } from "../../../src/features/threads/thread-title-service";
import {
createThreadTitleService,
type ThreadTitleService,
type ThreadTitleServiceHost,
} from "../../../src/features/threads/thread-title-service";
describe("ThreadTitleService", () => {
it("generates a title from visible context without saving it", async () => {
@ -45,8 +49,8 @@ describe("ThreadTitleService", () => {
});
});
function titleService(options: Partial<ConstructorParameters<typeof ThreadTitleService>[0]> = {}): ThreadTitleService {
return new ThreadTitleService({
function titleService(options: Partial<ThreadTitleServiceHost> = {}): ThreadTitleService {
return createThreadTitleService({
settings: {
current: () => ({ ...DEFAULT_SETTINGS, codexPath: "codex" }),
vaultPath: "/vault",