Refactor high-noise test setup

This commit is contained in:
murashit 2026-07-06 14:32:55 +09:00
parent be63c6171d
commit eeefedfc8f
3 changed files with 150 additions and 324 deletions

View file

@ -12,33 +12,17 @@ import { chatStateFixture, chatStateWith } from "../../support/state";
describe("chatReducer", () => {
it("clears active turn and thread-scoped state", () => {
let state = chatStateFixture();
state = chatStateWith(state, { activeThread: { id: "thread" } });
state = chatStateWith(state, { turn: { lifecycle: { kind: "running", turnId: "turn" } } });
state = chatStateWith(state, { runtime: { active: { model: "gpt-5.1" } } });
state = chatStateWith(state, { runtime: { active: { reasoningEffort: "high" } } });
state = chatStateWith(state, { runtime: { active: { serviceTier: "fast" } } });
state = chatStateWith(state, { runtime: { active: { approvalsReviewer: "auto_review" } } });
state = chatStateWith(state, { runtime: { active: { activePermissionProfile: { id: ":workspace", extends: null } } } });
state = chatStateWith(state, { runtime: { active: { collaborationMode: "plan" } } });
state = chatStateWith(state, { runtime: { pending: { collaborationMode: setCollaborationModeIntent("plan") } } });
state = chatStateWith(state, { activeThread: { goal: goal("thread") } });
state = chatStateWith(state, { messageStream: { historyCursor: "cursor" } });
state = chatStateWith(state, { messageStream: { loadingHistory: true } });
state = chatStateWith(state, { composer: { draft: "keep me" } });
state = withChatStateMessageStreamItems(state, [message("m1")]);
state = chatStateWith(state, { messageStream: { turnDiffs: new Map([["turn", "@@"]]) } });
state = chatStateWith(state, { requests: { approvals: [approval(1)] } });
state = chatStateWith(state, { requests: { pendingUserInputs: [userInput(2)] } });
state = chatStateWith(state, { requests: { userInputDrafts: new Map([["2:note", "draft"]]) } });
state = chatStateWith(state, { composer: { suggestSelected: 1 } });
state = chatStateWith(state, { composer: { suggestions: [suggestion("/plan")] } });
state = chatStateWith(state, { composer: { suggestionsDismissedSignature: "dismissed" } });
state = chatStateWith(state, { ui: { disclosures: { approvalDetails: new Set(["1:details"]) } } });
state = chatStateWith(state, { ui: { disclosures: { textDetails: new Set(["previous:details"]) } } });
state = chatStateWith(state, { ui: { messageActionMenu: { forkMenuItemId: "previous" } } });
let state = threadScopedResidue({ draft: "keep me", messageId: "m1" });
state = chatStateWith(state, {
ui: { goalEditor: { kind: "editing", threadId: "thread", objectiveDraft: "draft", tokenBudgetDraft: null } },
runtime: {
active: {
model: "gpt-5.1",
reasoningEffort: "high",
serviceTier: "fast",
approvalsReviewer: "auto_review",
activePermissionProfile: { id: ":workspace", extends: null },
},
},
});
let pendingState = chatReducer(state, { type: "runtime/model-requested", model: "gpt-5.2" });
pendingState = chatReducer(pendingState, { type: "runtime/permission-profile-requested", permissionProfile: ":read-only" });
@ -62,47 +46,11 @@ describe("chatReducer", () => {
expect(next.runtime.pending.fastMode).toEqual({ kind: "unchanged" });
expect(next.runtime.pending.approvalsReviewer).toEqual({ kind: "unchanged" });
expect(next.runtime.pending.collaborationMode).toEqual({ kind: "unchanged" });
expect(activeTurnId(next)).toBeNull();
expect(chatStateMessageStreamItems(next)).toEqual([]);
expect(next.messageStream.turnDiffs.size).toBe(0);
expect(next.messageStream.historyCursor).toBeNull();
expect(next.messageStream.loadingHistory).toBe(false);
expect(next.requests.approvals).toEqual([]);
expect(next.requests.pendingUserInputs).toEqual([]);
expect(next.requests.userInputDrafts.size).toBe(0);
expect(next.composer.draft).toBe("");
expect(next.composer.suggestSelected).toBe(0);
expect(next.composer.suggestions).toEqual([]);
expect(next.composer.suggestionsDismissedSignature).toBeNull();
expect(uiDisclosureCount(next)).toBe(0);
expect(next.ui.messageActionMenu.forkMenuItemId).toBeNull();
expect(next.ui.goalEditor.kind).toBe("closed");
expectThreadScopeReset(next, { items: [] });
});
it("resets thread-scoped state when resuming a thread", () => {
let state = chatStateFixture();
state = chatStateWith(state, { activeThread: { id: "previous-thread" } });
state = chatStateWith(state, { turn: { lifecycle: { kind: "running", turnId: "previous-turn" } } });
state = chatStateWith(state, { messageStream: { historyCursor: "cursor" } });
state = chatStateWith(state, { activeThread: { goal: goal("previous-thread") } });
state = chatStateWith(state, { messageStream: { loadingHistory: true } });
state = chatStateWith(state, { composer: { draft: "previous draft" } });
state = withChatStateMessageStreamItems(state, [message("previous-message")]);
state = chatStateWith(state, { messageStream: { turnDiffs: new Map([["previous-turn", "@@"]]) } });
state = chatStateWith(state, { requests: { approvals: [approval(1)] } });
state = chatStateWith(state, { requests: { pendingUserInputs: [userInput(2)] } });
state = chatStateWith(state, { requests: { userInputDrafts: new Map([["2:note", "draft"]]) } });
state = chatStateWith(state, { composer: { suggestSelected: 1 } });
state = chatStateWith(state, { composer: { suggestions: [suggestion("/plan")] } });
state = chatStateWith(state, { composer: { suggestionsDismissedSignature: "dismissed" } });
state = chatStateWith(state, { runtime: { active: { collaborationMode: "plan" } } });
state = chatStateWith(state, { runtime: { pending: { collaborationMode: setCollaborationModeIntent("plan") } } });
state = chatStateWith(state, { ui: { disclosures: { approvalDetails: new Set(["1:details"]) } } });
state = chatStateWith(state, { ui: { disclosures: { textDetails: new Set(["previous:details"]) } } });
state = chatStateWith(state, { ui: { messageActionMenu: { forkMenuItemId: "previous" } } });
state = chatStateWith(state, {
ui: { goalEditor: { kind: "editing", threadId: "previous-thread", objectiveDraft: "draft", tokenBudgetDraft: null } },
});
const state = threadScopedResidue({ threadId: "previous-thread", turnId: "previous-turn" });
const resumedItems = [message("resumed-message")];
const next = chatReducer(state, {
@ -124,23 +72,9 @@ describe("chatReducer", () => {
expect(next.activeThread.id).toBe("resumed-thread");
expect(next.activeThread.goal).toBeNull();
expect(activeTurnId(next)).toBeNull();
expect(next.messageStream.historyCursor).toBeNull();
expect(next.messageStream.loadingHistory).toBe(false);
expect(next.composer.draft).toBe("");
expect(chatStateMessageStreamItems(next)).toEqual(resumedItems);
expect(next.messageStream.turnDiffs.size).toBe(0);
expect(next.requests.approvals).toEqual([]);
expect(next.requests.pendingUserInputs).toEqual([]);
expect(next.requests.userInputDrafts.size).toBe(0);
expect(next.composer.suggestSelected).toBe(0);
expect(next.composer.suggestions).toEqual([]);
expect(next.composer.suggestionsDismissedSignature).toBeNull();
expect(next.runtime.active.collaborationMode).toBeNull();
expect(next.runtime.pending.collaborationMode).toEqual({ kind: "unchanged" });
expect(uiDisclosureCount(next)).toBe(0);
expect(next.ui.messageActionMenu.forkMenuItemId).toBeNull();
expect(next.ui.goalEditor.kind).toBe("closed");
expectThreadScopeReset(next, { items: resumedItems });
});
it("preserves empty-panel runtime reservations when thread activation explicitly requests it", () => {
@ -805,6 +739,63 @@ function message(id: string): MessageStreamItem {
return { id, kind: "message", role: "assistant", text: id, messageKind: "assistantResponse", messageState: "completed" };
}
function threadScopedResidue(options: { threadId?: string; turnId?: string; draft?: string; messageId?: string } = {}): ChatState {
const threadId = options.threadId ?? "thread";
const turnId = options.turnId ?? "turn";
let state = chatStateFixture({
activeThread: { id: threadId, goal: goal(threadId) },
turn: { lifecycle: { kind: "running", turnId } },
runtime: {
active: { collaborationMode: "plan" },
pending: { collaborationMode: setCollaborationModeIntent("plan") },
},
messageStream: {
historyCursor: "cursor",
loadingHistory: true,
turnDiffs: new Map([[turnId, "@@"]]),
},
requests: {
approvals: [approval(1)],
pendingUserInputs: [userInput(2)],
userInputDrafts: new Map([["2:note", "draft"]]),
},
composer: {
draft: options.draft ?? "previous draft",
suggestSelected: 1,
suggestions: [suggestion("/plan")],
suggestionsDismissedSignature: "dismissed",
},
ui: {
disclosures: {
approvalDetails: new Set(["1:details"]),
textDetails: new Set(["previous:details"]),
},
messageActionMenu: { forkMenuItemId: "previous" },
goalEditor: { kind: "editing", threadId, objectiveDraft: "draft", tokenBudgetDraft: null },
},
});
state = withChatStateMessageStreamItems(state, [message(options.messageId ?? "previous-message")]);
return state;
}
function expectThreadScopeReset(state: ChatState, options: { items: readonly MessageStreamItem[] }): void {
expect(activeTurnId(state)).toBeNull();
expect(chatStateMessageStreamItems(state)).toEqual(options.items);
expect(state.messageStream.turnDiffs.size).toBe(0);
expect(state.messageStream.historyCursor).toBeNull();
expect(state.messageStream.loadingHistory).toBe(false);
expect(state.requests.approvals).toEqual([]);
expect(state.requests.pendingUserInputs).toEqual([]);
expect(state.requests.userInputDrafts.size).toBe(0);
expect(state.composer.draft).toBe("");
expect(state.composer.suggestSelected).toBe(0);
expect(state.composer.suggestions).toEqual([]);
expect(state.composer.suggestionsDismissedSignature).toBeNull();
expect(uiDisclosureCount(state)).toBe(0);
expect(state.ui.messageActionMenu.forkMenuItemId).toBeNull();
expect(state.ui.goalEditor.kind).toBe("closed");
}
function suggestion(display: string): ChatState["composer"]["suggestions"][number] {
return { display, detail: "Plan mode", replacement: display, start: 0, appendSpaceOnInsert: true };
}

View file

@ -29,32 +29,50 @@ function renderComposerController(
renderUiRoot(parent, h(ComposerShell, controller.renderState(composerReadModelFromChatState(stateStore.getState()), actions)));
}
type ComposerControllerOptions = ConstructorParameters<typeof ChatComposerController>[0];
function composerControllerFixture(
options: { stateStore?: ChatStateStore; controller?: Partial<ComposerControllerOptions>; renderActions?: ChatComposerRenderActions } = {},
): {
controller: ChatComposerController;
parent: HTMLElement;
renderShell: ReturnType<typeof vi.fn>;
stateStore: ChatStateStore;
} {
const stateStore = options.stateStore ?? createChatStateStore();
const parent = document.createElement("div");
const controller = new ChatComposerController({
noteCandidateProvider: noteProvider(),
contextReferenceProvider: contextProvider(),
sourcePath: () => "",
viewId: "view",
referenceActiveNoteOnSend: () => false,
sendShortcut: () => "enter",
scrollThreadFromComposerEdges: () => false,
threadScrollFromComposer: vi.fn(),
canInterrupt: (_state) => false,
composerProjection: defaultComposerProjection,
currentModelForSuggestions: () => null,
togglePlan: vi.fn(),
toggleAutoReview: vi.fn(),
toggleFast: vi.fn(),
onDraftChange: vi.fn(),
onHeightChange: vi.fn(),
...options.controller,
stateStore,
});
const renderShell = vi.fn(() => renderComposerController(parent, controller, stateStore, options.renderActions));
stateStore.subscribe(renderShell);
return { controller, parent, renderShell, stateStore };
}
describe("ChatComposerController", () => {
it("derives composer placeholder and meta from the projection", () => {
const stateStore = createChatStateStore();
const projection = vi.fn((model: ChatPanelComposerReadModel) => ({
placeholder: `Projected ${model.draft.value || "empty"}`,
meta: defaultComposerProjection(model).meta,
}));
const controller = new ChatComposerController({
noteCandidateProvider: noteProvider(),
contextReferenceProvider: contextProvider(),
sourcePath: () => "",
stateStore,
viewId: "view",
referenceActiveNoteOnSend: () => false,
sendShortcut: () => "enter",
scrollThreadFromComposerEdges: () => false,
threadScrollFromComposer: vi.fn(),
canInterrupt: (_state) => false,
composerProjection: projection,
currentModelForSuggestions: () => null,
togglePlan: vi.fn(),
toggleAutoReview: vi.fn(),
toggleFast: vi.fn(),
onDraftChange: vi.fn(),
onHeightChange: vi.fn(),
});
const { controller, stateStore } = composerControllerFixture({ controller: { composerProjection: projection } });
const props = controller.renderState(composerReadModelFromChatState(stateStore.getState()), { submit: vi.fn() });
@ -712,28 +730,8 @@ describe("ChatComposerController", () => {
});
it("delegates composer runtime toggles", () => {
const stateStore = createChatStateStore();
const parent = document.createElement("div");
const togglePlan = vi.fn();
const controller = new ChatComposerController({
noteCandidateProvider: noteProvider(),
contextReferenceProvider: contextProvider(),
sourcePath: () => "",
stateStore,
viewId: "view",
referenceActiveNoteOnSend: () => false,
sendShortcut: () => "enter",
scrollThreadFromComposerEdges: () => false,
threadScrollFromComposer: vi.fn(),
canInterrupt: (_state) => false,
composerProjection: defaultComposerProjection,
currentModelForSuggestions: () => null,
togglePlan,
toggleAutoReview: vi.fn(),
toggleFast: vi.fn(),
onDraftChange: vi.fn(),
onHeightChange: vi.fn(),
});
const { controller, parent, stateStore } = composerControllerFixture({ controller: { togglePlan } });
renderComposerController(parent, controller, stateStore);
@ -745,27 +743,8 @@ describe("ChatComposerController", () => {
it("delegates submit events through render actions", () => {
const stateStore = createChatStateStore();
stateStore.dispatch({ type: "composer/draft-set", draft: "hello" });
const parent = document.createElement("div");
const submit = vi.fn();
const controller = new ChatComposerController({
noteCandidateProvider: noteProvider(),
contextReferenceProvider: contextProvider(),
sourcePath: () => "",
stateStore,
viewId: "view",
referenceActiveNoteOnSend: () => false,
sendShortcut: () => "enter",
scrollThreadFromComposerEdges: () => false,
threadScrollFromComposer: vi.fn(),
canInterrupt: (_state) => false,
composerProjection: defaultComposerProjection,
currentModelForSuggestions: () => null,
togglePlan: vi.fn(),
toggleAutoReview: vi.fn(),
toggleFast: vi.fn(),
onDraftChange: vi.fn(),
onHeightChange: vi.fn(),
});
const { controller, parent } = composerControllerFixture({ stateStore });
renderComposerController(parent, controller, stateStore, { submit });
composer(parent).dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
@ -774,28 +753,8 @@ describe("ChatComposerController", () => {
});
it("scrolls by page from the composer even when line edge scrolling is disabled", () => {
const stateStore = createChatStateStore();
const parent = document.createElement("div");
const threadScrollFromComposer = vi.fn();
const controller = new ChatComposerController({
noteCandidateProvider: noteProvider(),
contextReferenceProvider: contextProvider(),
sourcePath: () => "",
stateStore,
viewId: "view",
referenceActiveNoteOnSend: () => false,
sendShortcut: () => "enter",
scrollThreadFromComposerEdges: () => false,
threadScrollFromComposer,
canInterrupt: (_state) => false,
composerProjection: defaultComposerProjection,
currentModelForSuggestions: () => null,
togglePlan: vi.fn(),
toggleAutoReview: vi.fn(),
toggleFast: vi.fn(),
onDraftChange: vi.fn(),
onHeightChange: vi.fn(),
});
const { controller, parent, stateStore } = composerControllerFixture({ controller: { threadScrollFromComposer } });
renderComposerController(parent, controller, stateStore);
setTextAreaValue(composer(parent), "first\nsecond");
@ -808,28 +767,8 @@ describe("ChatComposerController", () => {
});
it("scrolls to stream edges from the composer even when line edge scrolling is disabled", () => {
const stateStore = createChatStateStore();
const parent = document.createElement("div");
const threadScrollFromComposer = vi.fn();
const controller = new ChatComposerController({
noteCandidateProvider: noteProvider(),
contextReferenceProvider: contextProvider(),
sourcePath: () => "",
stateStore,
viewId: "view",
referenceActiveNoteOnSend: () => false,
sendShortcut: () => "enter",
scrollThreadFromComposerEdges: () => false,
threadScrollFromComposer,
canInterrupt: (_state) => false,
composerProjection: defaultComposerProjection,
currentModelForSuggestions: () => null,
togglePlan: vi.fn(),
toggleAutoReview: vi.fn(),
toggleFast: vi.fn(),
onDraftChange: vi.fn(),
onHeightChange: vi.fn(),
});
const { controller, parent, stateStore } = composerControllerFixture({ controller: { threadScrollFromComposer } });
renderComposerController(parent, controller, stateStore);
setTextAreaValue(composer(parent), "first\nsecond");
@ -846,28 +785,8 @@ describe("ChatComposerController", () => {
});
it("leaves composer line edge scrolling disabled by the setting", () => {
const stateStore = createChatStateStore();
const parent = document.createElement("div");
const threadScrollFromComposer = vi.fn();
const controller = new ChatComposerController({
noteCandidateProvider: noteProvider(),
contextReferenceProvider: contextProvider(),
sourcePath: () => "",
stateStore,
viewId: "view",
referenceActiveNoteOnSend: () => false,
sendShortcut: () => "enter",
scrollThreadFromComposerEdges: () => false,
threadScrollFromComposer,
canInterrupt: (_state) => false,
composerProjection: defaultComposerProjection,
currentModelForSuggestions: () => null,
togglePlan: vi.fn(),
toggleAutoReview: vi.fn(),
toggleFast: vi.fn(),
onDraftChange: vi.fn(),
onHeightChange: vi.fn(),
});
const { controller, parent, stateStore } = composerControllerFixture({ controller: { threadScrollFromComposer } });
renderComposerController(parent, controller, stateStore);
setTextAreaValue(composer(parent), "first\nsecond");
@ -882,26 +801,7 @@ describe("ChatComposerController", () => {
it("clears the Preact-owned textarea ref when the composer unmounts", () => {
const stateStore = createChatStateStore();
stateStore.dispatch({ type: "composer/draft-set", draft: "state draft" });
const parent = document.createElement("div");
const controller = new ChatComposerController({
noteCandidateProvider: noteProvider(),
contextReferenceProvider: contextProvider(),
sourcePath: () => "",
stateStore,
viewId: "view",
referenceActiveNoteOnSend: () => false,
sendShortcut: () => "enter",
scrollThreadFromComposerEdges: () => false,
threadScrollFromComposer: vi.fn(),
canInterrupt: (_state) => false,
composerProjection: defaultComposerProjection,
currentModelForSuggestions: () => null,
togglePlan: vi.fn(),
toggleAutoReview: vi.fn(),
toggleFast: vi.fn(),
onDraftChange: vi.fn(),
onHeightChange: vi.fn(),
});
const { controller, parent } = composerControllerFixture({ stateStore });
renderComposerController(parent, controller, stateStore);
const mountedComposer = composer(parent);

View file

@ -38,9 +38,7 @@ describe("settings tab", () => {
it("auto-loads dynamic sections once and keeps one global refresh button", async () => {
const client = settingsClient();
const fetchModels = vi.fn().mockResolvedValue(modelMetadataFromCatalogModels([model("gpt-5.5")]));
withShortLivedAppServerClientMock.mockImplementation(
(_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) => operation(client),
);
useShortLivedClients(client);
const tab = newSettingsTab({ fetchModels });
tab.display();
@ -241,13 +239,7 @@ describe("settings tab", () => {
it("refreshes models, hooks, and archived threads from the global refresh button", async () => {
const firstClient = settingsClient({ models: [model("gpt-5.4")] });
const secondClient = settingsClient({ models: [model("gpt-5.5")] });
withShortLivedAppServerClientMock
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
operation(firstClient),
)
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
operation(secondClient),
);
useShortLivedClients(firstClient, secondClient);
const fetchModels = vi.fn().mockResolvedValue(modelMetadataFromCatalogModels([model("gpt-5.4")]));
const refreshModels = vi.fn().mockResolvedValue(modelMetadataFromCatalogModels([model("gpt-5.5")]));
const refreshArchived = vi
@ -282,9 +274,7 @@ describe("settings tab", () => {
models: [model("gpt-new")],
hooks: [hook({ key: "hook-new", command: "new hook", currentHash: "newhash" })],
});
withShortLivedAppServerClientMock
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) => operation(oldClient))
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) => operation(newClient));
useShortLivedClients(oldClient, newClient);
const fetchModels = vi.fn().mockResolvedValue(modelMetadataFromCatalogModels([model("gpt-old")]));
const refreshModels = vi.fn().mockResolvedValue(modelMetadataFromCatalogModels([model("gpt-new")]));
const refreshArchived = vi
@ -329,9 +319,7 @@ describe("settings tab", () => {
});
it("subscribes model updates only while the settings tab is displayed", () => {
withShortLivedAppServerClientMock.mockImplementation(
(_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) => operation(settingsClient()),
);
useShortLivedClients(settingsClient());
const unsubscribe = vi.fn();
const observeModels = vi.fn(() => unsubscribe);
const tab = newSettingsTab({ observeModels });
@ -375,13 +363,7 @@ describe("settings tab", () => {
const firstModels = deferred<ModelMetadata[]>();
const firstClient = settingsClient();
const secondClient = settingsClient({ models: [model("gpt-new")] });
withShortLivedAppServerClientMock
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
operation(firstClient),
)
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
operation(secondClient),
);
useShortLivedClients(firstClient, secondClient);
const refreshModels = vi
.fn()
.mockReturnValueOnce(firstModels.promise)
@ -424,19 +406,7 @@ describe("settings tab", () => {
const newerClient = settingsClient({
hooks: [hook({ key: "hook-new", command: "new hook", currentHash: "newhash" })],
});
withShortLivedAppServerClientMock
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
operation(initialClient),
)
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
operation(trustClient),
)
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
operation(staleClient),
)
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
operation(newerClient),
);
useShortLivedClients(initialClient, trustClient, staleClient, newerClient);
const controller = new SettingsDynamicSectionsController(settingsTabHost(), { display: vi.fn(), notify: vi.fn() });
await controller.refreshDynamicSections();
@ -467,16 +437,7 @@ describe("settings tab", () => {
const hookReloadClient = settingsClient({
hooks: [hook({ key: "hook-new", command: "new hook", currentHash: "newhash" })],
});
withShortLivedAppServerClientMock
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
operation(fullRefreshClient),
)
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
operation(trustClient),
)
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
operation(hookReloadClient),
);
useShortLivedClients(fullRefreshClient, trustClient, hookReloadClient);
const refreshModels = vi.fn().mockReturnValue(models.promise);
const refreshArchived = vi.fn().mockResolvedValue([panelThread({ id: "thread-full", preview: "Full archived", archived: true })]);
const controller = new SettingsDynamicSectionsController(settingsTabHost({ refreshModels, refreshArchived }), {
@ -507,16 +468,7 @@ describe("settings tab", () => {
"thread/unarchive": vi.fn(() => staleRestore.promise),
});
const newerClient = settingsClient();
withShortLivedAppServerClientMock
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
operation(initialClient),
)
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
operation(restoreClient),
)
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
operation(newerClient),
);
useShortLivedClients(initialClient, restoreClient, newerClient);
const refreshArchived = vi
.fn()
.mockResolvedValueOnce([panelThread({ id: "thread-old", preview: "Old archived", archived: true })])
@ -547,13 +499,7 @@ describe("settings tab", () => {
const restoreClient = settingsRequestClient({
"thread/unarchive": vi.fn().mockResolvedValue({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) }),
});
withShortLivedAppServerClientMock
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
operation(initialClient),
)
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
operation(restoreClient),
);
useShortLivedClients(initialClient, restoreClient);
const controller = new SettingsDynamicSectionsController(
settingsTabHost({
archivedThreads: [panelThread({ id: "thread-old", preview: "Old archived", archived: true })],
@ -581,13 +527,7 @@ describe("settings tab", () => {
const restoreClient = settingsRequestClient({
"thread/unarchive": vi.fn().mockResolvedValue({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) }),
});
withShortLivedAppServerClientMock
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
operation(initialClient),
)
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
operation(restoreClient),
);
useShortLivedClients(initialClient, restoreClient);
const controllerRef: { current: SettingsDynamicSectionsController | null } = { current: null };
let emitArchived = (_threads: readonly Thread[]): void => undefined;
const controller = new SettingsDynamicSectionsController(
@ -628,13 +568,7 @@ describe("settings tab", () => {
const deleteClient = settingsRequestClient({
"thread/delete": vi.fn().mockResolvedValue({}),
});
withShortLivedAppServerClientMock
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
operation(initialClient),
)
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
operation(deleteClient),
);
useShortLivedClients(initialClient, deleteClient);
const controllerRef: { current: SettingsDynamicSectionsController | null } = { current: null };
let emitArchived = (_threads: readonly Thread[]): void => undefined;
const controller = new SettingsDynamicSectionsController(
@ -672,9 +606,7 @@ describe("settings tab", () => {
it("uses cached models initially and publishes refreshed models", async () => {
const fetchModels = vi.fn().mockResolvedValue(modelMetadataFromCatalogModels([model("gpt-5.5")]));
const client = settingsClient({ models: [model("gpt-5.5")] });
withShortLivedAppServerClientMock.mockImplementation(
(_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) => operation(client),
);
useShortLivedClients(client);
const tab = newSettingsTab({ modelsSnapshot: modelMetadataFromCatalogModels([model("gpt-cached")]), fetchModels });
tab.display();
@ -690,9 +622,7 @@ describe("settings tab", () => {
it("replaces stale cached model options with an empty successful refresh while preserving saved values", async () => {
const fetchModels = vi.fn().mockResolvedValue([]);
const client = settingsClient({ models: [] });
withShortLivedAppServerClientMock.mockImplementation(
(_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) => operation(client),
);
useShortLivedClients(client);
const tab = newSettingsTab({
modelsSnapshot: modelMetadataFromCatalogModels([model("gpt-cached")]),
fetchModels,
@ -738,9 +668,7 @@ describe("settings tab", () => {
models: [model("gpt-5.4")],
hooksError: new Error("hooks unavailable"),
});
withShortLivedAppServerClientMock.mockImplementation(
(_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) => operation(client),
);
useShortLivedClients(client);
const tab = newSettingsTab({ archivedThreads: [panelThread({ preview: "Archived thread", archived: true })] });
tab.display();
@ -757,9 +685,7 @@ describe("settings tab", () => {
const client = settingsClient({
hooks: [hook({ key: "hook-1", command: "node hook.js", currentHash: "abc123", trustStatus: "untrusted" })],
});
withShortLivedAppServerClientMock.mockImplementation(
(_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) => operation(client),
);
useShortLivedClients(client);
const tab = newSettingsTab({ archivedThreads: [panelThread({ id: "thread-archived", preview: "Archived thread", archived: true })] });
tab.display();
@ -787,9 +713,7 @@ describe("settings tab", () => {
it("confirms archived thread deletion inline and cancels from outside clicks", async () => {
const client = settingsClient();
withShortLivedAppServerClientMock.mockImplementation(
(_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) => operation(client),
);
useShortLivedClients(client);
const tab = newSettingsTab({ archivedThreads: [panelThread({ id: "thread-archived", preview: "Archived thread", archived: true })] });
tab.display();
@ -810,9 +734,7 @@ describe("settings tab", () => {
it("keeps the settings shell mounted while dynamic sections update", async () => {
const saveSettings = vi.fn().mockResolvedValue(undefined);
const client = settingsClient();
withShortLivedAppServerClientMock.mockImplementation(
(_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) => operation(client),
);
useShortLivedClients(client);
const tab = newSettingsTab({
saveSettings,
archivedThreads: [panelThread({ id: "thread-archived", preview: "Archived thread", archived: true })],
@ -845,9 +767,7 @@ describe("settings tab", () => {
const client = settingsClient({
hooks: [hook({ key: "hook-1", command: "node hook.js", currentHash: "abc123", enabled: true })],
});
withShortLivedAppServerClientMock.mockImplementation(
(_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) => operation(client),
);
useShortLivedClients(client);
const hookUpdate = deferred<undefined>();
client.requestHandlers["config/batchWrite"]?.mockReturnValue(hookUpdate.promise);
const tab = newSettingsTab({ archivedThreads: [panelThread({ id: "thread-archived", preview: "Archived thread", archived: true })] });
@ -888,9 +808,7 @@ describe("settings tab", () => {
it("permanently deletes an archived thread from the confirmed settings row", async () => {
const client = settingsClient();
withShortLivedAppServerClientMock.mockImplementation(
(_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) => operation(client),
);
useShortLivedClients(client);
const deleteRequest = deferred<undefined>();
client.requestHandlers["thread/delete"]?.mockReturnValue(deleteRequest.promise);
const tab = newSettingsTab({ archivedThreads: [panelThread({ id: "thread-archived", preview: "Archived thread", archived: true })] });
@ -1027,6 +945,23 @@ type SettingsRequestClient = AppServerClient & {
requestHandlers: Record<string, ReturnType<typeof vi.fn<(params?: unknown, options?: unknown) => unknown>>>;
};
function useShortLivedClients(...clients: SettingsRequestClient[]): void {
const runWithClient = (client: SettingsRequestClient, operation: (client: AppServerClient) => Promise<unknown>) => operation(client);
if (clients.length === 1) {
const [client] = clients;
if (!client) throw new Error("Expected a short-lived client.");
withShortLivedAppServerClientMock.mockImplementation(
(_codexPath: string, _cwd: string, operation: (client: AppServerClient) => Promise<unknown>) => runWithClient(client, operation),
);
return;
}
for (const client of clients) {
withShortLivedAppServerClientMock.mockImplementationOnce(
(_codexPath: string, _cwd: string, operation: (client: AppServerClient) => Promise<unknown>) => runWithClient(client, operation),
);
}
}
function settingsRequestClient(
handlers: Record<string, ReturnType<typeof vi.fn<(params?: unknown, options?: unknown) => unknown>>>,
): SettingsRequestClient {