Narrow chat submission controller dependencies

This commit is contained in:
murashit 2026-06-04 08:19:34 +09:00
parent cd43d0c96a
commit bfe03c2fae
9 changed files with 339 additions and 155 deletions

View file

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

View file

@ -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<SlashCommandExecutionResult | undefined>;
}
export interface ComposerTurnSubmissionPort {
sendTurnText(text: string, codexInputOverride?: UserInput[], referencedThread?: ReferencedThreadDisplay): Promise<void>;
}
export interface ComposerConnectionPort {
ensureConnected: () => Promise<void>;
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<void>;
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<void> {
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));
}
}
}

View file

@ -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<void>;
}
export interface PlanImplementationSubmissionPort {
sendTurnText(text: string): Promise<void>;
}
export interface PlanImplementationControllerHost {
state: SubmissionStatePort;
currentClient: () => AppServerClient | null;
ensureConnected: () => Promise<void>;
sendTurnText: (text: string) => Promise<void>;
connection: PlanImplementationConnectionPort;
submission: PlanImplementationSubmissionPort;
}
export class PlanImplementationController {
@ -20,10 +28,10 @@ export class PlanImplementationController {
async implement(item: DisplayItem): Promise<void> {
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);
}
}

View file

@ -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<void>;
resumeThread: (threadId: string) => Promise<void>;
forkThread: (threadId: string) => Promise<void>;
rollbackThread: (threadId: string) => Promise<void>;
archiveThread: (threadId: string) => Promise<void>;
}
export interface SlashCommandRuntimePort {
toggleFastMode: () => void | Promise<void>;
toggleCollaborationMode: () => void | Promise<void>;
toggleAutoReview: () => void | Promise<void>;
setRequestedModel: (model: string | null) => boolean | undefined | Promise<boolean | undefined>;
setRequestedReasoningEffort: (effort: ReasoningEffort | null) => boolean | undefined | Promise<boolean | undefined>;
}
export interface SlashCommandStatusPort {
addSystemMessage: (text: string) => void;
addStructuredSystemMessage: (text: string, details: DisplayDetailSection[]) => void;
setStatus: (status: string) => void;
setRequestedModel: (model: string | null) => boolean | undefined | Promise<boolean | undefined>;
setRequestedReasoningEffort: (effort: ReasoningEffort | null) => boolean | undefined | Promise<boolean | undefined>;
statusSummaryLines: () => string[];
connectionDiagnosticDetails: () => DisplayDetailSection[];
mcpStatusLines: () => Promise<string[]>;
@ -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;
}
}

View file

@ -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<boolean>;
}
export interface TurnSubmissionThreadPort {
startThread: (preview?: string) => Promise<unknown>;
notifyActiveThreadIdentityChanged: () => void;
resetThreadTurnPresence: (hadTurns: boolean) => void;
}
export interface TurnSubmissionRuntimePort {
applyPendingThreadSettings: () => Promise<boolean>;
}
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<void> {
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() {

View file

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

View file

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

View file

@ -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<SlashCommandControllerHost> = {}) {
interface SlashCommandHostOverrides extends Partial<Omit<SlashCommandControllerHost, "threads" | "runtime" | "status">> {
threads?: Partial<SlashCommandThreadPort>;
runtime?: Partial<SlashCommandRuntimePort>;
status?: Partial<SlashCommandStatusPort>;
}
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.");
});
});

View file

@ -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<TurnSubmissionControllerHost> = {}) {
interface TurnSubmissionHostOverrides extends Partial<
Omit<TurnSubmissionControllerHost, "connection" | "restoredThread" | "thread" | "runtime" | "composer" | "view" | "status">
> {
connection?: Partial<TurnSubmissionConnectionPort>;
restoredThread?: Partial<TurnSubmissionRestoredThreadPort>;
thread?: Partial<TurnSubmissionThreadPort>;
runtime?: Partial<TurnSubmissionRuntimePort>;
composer?: Partial<TurnSubmissionComposerPort>;
view?: Partial<TurnSubmissionViewPort>;
status?: Partial<TurnSubmissionStatusPort>;
}
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<TurnSubmissionControllerHost> = {}) {
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<TurnSubmissionControllerHost> = {}) {
}),
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"),