Improve Codex connection failure status

This commit is contained in:
murashit 2026-05-31 15:20:18 +09:00
parent e32bff8a9b
commit cd2d06dfeb
5 changed files with 55 additions and 1 deletions

View file

@ -362,6 +362,7 @@ export function createChatViewControllerAssembly(host: ChatViewControllerAssembl
resetThreadTurnPresence: host.effects.thread.resetTurnPresence,
setStatus: host.effects.status.set,
addSystemMessage: host.effects.status.addSystemMessage,
configuredCommand: () => host.plugin.settings.codexPath,
refreshLiveState: host.effects.liveState.refresh,
render: host.effects.render.now,
scheduleRender: host.effects.render.schedule,

View file

@ -25,6 +25,7 @@ export interface ChatConnectionControllerHost {
resetThreadTurnPresence: (hadTurns: boolean) => void;
setStatus: (status: string) => void;
addSystemMessage: (text: string) => void;
configuredCommand: () => string;
refreshLiveState: () => void;
render: () => void;
scheduleRender: () => void;
@ -125,7 +126,7 @@ export class ChatConnectionController {
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.addSystemMessage(connectionErrorMessage(error, this.host.configuredCommand()));
this.host.notifyConnectionFailed();
}
if (!this.host.connectionWork.isStale(connection)) {
@ -137,3 +138,15 @@ export class ChatConnectionController {
this.host.stateStore.dispatch(action);
}
}
function connectionErrorMessage(error: unknown, configuredCommand: string): string {
const message = error instanceof Error ? error.message : String(error);
if (!isMissingCommandError(error)) return message;
return `Could not start Codex app-server because the configured command was not found: ${configuredCommand}. Check the Codex command path in settings. (${message})`;
}
function isMissingCommandError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const candidate = error as { code?: unknown; syscall?: unknown };
return candidate.code === "ENOENT" && candidate.syscall === "spawn";
}

View file

@ -50,6 +50,7 @@ export interface StatusDotStateInput {
connected: boolean;
turnBusy: boolean;
diagnostics: AppServerDiagnostics;
connectionFailed?: boolean;
turnStartBlocked?: boolean;
}
@ -156,6 +157,7 @@ export function toolbarViewModel(input: ToolbarViewModelInput): ToolbarViewModel
connected: input.connected,
turnBusy: input.turnBusy,
diagnostics: state.appServerDiagnostics,
connectionFailed: state.status === "Connection failed.",
});
const model = currentModel(snapshot, config);
const effort = currentReasoningEffort(snapshot, config);
@ -197,6 +199,7 @@ export function toolbarViewModel(input: ToolbarViewModelInput): ToolbarViewModel
export function statusDotState(input: StatusDotStateInput): ToolbarStatusState {
if (input.turnBusy) return "running";
if (input.connectionFailed) return "blocked";
if (!input.connected) return "offline";
if (input.turnStartBlocked) return "blocked";
return hasDiagnosticIssue(input.diagnostics) ? "degraded" : "ready";

View file

@ -46,6 +46,7 @@ function createController({ connected = false, client = {} as AppServerClient }
resetThreadTurnPresence: vi.fn(),
setStatus: vi.fn(),
addSystemMessage: vi.fn(),
configuredCommand: () => "codex",
refreshLiveState: vi.fn(),
render: vi.fn(),
scheduleRender: vi.fn(),
@ -117,4 +118,18 @@ describe("ChatConnectionController", () => {
runtimePicker: null,
});
});
it("explains missing configured command failures", async () => {
const { controller, connect, host } = createController();
const error = Object.assign(new Error("spawn codex ENOENT"), { code: "ENOENT", syscall: "spawn" });
connect.mockRejectedValueOnce(error);
await controller.ensureConnected();
expect(host.setStatus).toHaveBeenCalledWith("Connection failed.");
expect(host.addSystemMessage).toHaveBeenCalledWith(
"Could not start Codex app-server because the configured command was not found: codex. Check the Codex command path in settings. (spawn codex ENOENT)",
);
expect(host.notifyConnectionFailed).toHaveBeenCalledOnce();
});
});

View file

@ -81,6 +81,7 @@ describe("chat view model", () => {
expect(statusDotState({ connected: true, turnBusy: true, diagnostics })).toBe("running");
expect(statusDotState({ connected: false, turnBusy: false, diagnostics })).toBe("offline");
expect(statusDotState({ connected: false, turnBusy: false, diagnostics, connectionFailed: true })).toBe("blocked");
expect(statusDotState({ connected: true, turnBusy: false, diagnostics })).toBe("ready");
expect(statusDotState({ connected: true, turnBusy: false, diagnostics, turnStartBlocked: true })).toBe("blocked");
});
@ -138,6 +139,27 @@ describe("chat view model", () => {
expect(runningModel.statusState).toBe("running");
});
it("marks connection failure status as blocked in the toolbar model", () => {
const state = createChatState();
state.status = "Connection failed.";
const model = toolbarViewModel({
state,
snapshot: runtimeSnapshotForChatState({ state }),
connected: false,
turnBusy: false,
vaultPath: "/vault",
configuredCommand: "codex",
archiveConfirmThreadId: null,
archiveExportEnabled: true,
modelChoices: [],
effortChoices: [],
renameState: () => null,
});
expect(model.statusState).toBe("blocked");
});
it("builds slash-command status lines from chat state", () => {
const state = createChatState();
state.activeThreadId = "thread-1";