Extract connection controller

This commit is contained in:
murashit 2026-05-29 06:22:44 +09:00
parent ad25b95eb2
commit 7ec151b0ea
3 changed files with 255 additions and 71 deletions

View file

@ -0,0 +1,125 @@
import { StaleConnectionError } from "../../app-server/connection-manager";
import type { AppServerClient } from "../../app-server/client";
import type { InitializeResponse } from "../../generated/app-server/InitializeResponse";
import type { ChatAction, ChatStateStore } from "./chat-state";
import type { ChatAppServerController } from "./chat-app-server-controller";
import type { ChatConnectionWorkTracker, ActiveChatConnection } from "./view-lifecycle";
export interface ChatConnectionAdapter {
connect(): Promise<InitializeResponse>;
currentClient(): AppServerClient | null;
isConnected(): boolean;
}
export interface ChatConnectionControllerHost {
stateStore: ChatStateStore;
connection: ChatConnectionAdapter;
connectionWork: ChatConnectionWorkTracker;
appServer: ChatAppServerController;
setClient: (client: AppServerClient | null) => void;
loadSharedThreadList: () => Promise<void>;
scheduleDeferredDiagnostics: () => void;
clearDeferredDiagnostics: () => void;
refreshTabHeader: () => void;
setStatus: (status: string) => void;
addSystemMessage: (text: string) => void;
render: () => void;
scheduleRender: () => void;
notifyConnectionFailed: () => void;
}
export class ChatConnectionController {
constructor(private readonly host: ChatConnectionControllerHost) {}
async ensureConnected(): Promise<void> {
const connecting = this.host.connectionWork.active();
if (connecting?.promise) return connecting.promise;
if (this.host.connection.isConnected()) {
this.host.setClient(this.host.connection.currentClient());
return;
}
const connection = this.host.connectionWork.begin();
const promise = this.initializeConnection(connection);
connection.promise = promise;
try {
await promise;
} finally {
this.host.connectionWork.finish(connection, promise);
}
}
invalidate(): void {
this.host.connectionWork.invalidate();
}
async refreshThreads(): Promise<void> {
this.host.setClient(this.host.connection.currentClient());
if (!this.host.connection.currentClient()) return;
try {
await this.host.loadSharedThreadList();
await this.host.appServer.refreshPublishedAppServerMetadata();
this.host.refreshTabHeader();
this.host.render();
} catch (error) {
this.host.addSystemMessage(error instanceof Error ? error.message : String(error));
}
}
async refreshDiagnostics(): Promise<void> {
this.host.clearDeferredDiagnostics();
await this.ensureConnected();
if (!this.host.connection.currentClient()) return;
this.host.clearDeferredDiagnostics();
await this.host.appServer.refreshPublishedCapabilityDiagnostics();
this.host.render();
}
async refreshStatusPanel(): Promise<void> {
try {
await this.refreshDiagnostics();
} catch (error) {
this.host.addSystemMessage(error instanceof Error ? error.message : String(error));
}
await this.refreshThreads();
}
async refreshSkills(forceReload = false): Promise<void> {
this.host.setClient(this.host.connection.currentClient());
if (!this.host.connection.currentClient()) return;
await this.host.appServer.refreshPublishedSkills(forceReload);
this.host.render();
}
private async initializeConnection(connection: ActiveChatConnection): Promise<void> {
this.host.setStatus("Starting Codex app-server...");
try {
this.dispatch({ type: "connection/initialized", initializeResponse: await this.host.connection.connect() });
if (this.host.connectionWork.isStale(connection)) return;
const client = this.host.connection.currentClient();
this.host.setClient(client);
if (!client) throw new Error("Codex app-server connection did not initialize.");
await this.host.appServer.refreshPublishedAppServerMetadata();
if (this.host.connectionWork.isStale(connection)) return;
await this.host.loadSharedThreadList();
if (this.host.connectionWork.isStale(connection)) return;
this.host.scheduleDeferredDiagnostics();
this.host.refreshTabHeader();
this.host.setStatus("Connected.");
} catch (error) {
if (this.host.connectionWork.isStale(connection)) return;
if (error instanceof StaleConnectionError) return;
this.host.setStatus("Connection failed.");
this.host.addSystemMessage(error instanceof Error ? error.message : String(error));
this.host.notifyConnectionFailed();
}
if (!this.host.connectionWork.isStale(connection)) {
this.host.scheduleRender();
}
}
private dispatch(action: ChatAction): void {
this.host.stateStore.dispatch(action);
}
}

View file

