From bfe03c2faefdbc4016da1f43174eecfdda8a1023 Mon Sep 17 00:00:00 2001 From: murashit Date: Thu, 4 Jun 2026 08:19:34 +0900 Subject: [PATCH] Narrow chat submission controller dependencies --- .../chat/chat-view-controller-assembly.ts | 108 +++++++++++------- .../composer-submission-controller.ts | 52 ++++++--- .../plan-implementation-controller.ts | 20 +++- .../submission/slash-command-controller.ts | 66 ++++++----- .../submission/turn-submission-controller.ts | 78 +++++++++---- .../composer-submission-controller.test.ts | 21 ++-- .../plan-implementation-controller.test.ts | 26 +++-- .../slash-command-controller.test.ts | 44 +++++-- .../turn-submission-controller.test.ts | 79 +++++++++++-- 9 files changed, 339 insertions(+), 155 deletions(-) diff --git a/src/features/chat/chat-view-controller-assembly.ts b/src/features/chat/chat-view-controller-assembly.ts index 9c49fcfa..df1a582e 100644 --- a/src/features/chat/chat-view-controller-assembly.ts +++ b/src/features/chat/chat-view-controller-assembly.ts @@ -165,45 +165,65 @@ export function createChatViewControllerAssembly(host: ChatViewControllerAssembl }); turnSubmission = new TurnSubmissionController({ state: submissionState, - vaultPath: host.plugin.vaultPath, - currentClient: host.getClient, - ensureRestoredThreadLoaded: host.ensureRestoredThreadLoaded, - startThread: (preview) => appServer.startThread(preview), - notifyActiveThreadIdentityChanged: host.effects.thread.notifyIdentityChanged, - resetThreadTurnPresence: host.effects.thread.resetTurnPresence, - applyPendingThreadSettings: () => runtimeSettings.applyPendingThreadSettings(), - codexInput: (text) => composerController.codexInput(text), - setDraft: (text, options) => { - composerController.setDraft(text, options); + connection: { + vaultPath: host.plugin.vaultPath, + currentClient: host.getClient, + }, + restoredThread: { + ensureRestoredThreadLoaded: host.ensureRestoredThreadLoaded, + }, + thread: { + startThread: (preview) => appServer.startThread(preview), + notifyActiveThreadIdentityChanged: host.effects.thread.notifyIdentityChanged, + resetThreadTurnPresence: host.effects.thread.resetTurnPresence, + }, + runtime: { + applyPendingThreadSettings: () => runtimeSettings.applyPendingThreadSettings(), + }, + composer: { + codexInput: (text) => composerController.codexInput(text), + setDraft: (text, options) => { + composerController.setDraft(text, options); + }, + }, + view: { + forceMessagesToBottom: host.effects.scroll.forceBottom, + render: host.effects.render.now, + scheduleRender: host.effects.render.schedule, + }, + status: { + setStatus: host.effects.status.set, + addSystemMessage: host.effects.status.addSystemMessage, }, - forceMessagesToBottom: host.effects.scroll.forceBottom, - render: host.effects.render.now, - scheduleRender: host.effects.render.schedule, - setStatus: host.effects.status.set, - addSystemMessage: host.effects.status.addSystemMessage, }); slashCommands = new SlashCommandController({ state: submissionState, currentClient: host.getClient, codexInput: (text) => composerController.codexInput(text), - startNewThread: host.startNewThread, - resumeThread: host.selectThread, - forkThread: (threadId) => threadActions.forkThread(threadId), - rollbackThread: (threadId) => threadActions.rollbackThread(threadId), - archiveThread: (threadId) => threadActions.archiveThread(threadId), - toggleFastMode: () => runtimeSettings.toggleFastMode(), - toggleCollaborationMode: () => runtimeSettings.toggleCollaborationMode(), - toggleAutoReview: () => void runtimeSettings.toggleAutoReview(), - addSystemMessage: host.effects.status.addSystemMessage, - addStructuredSystemMessage: host.effects.status.addStructuredSystemMessage, - setStatus: host.effects.status.set, - setRequestedModel: (model) => runtimeSettings.setRequestedModel(model), - setRequestedReasoningEffort: (effort) => runtimeSettings.setRequestedReasoningEffort(effort), - statusSummaryLines: host.statusSummaryLines, - connectionDiagnosticDetails: host.connectionDiagnosticDetails, - mcpStatusLines: host.mcpStatusLines, - modelStatusLines: host.modelStatusLines, - effortStatusLines: host.effortStatusLines, + threads: { + startNewThread: host.startNewThread, + resumeThread: host.selectThread, + forkThread: (threadId) => threadActions.forkThread(threadId), + rollbackThread: (threadId) => threadActions.rollbackThread(threadId), + archiveThread: (threadId) => threadActions.archiveThread(threadId), + }, + runtime: { + toggleFastMode: () => runtimeSettings.toggleFastMode(), + toggleCollaborationMode: () => runtimeSettings.toggleCollaborationMode(), + toggleAutoReview: () => void runtimeSettings.toggleAutoReview(), + setRequestedModel: (model) => runtimeSettings.setRequestedModel(model), + setRequestedReasoningEffort: (effort) => runtimeSettings.setRequestedReasoningEffort(effort), + }, + status: { + addSystemMessage: host.effects.status.addSystemMessage, + addStructuredSystemMessage: host.effects.status.addStructuredSystemMessage, + setStatus: host.effects.status.set, + statusSummaryLines: host.statusSummaryLines, + connectionDiagnosticDetails: host.connectionDiagnosticDetails, + mcpStatusLines: host.mcpStatusLines, + modelStatusLines: host.modelStatusLines, + effortStatusLines: host.effortStatusLines, + }, }); messageRenderer = new ChatMessageRenderer({ app: host.app, @@ -245,19 +265,27 @@ export function createChatViewControllerAssembly(host: ChatViewControllerAssembl composer: composerController, slashCommands, turnSubmission, - currentClient: host.getClient, - ensureConnected: host.effects.client.ensureConnected, - setStatus: host.effects.status.set, - addSystemMessage: host.effects.status.addSystemMessage, + connection: { + currentClient: host.getClient, + ensureConnected: host.effects.client.ensureConnected, + }, + status: { + setStatus: host.effects.status.set, + addSystemMessage: host.effects.status.addSystemMessage, + }, }); serverRequestResponder = new ServerRequestResponder({ currentClient: host.getClient, }); planImplementation = new PlanImplementationController({ state: submissionState, - currentClient: host.getClient, - ensureConnected: host.effects.client.ensureConnected, - sendTurnText: (text) => turnSubmission.sendTurnText(text), + connection: { + currentClient: host.getClient, + ensureConnected: host.effects.client.ensureConnected, + }, + submission: { + sendTurnText: (text) => turnSubmission.sendTurnText(text), + }, }); const connection = new ConnectionManager(() => host.plugin.settings.codexPath, host.plugin.vaultPath, { onNotification: (notification) => { diff --git a/src/features/chat/controllers/submission/composer-submission-controller.ts b/src/features/chat/controllers/submission/composer-submission-controller.ts index aade1619..a7c4061c 100644 --- a/src/features/chat/controllers/submission/composer-submission-controller.ts +++ b/src/features/chat/controllers/submission/composer-submission-controller.ts @@ -1,19 +1,41 @@ import type { AppServerClient } from "../../../../app-server/client"; import { parseSlashCommand } from "../../composer/suggestions"; -import type { ChatComposerController } from "../../chat-composer-controller"; -import type { SlashCommandController } from "./slash-command-controller"; -import type { TurnSubmissionController } from "./turn-submission-controller"; +import type { SlashCommandExecutionResult } from "../../slash-commands"; +import type { SlashCommandName } from "../../composer/slash-commands"; +import type { ReferencedThreadDisplay } from "../../../../domain/threads/reference"; +import type { UserInput } from "../../../../generated/app-server/v2/UserInput"; import type { SubmissionStatePort } from "../state-ports"; +export interface ComposerDraftPort { + readonly trimmedDraft: string; + setDraft(text: string, options?: { clearSuggestions?: boolean; focus?: boolean }): void; +} + +export interface ComposerSlashCommandPort { + execute(command: SlashCommandName, args: string): Promise; +} + +export interface ComposerTurnSubmissionPort { + sendTurnText(text: string, codexInputOverride?: UserInput[], referencedThread?: ReferencedThreadDisplay): Promise; +} + +export interface ComposerConnectionPort { + ensureConnected: () => Promise; + currentClient: () => AppServerClient | null; +} + +export interface ComposerStatusPort { + setStatus: (status: string) => void; + addSystemMessage: (text: string) => void; +} + export interface ComposerSubmissionControllerHost { state: SubmissionStatePort; - composer: ChatComposerController; - slashCommands: SlashCommandController; - turnSubmission: TurnSubmissionController; - currentClient: () => AppServerClient | null; - ensureConnected: () => Promise; - setStatus: (status: string) => void; - addSystemMessage: (text: string) => void; + composer: ComposerDraftPort; + slashCommands: ComposerSlashCommandPort; + turnSubmission: ComposerTurnSubmissionPort; + connection: ComposerConnectionPort; + status: ComposerStatusPort; } export class ComposerSubmissionController { @@ -33,8 +55,8 @@ export class ComposerSubmissionController { const text = this.host.composer.trimmedDraft; if (!text) return; - await this.host.ensureConnected(); - if (!this.host.currentClient()) return; + await this.host.connection.ensureConnected(); + if (!this.host.connection.currentClient()) return; const slashCommand = parseSlashCommand(text); if (slashCommand) { @@ -52,13 +74,13 @@ export class ComposerSubmissionController { private async interruptTurn(): Promise { const state = this.host.state.snapshot(); const turnId = state.activeTurnId; - const client = this.host.currentClient(); + const client = this.host.connection.currentClient(); if (!client || !state.activeThreadId || !turnId) return; try { await client.interruptTurn(state.activeThreadId, turnId); - this.host.setStatus("Interrupt requested."); + this.host.status.setStatus("Interrupt requested."); } catch (error) { - this.host.addSystemMessage(error instanceof Error ? error.message : String(error)); + this.host.status.addSystemMessage(error instanceof Error ? error.message : String(error)); } } } diff --git a/src/features/chat/controllers/submission/plan-implementation-controller.ts b/src/features/chat/controllers/submission/plan-implementation-controller.ts index 0e7a331b..026733df 100644 --- a/src/features/chat/controllers/submission/plan-implementation-controller.ts +++ b/src/features/chat/controllers/submission/plan-implementation-controller.ts @@ -4,11 +4,19 @@ import type { SubmissionStatePort } from "../state-ports"; const IMPLEMENT_PLAN_PROMPT = "Please implement this plan."; +export interface PlanImplementationConnectionPort { + currentClient(): AppServerClient | null; + ensureConnected(): Promise; +} + +export interface PlanImplementationSubmissionPort { + sendTurnText(text: string): Promise; +} + export interface PlanImplementationControllerHost { state: SubmissionStatePort; - currentClient: () => AppServerClient | null; - ensureConnected: () => Promise; - sendTurnText: (text: string) => Promise; + connection: PlanImplementationConnectionPort; + submission: PlanImplementationSubmissionPort; } export class PlanImplementationController { @@ -20,10 +28,10 @@ export class PlanImplementationController { async implement(item: DisplayItem): Promise { if (!this.canImplement(item)) return; - await this.host.ensureConnected(); - if (!this.host.currentClient() || !this.host.state.snapshot().activeThreadId) return; + await this.host.connection.ensureConnected(); + if (!this.host.connection.currentClient() || !this.host.state.snapshot().activeThreadId) return; this.host.state.prepareImplementationTurn(); - await this.host.sendTurnText(IMPLEMENT_PLAN_PROMPT); + await this.host.submission.sendTurnText(IMPLEMENT_PLAN_PROMPT); } } diff --git a/src/features/chat/controllers/submission/slash-command-controller.ts b/src/features/chat/controllers/submission/slash-command-controller.ts index 15cf1676..73af6feb 100644 --- a/src/features/chat/controllers/submission/slash-command-controller.ts +++ b/src/features/chat/controllers/submission/slash-command-controller.ts @@ -12,23 +12,26 @@ import type { ReasoningEffort } from "../../../../generated/app-server/Reasoning import type { UserInput } from "../../../../generated/app-server/v2/UserInput"; import type { SubmissionStatePort } from "../state-ports"; -export interface SlashCommandControllerHost { - state: SubmissionStatePort; - currentClient: () => AppServerClient | null; - codexInput: (text: string) => UserInput[]; +export interface SlashCommandThreadPort { startNewThread: () => Promise; resumeThread: (threadId: string) => Promise; forkThread: (threadId: string) => Promise; rollbackThread: (threadId: string) => Promise; archiveThread: (threadId: string) => Promise; +} + +export interface SlashCommandRuntimePort { toggleFastMode: () => void | Promise; toggleCollaborationMode: () => void | Promise; toggleAutoReview: () => void | Promise; + setRequestedModel: (model: string | null) => boolean | undefined | Promise; + setRequestedReasoningEffort: (effort: ReasoningEffort | null) => boolean | undefined | Promise; +} + +export interface SlashCommandStatusPort { addSystemMessage: (text: string) => void; addStructuredSystemMessage: (text: string, details: DisplayDetailSection[]) => void; setStatus: (status: string) => void; - setRequestedModel: (model: string | null) => boolean | undefined | Promise; - setRequestedReasoningEffort: (effort: ReasoningEffort | null) => boolean | undefined | Promise; statusSummaryLines: () => string[]; connectionDiagnosticDetails: () => DisplayDetailSection[]; mcpStatusLines: () => Promise; @@ -36,6 +39,15 @@ export interface SlashCommandControllerHost { effortStatusLines: () => string[]; } +export interface SlashCommandControllerHost { + state: SubmissionStatePort; + currentClient: () => AppServerClient | null; + codexInput: (text: string) => UserInput[]; + threads: SlashCommandThreadPort; + runtime: SlashCommandRuntimePort; + status: SlashCommandStatusPort; +} + export class SlashCommandController { constructor(private readonly host: SlashCommandControllerHost) {} @@ -46,35 +58,35 @@ export class SlashCommandController { return runSlashCommand(command, args, { activeThreadId: state.activeThreadId, listedThreads: state.listedThreads, - startNewThread: () => this.host.startNewThread(), - resumeThread: (threadId) => this.host.resumeThread(threadId), + startNewThread: () => this.host.threads.startNewThread(), + resumeThread: (threadId) => this.host.threads.resumeThread(threadId), referThread: (thread, message) => this.referencedThreadInput(client, thread, message), - forkThread: (threadId) => this.host.forkThread(threadId), - rollbackThread: (threadId) => this.host.rollbackThread(threadId), + forkThread: (threadId) => this.host.threads.forkThread(threadId), + rollbackThread: (threadId) => this.host.threads.rollbackThread(threadId), compactThread: async (threadId) => { await client.compactThread(threadId); }, - archiveThread: (threadId) => this.host.archiveThread(threadId), + archiveThread: (threadId) => this.host.threads.archiveThread(threadId), busy: state.busy, - toggleFastMode: () => this.host.toggleFastMode(), - toggleCollaborationMode: () => this.host.toggleCollaborationMode(), - toggleAutoReview: () => this.host.toggleAutoReview(), + toggleFastMode: () => this.host.runtime.toggleFastMode(), + toggleCollaborationMode: () => this.host.runtime.toggleCollaborationMode(), + toggleAutoReview: () => this.host.runtime.toggleAutoReview(), addSystemMessage: (text) => { - this.host.addSystemMessage(text); + this.host.status.addSystemMessage(text); }, addStructuredSystemMessage: (text, details) => { - this.host.addStructuredSystemMessage(text, details); + this.host.status.addStructuredSystemMessage(text, details); }, setStatus: (status) => { - this.host.setStatus(status); + this.host.status.setStatus(status); }, - setRequestedModel: (model) => this.host.setRequestedModel(model), - setRequestedReasoningEffort: (effort) => this.host.setRequestedReasoningEffort(effort), - statusSummaryLines: () => this.host.statusSummaryLines(), - connectionDiagnosticDetails: () => this.host.connectionDiagnosticDetails(), - mcpStatusLines: () => this.host.mcpStatusLines(), - modelStatusLines: () => this.host.modelStatusLines(), - effortStatusLines: () => this.host.effortStatusLines(), + setRequestedModel: (model) => this.host.runtime.setRequestedModel(model), + setRequestedReasoningEffort: (effort) => this.host.runtime.setRequestedReasoningEffort(effort), + statusSummaryLines: () => this.host.status.statusSummaryLines(), + connectionDiagnosticDetails: () => this.host.status.connectionDiagnosticDetails(), + mcpStatusLines: () => this.host.status.mcpStatusLines(), + modelStatusLines: () => this.host.status.modelStatusLines(), + effortStatusLines: () => this.host.status.effortStatusLines(), }); } @@ -83,14 +95,14 @@ export class SlashCommandController { const response = await client.threadTurnsList(thread.id, null, REFERENCED_THREAD_TURN_LIMIT); const turns = referencedThreadTurns(response.data); if (turns.length === 0) { - this.host.addSystemMessage("Referenced thread has no readable conversation turns."); + this.host.status.addSystemMessage("Referenced thread has no readable conversation turns."); return null; } const reference = buildReferencedThreadInput(thread, turns, message, this.host.codexInput(message)); - this.host.setStatus(reference.status); + this.host.status.setStatus(reference.status); return reference; } catch (error) { - this.host.addSystemMessage(error instanceof Error ? error.message : String(error)); + this.host.status.addSystemMessage(error instanceof Error ? error.message : String(error)); return null; } } diff --git a/src/features/chat/controllers/submission/turn-submission-controller.ts b/src/features/chat/controllers/submission/turn-submission-controller.ts index 8ea0b538..4ba2a4d4 100644 --- a/src/features/chat/controllers/submission/turn-submission-controller.ts +++ b/src/features/chat/controllers/submission/turn-submission-controller.ts @@ -10,30 +10,58 @@ import { } from "./turn-submission"; import type { SubmissionStatePort } from "../state-ports"; -export interface TurnSubmissionControllerHost { - state: SubmissionStatePort; +export interface TurnSubmissionConnectionPort { vaultPath: string; currentClient: () => AppServerClient | null; +} + +export interface TurnSubmissionRestoredThreadPort { ensureRestoredThreadLoaded: () => Promise; +} + +export interface TurnSubmissionThreadPort { startThread: (preview?: string) => Promise; notifyActiveThreadIdentityChanged: () => void; resetThreadTurnPresence: (hadTurns: boolean) => void; +} + +export interface TurnSubmissionRuntimePort { applyPendingThreadSettings: () => Promise; +} + +export interface TurnSubmissionComposerPort { codexInput: (text: string) => UserInput[]; setDraft: (text: string, options?: { focus?: boolean; clearSuggestions?: boolean }) => void; +} + +export interface TurnSubmissionViewPort { forceMessagesToBottom: () => void; render: () => void; scheduleRender: () => void; +} + +export interface TurnSubmissionStatusPort { setStatus: (status: string) => void; addSystemMessage: (text: string) => void; } +export interface TurnSubmissionControllerHost { + state: SubmissionStatePort; + connection: TurnSubmissionConnectionPort; + restoredThread: TurnSubmissionRestoredThreadPort; + thread: TurnSubmissionThreadPort; + runtime: TurnSubmissionRuntimePort; + composer: TurnSubmissionComposerPort; + view: TurnSubmissionViewPort; + status: TurnSubmissionStatusPort; +} + export class TurnSubmissionController { constructor(private readonly host: TurnSubmissionControllerHost) {} async sendTurnText(text: string, codexInputOverride?: UserInput[], referencedThread?: ReferencedThreadDisplay): Promise { - if (!(await this.host.ensureRestoredThreadLoaded())) return; - const client = this.host.currentClient(); + if (!(await this.host.restoredThread.ensureRestoredThreadLoaded())) return; + const client = this.host.connection.currentClient(); if (!client) return; if (this.state.busy) { @@ -44,16 +72,16 @@ export class TurnSubmissionController { let optimisticUserId: string | null = null; try { if (!this.state.activeThreadId) { - const threadResponse = await this.host.startThread(text); + const threadResponse = await this.host.thread.startThread(text); if (!threadResponse) return; - this.host.notifyActiveThreadIdentityChanged(); - this.host.resetThreadTurnPresence(false); + this.host.thread.notifyActiveThreadIdentityChanged(); + this.host.thread.resetThreadTurnPresence(false); } const activeThreadId = this.state.activeThreadId; if (!activeThreadId) return; - if (!(await this.host.applyPendingThreadSettings())) return; + if (!(await this.host.runtime.applyPendingThreadSettings())) return; - const codexInput = codexInputOverride ?? this.host.codexInput(text); + const codexInput = codexInputOverride ?? this.host.composer.codexInput(text); optimisticUserId = `local-user-${String(Date.now())}`; const optimistic = optimisticTurnStart({ id: optimisticUserId, @@ -62,11 +90,11 @@ export class TurnSubmissionController { referencedThread, }); this.host.state.optimisticTurnStarted(optimistic.item, optimistic.pendingTurnStart); - this.host.forceMessagesToBottom(); - this.host.setDraft(""); - this.host.render(); + this.host.view.forceMessagesToBottom(); + this.host.composer.setDraft(""); + this.host.view.render(); - const response = await client.startTurn(activeThreadId, this.host.vaultPath, codexInput, optimisticUserId); + const response = await client.startTurn(activeThreadId, this.host.connection.vaultPath, codexInput, optimisticUserId); const pendingStart = this.state.pendingTurnStart; if ( shouldAcknowledgeTurnStart({ @@ -83,7 +111,7 @@ export class TurnSubmissionController { pendingTurnStart: pendingStart, }); this.host.state.turnStartAcknowledged(response.turn.id, displayItems); - this.host.setStatus("Turn running..."); + this.host.status.setStatus("Turn running..."); } } catch (error) { const displayItems = cleanupFailedTurnStart({ @@ -92,10 +120,10 @@ export class TurnSubmissionController { pendingTurnStart: this.state.pendingTurnStart, }); this.host.state.turnStartFailed(displayItems); - this.host.setDraft(text); - this.host.addSystemMessage(error instanceof Error ? error.message : String(error)); + this.host.composer.setDraft(text); + this.host.status.addSystemMessage(error instanceof Error ? error.message : String(error)); } - this.host.scheduleRender(); + this.host.view.scheduleRender(); } private async steerCurrentTurn( @@ -107,13 +135,13 @@ export class TurnSubmissionController { const threadId = this.state.activeThreadId; const expectedTurnId = this.state.activeTurnId; if (!threadId || !expectedTurnId) { - this.host.addSystemMessage("Current turn is not steerable yet."); + this.host.status.addSystemMessage("Current turn is not steerable yet."); return; } - const codexInput = codexInputOverride ?? this.host.codexInput(text); + const codexInput = codexInputOverride ?? this.host.composer.codexInput(text); const localSteerId = `local-steer-${String(Date.now())}`; - this.host.setDraft("", { clearSuggestions: true }); + this.host.composer.setDraft("", { clearSuggestions: true }); try { await client.steerTurn(threadId, expectedTurnId, codexInput, localSteerId); @@ -126,14 +154,14 @@ export class TurnSubmissionController { codexInput, }), ); - this.host.forceMessagesToBottom(); - this.host.setStatus("Steered current turn."); + this.host.view.forceMessagesToBottom(); + this.host.status.setStatus("Steered current turn."); } catch (error) { - this.host.setDraft(text, { focus: true }); - this.host.addSystemMessage(error instanceof Error ? error.message : String(error)); + this.host.composer.setDraft(text, { focus: true }); + this.host.status.addSystemMessage(error instanceof Error ? error.message : String(error)); } - this.host.scheduleRender(); + this.host.view.scheduleRender(); } private get state() { diff --git a/tests/features/chat/controllers/submission/composer-submission-controller.test.ts b/tests/features/chat/controllers/submission/composer-submission-controller.test.ts index 34c3af7f..b8648a96 100644 --- a/tests/features/chat/controllers/submission/composer-submission-controller.test.ts +++ b/tests/features/chat/controllers/submission/composer-submission-controller.test.ts @@ -4,9 +4,6 @@ import type { AppServerClient } from "../../../../../src/app-server/client"; import { createChatState, createChatStateStore } from "../../../../../src/features/chat/chat-state"; import { ComposerSubmissionController } from "../../../../../src/features/chat/controllers/submission/composer-submission-controller"; import { createSubmissionStatePort } from "../../../../../src/features/chat/controllers/state-ports"; -import type { ChatComposerController } from "../../../../../src/features/chat/chat-composer-controller"; -import type { SlashCommandController } from "../../../../../src/features/chat/controllers/submission/slash-command-controller"; -import type { TurnSubmissionController } from "../../../../../src/features/chat/controllers/submission/turn-submission-controller"; import type { Thread } from "../../../../../src/generated/app-server/v2/Thread"; function thread(id: string): Thread { @@ -47,13 +44,17 @@ function createController(draft: string) { return draft; }, setDraft, - } as unknown as ChatComposerController, - slashCommands: { execute } as unknown as SlashCommandController, - turnSubmission: { sendTurnText } as unknown as TurnSubmissionController, - currentClient: () => client, - ensureConnected: vi.fn().mockResolvedValue(undefined), - setStatus: vi.fn(), - addSystemMessage: vi.fn(), + }, + slashCommands: { execute }, + turnSubmission: { sendTurnText }, + connection: { + currentClient: () => client, + ensureConnected: vi.fn().mockResolvedValue(undefined), + }, + status: { + setStatus: vi.fn(), + addSystemMessage: vi.fn(), + }, }); return { controller, execute, interruptTurn, sendTurnText, setDraft, stateStore }; } diff --git a/tests/features/chat/controllers/submission/plan-implementation-controller.test.ts b/tests/features/chat/controllers/submission/plan-implementation-controller.test.ts index f6ead15b..b07045c6 100644 --- a/tests/features/chat/controllers/submission/plan-implementation-controller.test.ts +++ b/tests/features/chat/controllers/submission/plan-implementation-controller.test.ts @@ -37,13 +37,19 @@ function resumeThread(stateStore: ChatStateStore, displayItems: readonly Display function createController({ client = {} as AppServerClient } = {}) { const stateStore = createChatStateStore(createChatState()); + const ensureConnected = vi.fn().mockResolvedValue(undefined); + const sendTurnText = vi.fn().mockResolvedValue(undefined); const host: PlanImplementationControllerHost = { state: createSubmissionStatePort(stateStore), - currentClient: () => client, - ensureConnected: vi.fn().mockResolvedValue(undefined), - sendTurnText: vi.fn().mockResolvedValue(undefined), + connection: { + currentClient: () => client, + ensureConnected, + }, + submission: { + sendTurnText, + }, }; - return { controller: new PlanImplementationController(host), host, stateStore }; + return { controller: new PlanImplementationController(host), ensureConnected, sendTurnText, stateStore }; } describe("PlanImplementationController", () => { @@ -61,28 +67,28 @@ describe("PlanImplementationController", () => { }); it("switches out of plan mode and submits the implementation prompt", async () => { - const { controller, host, stateStore } = createController(); + const { controller, ensureConnected, sendTurnText, stateStore } = createController(); const plan = planItem("plan"); resumeThread(stateStore, [plan]); stateStore.dispatch({ type: "ui/panel-set", panel: "model" }); await controller.implement(plan); - expect(host.ensureConnected).toHaveBeenCalledOnce(); + expect(ensureConnected).toHaveBeenCalledOnce(); expect(stateStore.getState().selectedCollaborationMode).toBe("default"); expect(stateStore.getState().runtimePicker).toBeNull(); - expect(host.sendTurnText).toHaveBeenCalledWith("Please implement this plan."); + expect(sendTurnText).toHaveBeenCalledWith("Please implement this plan."); }); it("ignores stale plan items", async () => { - const { controller, host, stateStore } = createController(); + const { controller, ensureConnected, sendTurnText, stateStore } = createController(); const first = planItem("first"); const latest = planItem("latest"); resumeThread(stateStore, [first, latest]); await controller.implement(first); - expect(host.ensureConnected).not.toHaveBeenCalled(); - expect(host.sendTurnText).not.toHaveBeenCalled(); + expect(ensureConnected).not.toHaveBeenCalled(); + expect(sendTurnText).not.toHaveBeenCalled(); }); }); diff --git a/tests/features/chat/controllers/submission/slash-command-controller.test.ts b/tests/features/chat/controllers/submission/slash-command-controller.test.ts index 8d5cee04..1b0cf52a 100644 --- a/tests/features/chat/controllers/submission/slash-command-controller.test.ts +++ b/tests/features/chat/controllers/submission/slash-command-controller.test.ts @@ -5,6 +5,9 @@ import { createChatState, createChatStateStore } from "../../../../../src/featur import { SlashCommandController, type SlashCommandControllerHost, + type SlashCommandRuntimePort, + type SlashCommandStatusPort, + type SlashCommandThreadPort, } from "../../../../../src/features/chat/controllers/submission/slash-command-controller"; import { createSubmissionStatePort } from "../../../../../src/features/chat/controllers/state-ports"; import type { Thread } from "../../../../../src/generated/app-server/v2/Thread"; @@ -36,34 +39,53 @@ function thread(id: string, name: string | null = null): Thread { }; } -function createHost(overrides: Partial = {}) { +interface SlashCommandHostOverrides extends Partial> { + threads?: Partial; + runtime?: Partial; + status?: Partial; +} + +function createHost(overrides: SlashCommandHostOverrides = {}) { + const { threads: threadOverrides, runtime: runtimeOverrides, status: statusOverrides, ...hostOverrides } = overrides; const stateStore = createChatStateStore(createChatState()); const compactThread = vi.fn().mockResolvedValue({}); const threadTurnsList = vi.fn().mockResolvedValue({ data: [] }); const client = { compactThread, threadTurnsList } as unknown as AppServerClient; - const host: SlashCommandControllerHost = { - state: createSubmissionStatePort(stateStore), - currentClient: () => client, - codexInput: vi.fn((text: string) => textInput(text)), + const threads: SlashCommandThreadPort = { startNewThread: vi.fn().mockResolvedValue(undefined), resumeThread: vi.fn().mockResolvedValue(undefined), forkThread: vi.fn().mockResolvedValue(undefined), rollbackThread: vi.fn().mockResolvedValue(undefined), archiveThread: vi.fn().mockResolvedValue(undefined), + ...threadOverrides, + }; + const runtime: SlashCommandRuntimePort = { toggleFastMode: vi.fn(), toggleCollaborationMode: vi.fn(), toggleAutoReview: vi.fn(), + setRequestedModel: vi.fn(), + setRequestedReasoningEffort: vi.fn(), + ...runtimeOverrides, + }; + const status: SlashCommandStatusPort = { addSystemMessage: vi.fn(), addStructuredSystemMessage: vi.fn(), setStatus: vi.fn(), - setRequestedModel: vi.fn(), - setRequestedReasoningEffort: vi.fn(), statusSummaryLines: () => [], connectionDiagnosticDetails: () => [], mcpStatusLines: vi.fn().mockResolvedValue([]), modelStatusLines: () => [], effortStatusLines: () => [], - ...overrides, + ...statusOverrides, + }; + const host: SlashCommandControllerHost = { + state: createSubmissionStatePort(stateStore), + currentClient: () => client, + codexInput: vi.fn((text: string) => textInput(text)), + threads, + runtime, + status, + ...hostOverrides, }; return { compactThread, host, stateStore, threadTurnsList }; } @@ -75,7 +97,7 @@ describe("SlashCommandController", () => { const result = await controller.execute("clear", ""); - expect(host.startNewThread).toHaveBeenCalledOnce(); + expect(host.threads.startNewThread).toHaveBeenCalledOnce(); expect(result).toBeUndefined(); }); @@ -101,7 +123,7 @@ describe("SlashCommandController", () => { await controller.execute("compact", ""); expect(compactThread).toHaveBeenCalledWith("thread"); - expect(host.setStatus).toHaveBeenCalledWith("Compaction requested."); + expect(host.status.setStatus).toHaveBeenCalledWith("Compaction requested."); }); it("reports unreadable referenced threads", async () => { @@ -116,6 +138,6 @@ describe("SlashCommandController", () => { expect(threadTurnsList).toHaveBeenCalledWith("other", null, 20); expect(result).toBeUndefined(); - expect(host.addSystemMessage).toHaveBeenCalledWith("Referenced thread has no readable conversation turns."); + expect(host.status.addSystemMessage).toHaveBeenCalledWith("Referenced thread has no readable conversation turns."); }); }); diff --git a/tests/features/chat/controllers/submission/turn-submission-controller.test.ts b/tests/features/chat/controllers/submission/turn-submission-controller.test.ts index e09e895f..50afa93f 100644 --- a/tests/features/chat/controllers/submission/turn-submission-controller.test.ts +++ b/tests/features/chat/controllers/submission/turn-submission-controller.test.ts @@ -5,6 +5,13 @@ import { createChatState, createChatStateStore } from "../../../../../src/featur import { TurnSubmissionController, type TurnSubmissionControllerHost, + type TurnSubmissionComposerPort, + type TurnSubmissionConnectionPort, + type TurnSubmissionRestoredThreadPort, + type TurnSubmissionRuntimePort, + type TurnSubmissionStatusPort, + type TurnSubmissionThreadPort, + type TurnSubmissionViewPort, } from "../../../../../src/features/chat/controllers/submission/turn-submission-controller"; import { createSubmissionStatePort } from "../../../../../src/features/chat/controllers/state-ports"; import type { Thread } from "../../../../../src/generated/app-server/v2/Thread"; @@ -36,7 +43,29 @@ function thread(id: string): Thread { }; } -function createHost(overrides: Partial = {}) { +interface TurnSubmissionHostOverrides extends Partial< + Omit +> { + connection?: Partial; + restoredThread?: Partial; + thread?: Partial; + runtime?: Partial; + composer?: Partial; + view?: Partial; + status?: Partial; +} + +function createHost(overrides: TurnSubmissionHostOverrides = {}) { + const { + connection: connectionOverrides, + restoredThread: restoredThreadOverrides, + thread: threadOverrides, + runtime: runtimeOverrides, + composer: composerOverrides, + view: viewOverrides, + status: statusOverrides, + ...hostOverrides + } = overrides; const stateStore = createChatStateStore(createChatState()); const startTurn = vi.fn().mockResolvedValue({ turn: { id: "turn" } }); const steerTurn = vi.fn().mockResolvedValue({}); @@ -44,11 +73,16 @@ function createHost(overrides: Partial = {}) { startTurn, steerTurn, } as unknown as AppServerClient; - const host: TurnSubmissionControllerHost = { - state: createSubmissionStatePort(stateStore), + const connection: TurnSubmissionConnectionPort = { vaultPath: "/vault", currentClient: () => client, + ...connectionOverrides, + }; + const restoredThread: TurnSubmissionRestoredThreadPort = { ensureRestoredThreadLoaded: vi.fn().mockResolvedValue(true), + ...restoredThreadOverrides, + }; + const threadPort: TurnSubmissionThreadPort = { startThread: vi.fn().mockImplementation(async () => { stateStore.dispatch({ type: "thread/resumed", @@ -65,15 +99,38 @@ function createHost(overrides: Partial = {}) { }), notifyActiveThreadIdentityChanged: vi.fn(), resetThreadTurnPresence: vi.fn(), + ...threadOverrides, + }; + const runtime: TurnSubmissionRuntimePort = { applyPendingThreadSettings: vi.fn().mockResolvedValue(true), + ...runtimeOverrides, + }; + const composer: TurnSubmissionComposerPort = { codexInput: vi.fn((text: string) => textInput(text)), setDraft: vi.fn(), + ...composerOverrides, + }; + const view: TurnSubmissionViewPort = { forceMessagesToBottom: vi.fn(), render: vi.fn(), scheduleRender: vi.fn(), + ...viewOverrides, + }; + const status: TurnSubmissionStatusPort = { setStatus: vi.fn(), addSystemMessage: vi.fn(), - ...overrides, + ...statusOverrides, + }; + const host: TurnSubmissionControllerHost = { + state: createSubmissionStatePort(stateStore), + connection, + restoredThread, + thread: threadPort, + runtime, + composer, + view, + status, + ...hostOverrides, }; return { host, startTurn, stateStore, steerTurn }; } @@ -85,14 +142,14 @@ describe("TurnSubmissionController", () => { await controller.sendTurnText("hello"); - expect(host.startThread).toHaveBeenCalledWith("hello"); - expect(host.notifyActiveThreadIdentityChanged).toHaveBeenCalledOnce(); - expect(host.resetThreadTurnPresence).toHaveBeenCalledWith(false); + expect(host.thread.startThread).toHaveBeenCalledWith("hello"); + expect(host.thread.notifyActiveThreadIdentityChanged).toHaveBeenCalledOnce(); + expect(host.thread.resetThreadTurnPresence).toHaveBeenCalledWith(false); expect(startTurn).toHaveBeenCalledWith("thread", "/vault", textInput("hello"), expect.stringMatching(/^local-user-\d+$/)); expect(stateStore.getState().turnLifecycle).toEqual({ kind: "running", turnId: "turn" }); - expect(host.setDraft).toHaveBeenCalledWith(""); - expect(host.setStatus).toHaveBeenCalledWith("Turn running..."); - expect(host.scheduleRender).toHaveBeenCalledOnce(); + expect(host.composer.setDraft).toHaveBeenCalledWith(""); + expect(host.status.setStatus).toHaveBeenCalledWith("Turn running..."); + expect(host.view.scheduleRender).toHaveBeenCalledOnce(); }); it("steers a running turn instead of starting another turn", async () => { @@ -115,7 +172,7 @@ describe("TurnSubmissionController", () => { expect(steerTurn).toHaveBeenCalledWith("thread", "turn", textInput("follow up"), expect.stringMatching(/^local-steer-\d+$/)); expect(startTurn).not.toHaveBeenCalled(); - expect(host.setStatus).toHaveBeenCalledWith("Steered current turn."); + expect(host.status.setStatus).toHaveBeenCalledWith("Steered current turn."); const localSteerId = steerTurn.mock.calls[0]?.[3]; expect( stateStore.getState().displayItems.some((item) => item.kind === "message" && item.id === localSteerId && item.text === "follow up"),