refactor(chat): make reconnect reset one scoped transition

This commit is contained in:
murashit 2026-07-20 17:41:03 +09:00
parent a92f3b031d
commit aaa04507e0
4 changed files with 78 additions and 55 deletions

View file

@ -105,10 +105,6 @@ export class ConnectionManager {
return promise;
}
resetConnection(): void {
this.disconnect();
}
disconnect(): void {
const previous = this.state;
this.state = { kind: "disconnected", generation: previous.generation + 1 };

View file

@ -6,10 +6,7 @@ const STATUS_RECONNECTING = "Reconnecting...";
export interface ChatReconnectActionsHost {
stateStore: ChatStateStore;
invalidateConnectionWork: () => void;
invalidateThreadWork: () => void;
clearDeferredDiagnostics: () => void;
resetConnection: () => void;
resetConnectionScope: () => void;
setStatus: (statusText: string, phase?: ChatConnectionPhase) => void;
ensureConnected: () => Promise<void>;
isConnected: () => boolean;
@ -17,34 +14,35 @@ export interface ChatReconnectActionsHost {
addSystemMessage: (text: string) => void;
}
export async function reconnectPanel(
host: ChatReconnectActionsHost,
target: { resumeThreadId: string | null; isCurrent?: () => boolean } | null = null,
): Promise<boolean> {
export function createReconnectPanelAction(host: ChatReconnectActionsHost): () => Promise<boolean> {
let activeReconnect: Promise<boolean> | null = null;
return async () => {
if (activeReconnect) return activeReconnect;
const operation = reconnectPanel(host);
activeReconnect = operation;
try {
return await operation;
} finally {
if (activeReconnect === operation) activeReconnect = null;
}
};
}
async function reconnectPanel(host: ChatReconnectActionsHost): Promise<boolean> {
const currentState = host.stateStore.getState();
const panelTarget = capturePanelTargetLease(currentState);
const threadId = target
? target.resumeThreadId
: activeThreadState(currentState)?.lifetime?.kind === "ephemeral"
? null
: panelThreadId(currentState);
const isCurrent = target?.isCurrent ?? (() => true);
const threadId = activeThreadState(currentState)?.lifetime?.kind === "ephemeral" ? null : panelThreadId(currentState);
host.stateStore.dispatch({ type: "ui/panel-set", panel: null });
host.invalidateConnectionWork();
host.invalidateThreadWork();
host.clearDeferredDiagnostics();
host.resetConnection();
host.resetConnectionScope();
host.stateStore.dispatch({ type: "connection/scoped-cleared" });
host.setStatus(STATUS_RECONNECTING, { kind: "connecting" });
await host.ensureConnected();
if (!isCurrent() || !host.isConnected() || !panelTargetLeaseIsCurrent(host.stateStore.getState(), panelTarget)) return false;
if (!host.isConnected() || !panelTargetLeaseIsCurrent(host.stateStore.getState(), panelTarget)) return false;
if (!threadId) return true;
try {
if (!(await host.resumeThread(threadId))) return false;
if (!isCurrent() || !panelTargetLeaseIsCurrent(host.stateStore.getState(), panelTarget)) {
return false;
}
if (!panelTargetLeaseIsCurrent(host.stateStore.getState(), panelTarget)) return false;
return activeThreadId(host.stateStore.getState()) === threadId;
} catch (error) {
host.addSystemMessage(error instanceof Error ? error.message : String(error));

View file

@ -2,7 +2,7 @@ import { codexPanelAppServerInitializeParams } from "../../../app-server/connect
import { ConnectionManager } from "../../../app-server/connection/connection-manager";
import { isStaleAppServerResourceContextError } from "../../../app-server/query/resource-store";
import { createChatAppServerGateway, createChatCurrentAppServerGateway } from "../app-server/session-gateway";
import { reconnectPanel } from "../application/connection/reconnect-actions";
import { createReconnectPanelAction } from "../application/connection/reconnect-actions";
import { createLocalIdSource, type LocalIdSource } from "../application/local-id-source";
import { runtimeSnapshotForChatState } from "../application/runtime/snapshot";
import type { ChatAction, ChatConnectionPhase } from "../application/state/root-reducer";
@ -224,18 +224,12 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat
});
const reconnectHost = {
stateStore,
invalidateConnectionWork: () => {
resetConnectionScope: () => {
connectionActions.invalidate();
},
invalidateThreadWork: () => {
invalidateThreadWork();
},
clearDeferredDiagnostics: () => {
host.deferredTasks.clearDiagnostics();
},
resetConnection: () => {
connectionBundle.invalidateConnectionScope();
connection.resetConnection();
connection.disconnect();
},
setStatus: (statusText: string, phase?: ChatConnectionPhase) => {
status.set(statusText, phase);
@ -247,8 +241,9 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat
status.addSystemMessage(text);
},
};
const reconnectPanel = createReconnectPanelAction(reconnectHost);
const reconnect = async () => {
await reconnectPanel(reconnectHost);
await reconnectPanel();
};
const reconnectForUser = host.reconnect ?? reconnect;
const turn = createTurnBundle(host, {

View file

@ -1,5 +1,8 @@
import { describe, expect, it, vi } from "vitest";
import { type ChatReconnectActionsHost, reconnectPanel } from "../../../../../src/features/chat/application/connection/reconnect-actions";
import {
type ChatReconnectActionsHost,
createReconnectPanelAction,
} from "../../../../../src/features/chat/application/connection/reconnect-actions";
import { activeThreadId, createChatState } from "../../../../../src/features/chat/application/state/root-reducer";
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
@ -36,10 +39,7 @@ function createHost(overrides: Partial<ChatReconnectActionsHost> = {}) {
stateStore.dispatch({ type: "thread-list/applied", threads: [{ id: "thread" } as never] });
const host: ChatReconnectActionsHost = {
stateStore,
invalidateConnectionWork: vi.fn(),
invalidateThreadWork: vi.fn(),
clearDeferredDiagnostics: vi.fn(),
resetConnection: vi.fn(),
resetConnectionScope: vi.fn(),
setStatus: vi.fn(),
ensureConnected: vi.fn().mockResolvedValue(undefined),
isConnected: vi.fn(() => true),
@ -63,20 +63,17 @@ function createHost(overrides: Partial<ChatReconnectActionsHost> = {}) {
addSystemMessage: vi.fn(),
...overrides,
};
return { host, stateStore };
return { host, stateStore, reconnect: createReconnectPanelAction(host) };
}
describe("reconnectPanel", () => {
describe("createReconnectPanelAction", () => {
it("resets local connection work before reconnecting and retains shared thread projections", async () => {
const { host, stateStore } = createHost();
const { host, stateStore, reconnect } = createHost();
await expect(reconnectPanel(host)).resolves.toBe(true);
await expect(reconnect()).resolves.toBe(true);
expect(stateStore.getState().ui.toolbarPanel).toBeNull();
expect(host.invalidateConnectionWork).toHaveBeenCalledOnce();
expect(host.invalidateThreadWork).toHaveBeenCalledOnce();
expect(host.clearDeferredDiagnostics).toHaveBeenCalledOnce();
expect(host.resetConnection).toHaveBeenCalledOnce();
expect(host.resetConnectionScope).toHaveBeenCalledOnce();
expect(host.setStatus).toHaveBeenCalledWith("Reconnecting...", { kind: "connecting" });
expect(stateStore.getState().requests.pendingUserInputs).toEqual([]);
expect(stateStore.getState().threadList).toEqual({
@ -124,7 +121,7 @@ describe("reconnectPanel", () => {
},
},
] as const)("resumes the same $label thread after an unexpected exit", async ({ thread }) => {
const { host, stateStore } = createHost();
const { host, stateStore, reconnect } = createHost();
stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicyKnown: true,
@ -148,14 +145,14 @@ describe("reconnectPanel", () => {
provenance: thread.provenance,
});
await expect(reconnectPanel(host)).resolves.toBe(true);
await expect(reconnect()).resolves.toBe(true);
expect(host.resumeThread).toHaveBeenCalledWith(thread.id);
expect(activeThreadId(stateStore.getState())).toBe(thread.id);
});
it("does not resume an ephemeral thread after an unexpected exit", async () => {
const { host, stateStore } = createHost();
const { host, stateStore, reconnect } = createHost();
stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicyKnown: true,
@ -174,18 +171,55 @@ describe("reconnectPanel", () => {
stateStore.dispatch({ type: "connection/scoped-cleared" });
expect(stateStore.getState().panelThread).toEqual({ kind: "empty" });
await reconnectPanel(host);
await reconnect();
expect(host.resumeThread).not.toHaveBeenCalled();
});
it("reports resume failures after reconnecting", async () => {
const { host } = createHost({
const { host, reconnect } = createHost({
resumeThread: vi.fn().mockRejectedValue(new Error("resume failed")),
});
await reconnectPanel(host);
await reconnect();
expect(host.addSystemMessage).toHaveBeenCalledWith("resume failed");
});
it("coalesces overlapping manual reconnect requests", async () => {
let finishConnecting: () => void = () => undefined;
const connecting = new Promise<void>((resolve) => {
finishConnecting = resolve;
});
const { host, reconnect } = createHost({
ensureConnected: vi.fn(() => connecting),
});
const first = reconnect();
const second = reconnect();
expect(host.resetConnectionScope).toHaveBeenCalledOnce();
expect(host.ensureConnected).toHaveBeenCalledOnce();
finishConnecting();
await expect(Promise.all([first, second])).resolves.toEqual([true, true]);
expect(host.resumeThread).toHaveBeenCalledOnce();
});
it("does not resume the old target after navigation while reconnecting", async () => {
let finishConnecting: () => void = () => undefined;
const connecting = new Promise<void>((resolve) => {
finishConnecting = resolve;
});
const { host, stateStore, reconnect } = createHost({
ensureConnected: vi.fn(() => connecting),
});
const operation = reconnect();
stateStore.dispatch({ type: "active-thread/cleared" });
finishConnecting();
await expect(operation).resolves.toBe(false);
expect(host.resumeThread).not.toHaveBeenCalled();
});
});