@ -1,7 +1,7 @@
import { ItemView, Notice, type ViewStateResult, type WorkspaceLeaf } from "obsidian";
import type { AppServerClient } from "../../app-server/client";
import { ConnectionManager, StaleConnectionError } from "../../app-server/connection-manager";
import { ConnectionManager } from "../../app-server/connection-manager";
import { VIEW_TYPE_CODEX_PANEL } from "../../constants";
import { createSystemItem } from "./display/system";
import type { DisplayDetailSection, DisplayItem } from "./display/types";
@ -53,7 +53,6 @@ import {
ChatConnectionWorkTracker,
ChatResumeWorkTracker,
ChatViewDeferredTasks,
type ActiveChatConnection,
type ActiveChatResume,
type ChatViewRenderScheduleOptions,
} from "./view-lifecycle";
@ -65,6 +64,7 @@ import { ChatMessageScrollController } from "./message-scroll-controller";
import { TurnSubmissionController } from "./turn-submission-controller";
import { SlashCommandController } from "./slash-command-controller";
import { ComposerSubmissionController } from "./composer-submission-controller";
import { ChatConnectionController } from "./connection-controller";
export interface CodexChatHost {
readonly settings: CodexPanelSettings;
@ -88,6 +88,7 @@ export class CodexChatView extends ItemView {
private readonly connection: ConnectionManager;
private readonly controller: ChatController;
private readonly appServer: ChatAppServerController;
private readonly connectionController: ChatConnectionController;
private readonly history: ThreadHistoryLoader;
private readonly threadActions: ChatThreadActionController;
private readonly runtimeSettings: ChatRuntimeSettingsController;
@ -308,6 +309,40 @@ export class CodexChatView extends ItemView {
this.plugin.publishAppServerMetadata(metadata);
},
});
this.connectionController = new ChatConnectionController({
stateStore: this.chatState,
connection: this.connection,
connectionWork: this.connectionWork,
appServer: this.appServer,
setClient: (client) => {
this.client = client;
},
loadSharedThreadList: () => this.loadSharedThreadList(),
scheduleDeferredDiagnostics: () => {
this.scheduleDeferredDiagnostics();
},
clearDeferredDiagnostics: () => {
this.clearDeferredDiagnostics();
},
refreshTabHeader: () => {
this.refreshTabHeader();
},
setStatus: (status) => {
this.setStatus(status);
},
addSystemMessage: (text) => {
this.addSystemMessage(text);
},
render: () => {
this.render();
},
scheduleRender: () => {
this.scheduleRender();
},
notifyConnectionFailed: () => {
new Notice("Codex app-server connection failed.");
},
});
this.history = new ThreadHistoryLoader({
stateStore: this.chatState,
currentClient: () => this.client,
@ -627,52 +662,11 @@ export class CodexChatView extends ItemView {
}
private async ensureConnected(): Promise<void> {
const connecting = this.connectionWork.active();
if (connecting?.promise) return connecting.promise;
if (this.connection.isConnected()) {
this.client = this.connection.currentClient();
return;
}
const connection = this.connectionWork.begin();
const promise = this.initializeConnection(connection);
connection.promise = promise;
try {
await promise;
} finally {
this.connectionWork.finish(connection, promise);
}
}
private async initializeConnection(connection: ActiveChatConnection): Promise<void> {
this.setStatus("Starting Codex app-server...");
try {
this.dispatch({ type: "connection/initialized", initializeResponse: await this.connection.connect() });
if (this.connectionWork.isStale(connection)) return;
this.client = this.connection.currentClient();
if (!this.client) throw new Error("Codex app-server connection did not initialize.");
await this.appServer.refreshPublishedAppServerMetadata();
if (this.connectionWork.isStale(connection)) return;
await this.loadSharedThreadList();
if (this.connectionWork.isStale(connection)) return;
this.scheduleDeferredDiagnostics();
this.refreshTabHeader();
this.setStatus("Connected.");
} catch (error) {
if (this.connectionWork.isStale(connection)) return;
if (error instanceof StaleConnectionError) return;
this.setStatus("Connection failed.");
this.addSystemMessage(error instanceof Error ? error.message : String(error));
new Notice("Codex app-server connection failed.");
}
if (!this.connectionWork.isStale(connection)) {
this.scheduleRender();
}
await this.connectionController.ensureConnected();
}
private invalidateConnectionWork(): void {
this.connectionWork.invalidate();
this.connectionController.invalidate();
}
async startNewThread(): Promise<void> {
@ -687,41 +681,19 @@ export class CodexChatView extends ItemView {
}
private async refreshThreads(): Promise<void> {
this.client = this.connection.currentClient();
if (!this.client) return;
try {
await this.loadSharedThreadList();
await this.appServer.refreshPublishedAppServerMetadata();
this.refreshTabHeader();
this.render();
} catch (error) {
this.addSystemMessage(error instanceof Error ? error.message : String(error));
}
await this.connectionController.refreshThreads();
}
private async refreshDiagnostics(): Promise<void> {
this.clearDeferredDiagnostics();
await this.ensureConnected();
if (!this.client) return;
this.clearDeferredDiagnostics();
await this.appServer.refreshPublishedCapabilityDiagnostics();
this.render();
await this.connectionController.refreshDiagnostics();
}
private async refreshStatusPanel(): Promise<void> {
try {
await this.refreshDiagnostics();
} catch (error) {
this.addSystemMessage(error instanceof Error ? error.message : String(error));
}
await this.refreshThreads();
await this.connectionController.refreshStatusPanel();
}
private async refreshSkills(forceReload = false): Promise<void> {
this.client = this.connection.currentClient();
if (!this.client) return;
await this.appServer.refreshPublishedSkills(forceReload);
this.render();
await this.connectionController.refreshSkills(forceReload);
}
private async resumeThread(threadId: string): Promise<void> {

View file

@ -0,0 +1,87 @@
import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../../src/app-server/client";
import { createChatState, createChatStateStore } from "../../../src/features/chat/chat-state";
import { ChatConnectionController, type ChatConnectionAdapter } from "../../../src/features/chat/connection-controller";
import { ChatConnectionWorkTracker } from "../../../src/features/chat/view-lifecycle";
import type { ChatAppServerController } from "../../../src/features/chat/chat-app-server-controller";
function createController({ connected = false, client = {} as AppServerClient } = {}) {
const stateStore = createChatStateStore(createChatState());
let currentClient: AppServerClient | null = connected ? client : null;
const connect = vi.fn().mockImplementation(async () => {
currentClient = client;
return { codexHome: "/codex", platformFamily: "unix", platformOs: "macos", userAgent: "test" };
});
const connection: ChatConnectionAdapter = {
connect,
currentClient: () => currentClient,
isConnected: () => Boolean(currentClient),
};
const refreshPublishedAppServerMetadata = vi.fn().mockResolvedValue(null);
const refreshPublishedCapabilityDiagnostics = vi.fn().mockResolvedValue(undefined);
const refreshPublishedSkills = vi.fn().mockResolvedValue(undefined);
const appServer = {
refreshPublishedAppServerMetadata,
refreshPublishedCapabilityDiagnostics,
refreshPublishedSkills,
} as unknown as ChatAppServerController;
const setClient = vi.fn((next: AppServerClient | null) => {
currentClient = next;
});
const host = {
stateStore,
connection,
connectionWork: new ChatConnectionWorkTracker(),
appServer,
setClient,
loadSharedThreadList: vi.fn().mockResolvedValue(undefined),
scheduleDeferredDiagnostics: vi.fn(),
clearDeferredDiagnostics: vi.fn(),
refreshTabHeader: vi.fn(),
setStatus: vi.fn(),
addSystemMessage: vi.fn(),
render: vi.fn(),
scheduleRender: vi.fn(),
notifyConnectionFailed: vi.fn(),
};
return {
connect,
controller: new ChatConnectionController(host),
host,
refreshPublishedAppServerMetadata,
refreshPublishedCapabilityDiagnostics,
stateStore,
};
}
describe("ChatConnectionController", () => {
it("connects once and publishes startup metadata", async () => {
const { connect, controller, host, refreshPublishedAppServerMetadata, stateStore } = createController();
await controller.ensureConnected();
expect(connect).toHaveBeenCalledOnce();
expect(stateStore.getState().initializeResponse).toEqual({
codexHome: "/codex",
platformFamily: "unix",
platformOs: "macos",
userAgent: "test",
});
expect(refreshPublishedAppServerMetadata).toHaveBeenCalledOnce();
expect(host.loadSharedThreadList).toHaveBeenCalledOnce();
expect(host.scheduleDeferredDiagnostics).toHaveBeenCalledOnce();
expect(host.setStatus).toHaveBeenCalledWith("Connected.");
expect(host.scheduleRender).toHaveBeenCalledOnce();
});
it("refreshes diagnostics after clearing deferred diagnostics", async () => {
const { controller, host, refreshPublishedCapabilityDiagnostics } = createController({ connected: true });
await controller.refreshDiagnostics();
expect(host.clearDeferredDiagnostics).toHaveBeenCalledTimes(2);
expect(refreshPublishedCapabilityDiagnostics).toHaveBeenCalledOnce();
expect(host.render).toHaveBeenCalledOnce();
});
});