mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
1012 lines
39 KiB
TypeScript
1012 lines
39 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { act } from "preact/test-utils";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { AppServerClientHandlers, ClientResponseByMethod, TypedClientRequestMethod } from "../../../src/app-server/connection/client";
|
|
import type { ClientRequestParams, RequestId, ServerNotification } from "../../../src/app-server/connection/rpc-messages";
|
|
import type { TurnItem, TurnRecord } from "../../../src/app-server/protocol/turn";
|
|
import type { AppServerStartStructuredTurnOptions } from "../../../src/app-server/services/turns";
|
|
import type { ModelMetadata, ReasoningEffort } from "../../../src/domain/catalog/metadata";
|
|
import type { ServerInitialization } from "../../../src/domain/server/initialization";
|
|
import {
|
|
type AppServerSelectionRewriteTransportOptions,
|
|
createAppServerSelectionRewriteTransport,
|
|
} from "../../../src/features/selection-rewrite/app-server-transport";
|
|
import { buildSelectionUnifiedDiff } from "../../../src/features/selection-rewrite/diff";
|
|
import {
|
|
canApplySelectionRewrite,
|
|
type SelectionRewriteLifecycleEvent,
|
|
type SelectionRewriteState,
|
|
transitionSelectionRewriteState,
|
|
} from "../../../src/features/selection-rewrite/model";
|
|
import { selectionRewriteOutputParseResultFromText } from "../../../src/features/selection-rewrite/output";
|
|
import { SelectionRewritePopover } from "../../../src/features/selection-rewrite/popover.dom";
|
|
import { positionSelectionRewritePopover } from "../../../src/features/selection-rewrite/position.dom";
|
|
import { buildSelectionRewritePrompt } from "../../../src/features/selection-rewrite/prompt";
|
|
import type { SelectionRewriteTransportRequest } from "../../../src/features/selection-rewrite/transport";
|
|
import type { ModelListResponse } from "../../../src/generated/app-server/v2/ModelListResponse";
|
|
import type { ThreadStartResponse } from "../../../src/generated/app-server/v2/ThreadStartResponse";
|
|
import { deferred } from "../../support/async";
|
|
import { installObsidianDomShims } from "../../support/dom";
|
|
|
|
type InitializeResponse = ServerInitialization;
|
|
type Turn = TurnRecord;
|
|
interface TurnStartResponse {
|
|
turn: TurnRecord;
|
|
}
|
|
type SelectionRewriteClientFactory = NonNullable<AppServerSelectionRewriteTransportOptions["clientFactory"]>;
|
|
type SelectionRewriteClient = ReturnType<SelectionRewriteClientFactory>;
|
|
type SelectionRewriteTestRunOptions = SelectionRewriteTransportRequest & { clientFactory: SelectionRewriteClientFactory };
|
|
|
|
const selectionRewriteGenerate = vi.fn();
|
|
|
|
installObsidianDomShims();
|
|
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
beforeEach(() => {
|
|
document.body.replaceChildren();
|
|
selectionRewriteGenerate.mockReset();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("selection selection rewrite output", () => {
|
|
it("parses valid selection rewrite JSON", () => {
|
|
expect(selectionRewriteOutputParseResultFromText('{"replacementText":"rewritten"}').output).toEqual({ replacementText: "rewritten" });
|
|
});
|
|
|
|
it("rejects invalid selection rewrite JSON", () => {
|
|
expect(selectionRewriteOutputParseResultFromText("replacementText: rewritten").output).toBeNull();
|
|
expect(selectionRewriteOutputParseResultFromText('{"replacementText":42}').output).toBeNull();
|
|
expect(selectionRewriteOutputParseResultFromText('{"text":"rewritten"}').output).toBeNull();
|
|
});
|
|
|
|
it("keeps raw model text when selection rewrite output parsing fails", () => {
|
|
expect(selectionRewriteOutputParseResultFromText("replacementText: final")).toEqual({
|
|
output: null,
|
|
rawText: "replacementText: final",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("selection rewrite prompt", () => {
|
|
it("always includes note context with the selected text", () => {
|
|
const prompt = buildSelectionRewritePrompt(rewriteState());
|
|
|
|
expect(prompt).toContain("Context mode: Selection + note context");
|
|
expect(prompt).toContain("Current note context:");
|
|
expect(prompt).toContain("# Heading");
|
|
});
|
|
|
|
it("uses fences that cannot be closed by note code blocks", () => {
|
|
const prompt = buildSelectionRewritePrompt(
|
|
rewriteState({
|
|
originalText: "```ts\nconst value = 1;\n```",
|
|
noteText: "````markdown\n```ts\nconst value = 1;\n```\n````",
|
|
}),
|
|
);
|
|
|
|
expect(prompt).toContain("````text\n```ts");
|
|
expect(prompt).toContain("`````text\n````markdown");
|
|
});
|
|
|
|
it("centers long note context around the selection", () => {
|
|
const noteText = `START ONLY\n${"filler\n".repeat(4_000)}Near before\nRevise this sentence.\nNear after`;
|
|
const prompt = buildSelectionRewritePrompt(
|
|
rewriteState({
|
|
noteText,
|
|
targetRange: {
|
|
from: { line: 4_002, ch: 0 },
|
|
to: { line: 4_002, ch: 22 },
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(prompt).toContain("Near before");
|
|
expect(prompt).toContain("Near after");
|
|
expect(prompt).not.toContain("START ONLY");
|
|
});
|
|
});
|
|
|
|
describe("selection rewrite diff", () => {
|
|
it("renders replacements in a selection-scoped unified diff", () => {
|
|
const diff = buildSelectionUnifiedDiff("Note.md", "alpha\nbeta", "alpha\ngamma");
|
|
|
|
expect(diff).toContain("diff --git a/Note.md b/Note.md");
|
|
expect(diff).toContain("@@ -1,2 +1,2 @@");
|
|
expect(diff).toContain(" alpha");
|
|
expect(diff).toContain("-beta");
|
|
expect(diff).toContain("+gamma");
|
|
});
|
|
|
|
it("orders full replacement blocks as removals before additions", () => {
|
|
const diff = buildSelectionUnifiedDiff(
|
|
"Note.md",
|
|
"これはdiffのテストです。\n今日は元気です。\nとても元気です。",
|
|
"これはdiffのてすとです。\nきょうはげんきです。\nとてもげんきです。",
|
|
);
|
|
|
|
expect(diff).toContain(
|
|
[
|
|
"-これはdiffのテストです。",
|
|
"-今日は元気です。",
|
|
"-とても元気です。",
|
|
"+これはdiffのてすとです。",
|
|
"+きょうはげんきです。",
|
|
"+とてもげんきです。",
|
|
].join("\n"),
|
|
);
|
|
});
|
|
|
|
it("bounds large selection diffs to a linear prefix and suffix comparison", () => {
|
|
const originalText = [
|
|
"same start",
|
|
...Array.from({ length: 160 }, (_unused, index) => `old before ${String(index)}`),
|
|
"shared middle",
|
|
...Array.from({ length: 160 }, (_unused, index) => `old after ${String(index)}`),
|
|
"same end",
|
|
].join("\n");
|
|
const replacementText = [
|
|
"same start",
|
|
...Array.from({ length: 160 }, (_unused, index) => `new before ${String(index)}`),
|
|
"shared middle",
|
|
...Array.from({ length: 160 }, (_unused, index) => `new after ${String(index)}`),
|
|
"same end",
|
|
].join("\n");
|
|
|
|
const diff = buildSelectionUnifiedDiff("Note.md", originalText, replacementText);
|
|
|
|
expect(diff).toContain("\n same start\n");
|
|
expect(diff).toContain("\n-old before 0\n");
|
|
expect(diff).toContain("\n-shared middle\n");
|
|
expect(diff).toContain("\n+shared middle\n");
|
|
expect(diff).toContain("\n same end");
|
|
expect(diff).not.toContain("\n shared middle\n");
|
|
});
|
|
|
|
it("renders additions and deletions", () => {
|
|
expect(buildSelectionUnifiedDiff("Note.md", "", "added")).toContain("+added");
|
|
expect(buildSelectionUnifiedDiff("Note.md", "removed", "")).toContain("-removed");
|
|
});
|
|
|
|
it("renders unchanged text as context", () => {
|
|
expect(buildSelectionUnifiedDiff("Note.md", "same", "same")).toContain(" same");
|
|
});
|
|
});
|
|
|
|
describe("selection rewrite apply guard", () => {
|
|
it("allows apply only when the current range and surrounding note context still match the original selection", () => {
|
|
const state = rewriteState();
|
|
expect(canApplySelectionRewrite({ currentText: "Revise this sentence.", currentNoteText: state.noteText }, state)).toBe(true);
|
|
expect(canApplySelectionRewrite({ currentText: "Changed sentence.", currentNoteText: state.noteText }, state)).toBe(false);
|
|
expect(
|
|
canApplySelectionRewrite(
|
|
{
|
|
currentText: "Revise this sentence.",
|
|
currentNoteText: "# Heading\n\nRevise this sentence.\n\nEdited next paragraph.",
|
|
},
|
|
state,
|
|
),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("selection rewrite positioning", () => {
|
|
it("uses the target view window HTMLElement constructor for editor DOM checks", () => {
|
|
const frame = document.createElement("iframe");
|
|
document.body.appendChild(frame);
|
|
const viewWindow = expectPresent(frame.contentWindow);
|
|
const viewDocument = expectPresent(frame.contentDocument);
|
|
const root = viewDocument.createElement("div");
|
|
viewDocument.body.appendChild(root);
|
|
const editorDom = viewDocument.createElement("div");
|
|
const editor = {
|
|
cm: {
|
|
dom: editorDom,
|
|
},
|
|
getCursor: () => ({ line: 0, ch: 0 }),
|
|
posToOffset: () => 0,
|
|
};
|
|
|
|
expect(positionSelectionRewritePopover(root, editor as never, viewWindow, 8)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("selection rewrite lifecycle", () => {
|
|
it("transitions through generation and preview states", () => {
|
|
const generating = transitionSelectionRewriteState(rewriteState({ replacementText: "old", debugText: "old debug" }), {
|
|
type: "generation-started",
|
|
instruction: "Shorten it.",
|
|
});
|
|
|
|
expect(generating).toMatchObject({
|
|
instruction: "Shorten it.",
|
|
status: "generating",
|
|
streamText: "",
|
|
replacementText: null,
|
|
debugText: null,
|
|
});
|
|
|
|
const streaming = transitionSelectionRewriteState(generating, { type: "preview-updated", text: "New" });
|
|
expect(streaming.streamText).toBe("New");
|
|
|
|
const preview = transitionSelectionRewriteState(streaming, { type: "generation-succeeded", replacementText: "New text." });
|
|
expect(preview).toMatchObject({
|
|
status: "preview",
|
|
streamText: "",
|
|
replacementText: "New text.",
|
|
});
|
|
});
|
|
|
|
it("ignores stale generation callbacks after generation is no longer active", () => {
|
|
const state = rewriteState({ status: "cancelled", streamText: "" });
|
|
|
|
expect(transitionSelectionRewriteState(state, { type: "generation-started", instruction: "late" })).toBe(state);
|
|
expect(transitionSelectionRewriteState(state, { type: "preview-updated", text: "late" })).toBe(state);
|
|
expect(transitionSelectionRewriteState(state, { type: "generation-succeeded", replacementText: "late" })).toBe(state);
|
|
expect(transitionSelectionRewriteState(state, { type: "generation-failed", debugText: "late" })).toBe(state);
|
|
});
|
|
|
|
it("marks terminal user actions explicitly", () => {
|
|
expect(transitionSelectionRewriteState(rewriteState(), { type: "cancelled" }).status).toBe("cancelled");
|
|
expect(transitionSelectionRewriteState(rewriteState({ replacementText: "New text." }), { type: "applied" }).status).toBe("applied");
|
|
});
|
|
|
|
it.each([{ status: "editing-prompt" }, { status: "preview" }, { status: "failed" }] satisfies {
|
|
status: SelectionRewriteState["status"];
|
|
}[])("allows regeneration from $status state", ({ status }) => {
|
|
const next = transitionSelectionRewriteState(selectionRewriteStateWithStatus(status), {
|
|
type: "generation-started",
|
|
instruction: "Try again.",
|
|
});
|
|
|
|
expect(next).toMatchObject({
|
|
status: "generating",
|
|
instruction: "Try again.",
|
|
streamText: "",
|
|
replacementText: null,
|
|
debugText: null,
|
|
});
|
|
});
|
|
|
|
it("preserves state identity for ignored transitions", () => {
|
|
const ignoredTransitions = [
|
|
{ status: "editing-prompt", event: previewUpdatedEvent() },
|
|
{ status: "editing-prompt", event: generationSucceededEvent() },
|
|
{ status: "editing-prompt", event: generationFailedEvent() },
|
|
{ status: "preview", event: previewUpdatedEvent() },
|
|
{ status: "preview", event: generationSucceededEvent() },
|
|
{ status: "preview", event: generationFailedEvent() },
|
|
{ status: "failed", event: previewUpdatedEvent() },
|
|
{ status: "failed", event: generationSucceededEvent() },
|
|
{ status: "failed", event: generationFailedEvent() },
|
|
{ status: "cancelled", event: { type: "generation-started", instruction: "late" } },
|
|
{ status: "cancelled", event: previewUpdatedEvent() },
|
|
{ status: "cancelled", event: generationSucceededEvent() },
|
|
{ status: "cancelled", event: generationFailedEvent() },
|
|
{ status: "applied", event: { type: "generation-started", instruction: "late" } },
|
|
{ status: "applied", event: previewUpdatedEvent() },
|
|
{ status: "applied", event: generationSucceededEvent() },
|
|
{ status: "applied", event: generationFailedEvent() },
|
|
] satisfies {
|
|
status: SelectionRewriteState["status"];
|
|
event: SelectionRewriteLifecycleEvent;
|
|
}[];
|
|
|
|
for (const { status, event } of ignoredTransitions) {
|
|
const state = selectionRewriteStateWithStatus(status);
|
|
expect(transitionSelectionRewriteState(state, event), `${status} via ${event.type}`).toBe(state);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("selection rewrite runner lifecycle", () => {
|
|
it("keeps pre-acknowledgement completion when turn notifications arrive before turn/start resolves", async () => {
|
|
const { clientFactory, client } = fakeSelectionRewriteClientFactory((fake) => {
|
|
fake.startStructuredTurnImpl = async () => {
|
|
fake.emit(completedItemNotification("thread", "turn", agentMessage("answer", '{"replacementText":"early"}')));
|
|
fake.emit(turnCompletedNotification("thread", turn([], { id: "turn", status: "completed" })));
|
|
return { turn: turn([], { id: "turn", status: "inProgress" }) };
|
|
};
|
|
});
|
|
|
|
await expect(runSelectionRewrite(runOptions(clientFactory))).resolves.toEqual({ replacementText: "early" });
|
|
expect(client.current?.disconnected).toBe(true);
|
|
});
|
|
|
|
it("ignores notifications outside the active selection rewrite turn", async () => {
|
|
const previews: string[] = [];
|
|
const { clientFactory, client } = fakeSelectionRewriteClientFactory();
|
|
const rewriting = runSelectionRewrite({
|
|
...runOptions(clientFactory),
|
|
onPreview: (text) => previews.push(text),
|
|
});
|
|
|
|
await expectPresent(client.current).structuredTurnStarted;
|
|
await Promise.resolve();
|
|
|
|
const fake = expectPresent(client.current);
|
|
fake.emit(agentDeltaNotification("other-thread", "turn", "ignored"));
|
|
fake.emit(completedItemNotification("thread", "other-turn", agentMessage("wrong", '{"replacementText":"wrong"}')));
|
|
fake.emit(agentDeltaNotification("thread", "turn", '{"replacementText":"stream'));
|
|
fake.emit(agentDeltaNotification("thread", "turn", 'ed"}'));
|
|
fake.emit(completedItemNotification("thread", "turn", agentMessage("answer", '{"replacementText":"final"}')));
|
|
fake.emit(turnCompletedNotification("thread", turn([], { id: "turn", status: "completed" })));
|
|
|
|
await expect(rewriting).resolves.toEqual({ replacementText: "final" });
|
|
expect(previews).toEqual(['{"replacementText":"stream', '{"replacementText":"streamed"}']);
|
|
});
|
|
|
|
it("passes validated runtime overrides to the structured rewrite turn", async () => {
|
|
const { clientFactory, client } = fakeSelectionRewriteClientFactory((fake) => {
|
|
fake.modelList = { data: [modelMetadata("gpt-5.4-mini", ["low", "medium", "high"]) as never], nextCursor: null };
|
|
fake.startStructuredTurnImpl = async () => {
|
|
fake.emit(completedItemNotification("thread", "turn", agentMessage("answer", '{"replacementText":"final"}')));
|
|
fake.emit(turnCompletedNotification("thread", turn([], { id: "turn", status: "completed" })));
|
|
return { turn: turn([], { id: "turn", status: "inProgress" }) };
|
|
};
|
|
});
|
|
|
|
await expect(
|
|
runSelectionRewrite({
|
|
...runOptions(clientFactory),
|
|
runtimeSettings: { rewriteSelectionModel: "gpt-5.4-mini", rewriteSelectionEffort: "minimal" },
|
|
}),
|
|
).resolves.toEqual({ replacementText: "final" });
|
|
|
|
expect(client.current?.startStructuredTurnOptions?.runtime).toEqual({ model: "gpt-5.4-mini" });
|
|
});
|
|
});
|
|
|
|
describe("selection rewrite popover", () => {
|
|
it("enables Generate only after the instruction has content", () => {
|
|
const popover = new SelectionRewritePopover(popoverOptions({ state: rewriteState({ instruction: "" }) }));
|
|
|
|
openPopover(popover);
|
|
|
|
const instruction = expectPresent(document.querySelector<HTMLTextAreaElement>(".codex-panel-selection-rewrite__instruction"));
|
|
const generate = expectPresent(document.querySelector<HTMLButtonElement>('button[aria-label="Generate"]'));
|
|
expect(generate.disabled).toBe(true);
|
|
|
|
void act(() => {
|
|
setTextareaValue(instruction, "Make it concise.");
|
|
instruction.dispatchEvent(new Event("input", { bubbles: true }));
|
|
});
|
|
|
|
expect(generate.disabled).toBe(false);
|
|
|
|
closePopover(popover);
|
|
});
|
|
|
|
it("generates from the Enter shortcut, renders a preview diff, and applies from the action shortcut", async () => {
|
|
const editor = editorFixture();
|
|
const onClose = vi.fn();
|
|
selectionRewriteGenerate.mockImplementation(async (options) => {
|
|
options.onPreview?.("Rewritten sentence.");
|
|
return { replacementText: "Rewritten sentence." };
|
|
});
|
|
const popover = new SelectionRewritePopover(
|
|
popoverOptions({ editor: editor.editor, onClose, state: rewriteState({ instruction: "" }) }),
|
|
);
|
|
|
|
openPopover(popover);
|
|
const instruction = expectPresent(document.querySelector<HTMLTextAreaElement>(".codex-panel-selection-rewrite__instruction"));
|
|
expect(instruction.getAttribute("aria-label")).toBeNull();
|
|
void act(() => {
|
|
setTextareaValue(instruction, "Make it concise.");
|
|
instruction.dispatchEvent(new Event("input", { bubbles: true }));
|
|
});
|
|
|
|
await act(async () => {
|
|
instruction.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(selectionRewriteGenerate).toHaveBeenCalledOnce();
|
|
expect(document.querySelector(".codex-panel-selection-rewrite__diff")?.textContent).toContain("Rewritten sentence.");
|
|
const apply = expectPresent(document.querySelector<HTMLButtonElement>('button[aria-label="Apply"]'));
|
|
expect(apply.disabled).toBe(false);
|
|
|
|
await act(async () => {
|
|
apply.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(editor.replaceRange).toHaveBeenCalledWith("Rewritten sentence.", { line: 1, ch: 0 }, { line: 1, ch: 22 }, "codex-panel-rewrite");
|
|
expect(document.querySelector(".codex-panel-selection-rewrite")).toBeNull();
|
|
expect(onClose).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("refuses to apply when the current editor range no longer matches the original selection", async () => {
|
|
const editor = editorFixture({ currentText: "Changed sentence." });
|
|
const popover = new SelectionRewritePopover(
|
|
popoverOptions({
|
|
editor: editor.editor,
|
|
state: rewriteState({ status: "preview", replacementText: "Rewritten sentence." }),
|
|
}),
|
|
);
|
|
|
|
openPopover(popover);
|
|
await act(async () => {
|
|
expectPresent(document.querySelector<HTMLButtonElement>('button[aria-label="Apply"]')).click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(editor.getRange).toHaveBeenCalledWith({ line: 1, ch: 0 }, { line: 1, ch: 22 });
|
|
expect(editor.replaceRange).not.toHaveBeenCalled();
|
|
expect(document.querySelector(".codex-panel-selection-rewrite")).not.toBeNull();
|
|
expect(document.body.textContent).toContain("Selection changed. Generate the rewrite again before applying.");
|
|
|
|
closePopover(popover);
|
|
});
|
|
|
|
it("unmounts and removes the Preact popover when closed", () => {
|
|
const onClose = vi.fn();
|
|
const popover = new SelectionRewritePopover(popoverOptions({ onClose }));
|
|
|
|
openPopover(popover);
|
|
expect(document.querySelector(".codex-panel-selection-rewrite__instruction")).not.toBeNull();
|
|
expect(document.querySelector<HTMLButtonElement>('button[aria-label="Cancel"]')).toBeNull();
|
|
|
|
closePopover(popover);
|
|
|
|
expect(document.querySelector(".codex-panel-selection-rewrite")).toBeNull();
|
|
expect(onClose).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("closes from outside pointerdown without closing internal controls", () => {
|
|
const onClose = vi.fn();
|
|
const popover = new SelectionRewritePopover(popoverOptions({ onClose }));
|
|
|
|
openPopover(popover);
|
|
const generate = expectPresent(document.querySelector<HTMLButtonElement>('button[aria-label="Generate"]'));
|
|
void act(() => {
|
|
generate.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
|
|
});
|
|
|
|
expect(document.querySelector(".codex-panel-selection-rewrite")).not.toBeNull();
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
|
|
void act(() => {
|
|
document.body.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
|
|
});
|
|
|
|
expect(document.querySelector(".codex-panel-selection-rewrite")).toBeNull();
|
|
expect(onClose).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("aborts the active generation when closed and ignores its late result", async () => {
|
|
const rewrite = deferred<{ replacementText: string }>();
|
|
let signal: AbortSignal | undefined;
|
|
selectionRewriteGenerate.mockImplementation((options) => {
|
|
signal = options.signal;
|
|
return rewrite.promise;
|
|
});
|
|
const popover = new SelectionRewritePopover(popoverOptions());
|
|
|
|
openPopover(popover);
|
|
await act(async () => {
|
|
expectPresent(document.querySelector<HTMLButtonElement>('button[aria-label="Generate"]')).click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
closePopover(popover);
|
|
expect(signal?.aborted).toBe(true);
|
|
|
|
rewrite.resolve({ replacementText: "Late rewrite" });
|
|
await act(async () => {
|
|
await flushPromises();
|
|
});
|
|
|
|
expect(document.querySelector(".codex-panel-selection-rewrite")).toBeNull();
|
|
expect(document.body.textContent).not.toContain("Late rewrite");
|
|
});
|
|
|
|
it("ignores late generation preview callbacks after close", async () => {
|
|
const rewrite = deferred<{ replacementText: string }>();
|
|
const options = popoverOptions();
|
|
const preview: { current: ((text: string) => void) | null } = { current: null };
|
|
selectionRewriteGenerate.mockImplementation((runOptions) => {
|
|
preview.current = runOptions.onPreview ?? null;
|
|
return rewrite.promise;
|
|
});
|
|
const popover = new SelectionRewritePopover(options);
|
|
|
|
openPopover(popover);
|
|
await act(async () => {
|
|
expectPresent(document.querySelector<HTMLButtonElement>('button[aria-label="Generate"]')).click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
closePopover(popover);
|
|
const latePreview = preview.current;
|
|
if (!latePreview) throw new Error("Expected preview callback to be captured");
|
|
latePreview("Late partial");
|
|
|
|
expect(options.state.streamText).toBe("");
|
|
|
|
rewrite.resolve({ replacementText: "Late rewrite" });
|
|
await act(async () => {
|
|
await flushPromises();
|
|
});
|
|
});
|
|
|
|
it("keeps only one generation run active while a rewrite is pending", async () => {
|
|
const rewrite = deferred<{ replacementText: string }>();
|
|
selectionRewriteGenerate.mockReturnValue(rewrite.promise);
|
|
const popover = new SelectionRewritePopover(popoverOptions());
|
|
|
|
openPopover(popover);
|
|
const generate = expectPresent(document.querySelector<HTMLButtonElement>('button[aria-label="Generate"]'));
|
|
await act(async () => {
|
|
generate.click();
|
|
generate.click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(selectionRewriteGenerate).toHaveBeenCalledOnce();
|
|
|
|
rewrite.resolve({ replacementText: "Rewritten once." });
|
|
await act(async () => {
|
|
await flushPromises();
|
|
});
|
|
|
|
expect(document.querySelector(".codex-panel-selection-rewrite__diff")?.textContent).toContain("Rewritten once.");
|
|
});
|
|
|
|
it("restores the current draft after accidental instruction history navigation", async () => {
|
|
selectionRewriteGenerate.mockResolvedValue({ replacementText: "Rewritten." });
|
|
|
|
await generateInstructionHistoryItem("History item one.");
|
|
await generateInstructionHistoryItem("History item two.");
|
|
|
|
const popover = new SelectionRewritePopover(popoverOptions({ state: rewriteState({ instruction: "Current draft." }) }));
|
|
openPopover(popover);
|
|
const instruction = expectPresent(document.querySelector<HTMLTextAreaElement>(".codex-panel-selection-rewrite__instruction"));
|
|
instruction.setSelectionRange(0, 0);
|
|
|
|
void act(() => {
|
|
instruction.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowUp" }));
|
|
});
|
|
|
|
expect(instruction.value).toBe("History item two.");
|
|
|
|
void act(() => {
|
|
instruction.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowDown" }));
|
|
});
|
|
|
|
expect(instruction.value).toBe("Current draft.");
|
|
|
|
closePopover(popover);
|
|
});
|
|
|
|
it("preserves edited instruction history drafts while navigating history", async () => {
|
|
selectionRewriteGenerate.mockResolvedValue({ replacementText: "Rewritten." });
|
|
|
|
await generateInstructionHistoryItem("History item one.");
|
|
await generateInstructionHistoryItem("History item two.");
|
|
|
|
const popover = new SelectionRewritePopover(popoverOptions({ state: rewriteState({ instruction: "Current draft." }) }));
|
|
openPopover(popover);
|
|
const instruction = expectPresent(document.querySelector<HTMLTextAreaElement>(".codex-panel-selection-rewrite__instruction"));
|
|
instruction.setSelectionRange(0, 0);
|
|
|
|
void act(() => {
|
|
instruction.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowUp" }));
|
|
setTextareaValue(instruction, "Edited history draft.");
|
|
instruction.dispatchEvent(new Event("input", { bubbles: true }));
|
|
instruction.setSelectionRange(0, 0);
|
|
});
|
|
|
|
expect(instruction.value).toBe("Edited history draft.");
|
|
|
|
void act(() => {
|
|
instruction.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, ctrlKey: true, key: "p" }));
|
|
});
|
|
|
|
expect(instruction.value).toBe("History item one.");
|
|
|
|
void act(() => {
|
|
instruction.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowDown" }));
|
|
});
|
|
|
|
expect(instruction.value).toBe("History item two.");
|
|
|
|
void act(() => {
|
|
instruction.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, ctrlKey: true, key: "n" }));
|
|
});
|
|
|
|
expect(instruction.value).toBe("Edited history draft.");
|
|
|
|
closePopover(popover);
|
|
});
|
|
|
|
it("records edited history items as new entries only when generated", async () => {
|
|
selectionRewriteGenerate.mockResolvedValue({ replacementText: "Rewritten." });
|
|
|
|
await generateInstructionHistoryItem("Rewrite as bullets.");
|
|
|
|
const editingPopover = new SelectionRewritePopover(popoverOptions({ state: rewriteState({ instruction: "" }) }));
|
|
openPopover(editingPopover);
|
|
const editingInstruction = expectPresent(document.querySelector<HTMLTextAreaElement>(".codex-panel-selection-rewrite__instruction"));
|
|
|
|
void act(() => {
|
|
editingInstruction.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowUp" }));
|
|
setTextareaValue(editingInstruction, "Rewrite as numbered bullets.");
|
|
editingInstruction.dispatchEvent(new Event("input", { bubbles: true }));
|
|
});
|
|
|
|
await act(async () => {
|
|
expectPresent(document.querySelector<HTMLButtonElement>('button[aria-label="Generate"]')).click();
|
|
await flushPromises();
|
|
});
|
|
|
|
closePopover(editingPopover);
|
|
|
|
const historyPopover = new SelectionRewritePopover(popoverOptions({ state: rewriteState({ instruction: "" }) }));
|
|
openPopover(historyPopover);
|
|
const historyInstruction = expectPresent(document.querySelector<HTMLTextAreaElement>(".codex-panel-selection-rewrite__instruction"));
|
|
|
|
void act(() => {
|
|
historyInstruction.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowUp" }));
|
|
});
|
|
|
|
expect(historyInstruction.value).toBe("Rewrite as numbered bullets.");
|
|
|
|
void act(() => {
|
|
historyInstruction.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowUp" }));
|
|
});
|
|
|
|
expect(historyInstruction.value).toBe("Rewrite as bullets.");
|
|
|
|
closePopover(historyPopover);
|
|
});
|
|
});
|
|
|
|
function rewriteState(overrides: Partial<SelectionRewriteState> = {}): SelectionRewriteState {
|
|
return {
|
|
filePath: "Note.md",
|
|
targetRange: {
|
|
from: { line: 1, ch: 0 },
|
|
to: { line: 1, ch: 22 },
|
|
},
|
|
originalText: "Revise this sentence.",
|
|
noteText: "# Heading\n\nRevise this sentence.\n\nNext paragraph.",
|
|
instruction: "Make it clearer.",
|
|
status: "editing-prompt",
|
|
streamText: "",
|
|
replacementText: null,
|
|
debugText: null,
|
|
...overrides,
|
|
} as SelectionRewriteState;
|
|
}
|
|
|
|
function selectionRewriteStateWithStatus(status: SelectionRewriteState["status"]): SelectionRewriteState {
|
|
switch (status) {
|
|
case "editing-prompt":
|
|
return rewriteState({ status: "editing-prompt", streamText: "", replacementText: null, debugText: null });
|
|
case "generating":
|
|
return rewriteState({ status: "generating", streamText: "draft", replacementText: null, debugText: null });
|
|
case "preview":
|
|
return rewriteState({ status: "preview", streamText: "", replacementText: "Preview text.", debugText: null });
|
|
case "failed":
|
|
return rewriteState({ status: "failed", streamText: "", replacementText: null, debugText: "debug" });
|
|
case "cancelled":
|
|
return rewriteState({ status: "cancelled", streamText: "partial", replacementText: null, debugText: "debug" });
|
|
case "applied":
|
|
return rewriteState({ status: "applied", streamText: "", replacementText: "Applied text.", debugText: null });
|
|
}
|
|
}
|
|
|
|
function previewUpdatedEvent(): SelectionRewriteLifecycleEvent {
|
|
return { type: "preview-updated", text: "late preview" };
|
|
}
|
|
|
|
function generationSucceededEvent(): SelectionRewriteLifecycleEvent {
|
|
return { type: "generation-succeeded", replacementText: "late replacement" };
|
|
}
|
|
|
|
function generationFailedEvent(): SelectionRewriteLifecycleEvent {
|
|
return { type: "generation-failed", debugText: "late debug" };
|
|
}
|
|
|
|
function openPopover(popover: SelectionRewritePopover): void {
|
|
void act(() => {
|
|
popover.open();
|
|
});
|
|
}
|
|
|
|
function closePopover(popover: SelectionRewritePopover): void {
|
|
void act(() => {
|
|
popover.close();
|
|
});
|
|
}
|
|
|
|
async function generateInstructionHistoryItem(instructionText: string): Promise<void> {
|
|
const popover = new SelectionRewritePopover(popoverOptions({ state: rewriteState({ instruction: "" }) }));
|
|
openPopover(popover);
|
|
const instruction = expectPresent(document.querySelector<HTMLTextAreaElement>(".codex-panel-selection-rewrite__instruction"));
|
|
|
|
void act(() => {
|
|
setTextareaValue(instruction, instructionText);
|
|
instruction.dispatchEvent(new Event("input", { bubbles: true }));
|
|
});
|
|
|
|
await act(async () => {
|
|
expectPresent(document.querySelector<HTMLButtonElement>('button[aria-label="Generate"]')).click();
|
|
await flushPromises();
|
|
});
|
|
|
|
closePopover(popover);
|
|
}
|
|
|
|
function popoverOptions(
|
|
overrides: Partial<ConstructorParameters<typeof SelectionRewritePopover>[0]> = {},
|
|
): ConstructorParameters<typeof SelectionRewritePopover>[0] {
|
|
return {
|
|
editor: editorFixture().editor,
|
|
runtimeSettings: { rewriteSelectionModel: null, rewriteSelectionEffort: null },
|
|
sendShortcut: "enter",
|
|
state: rewriteState(),
|
|
transport: {
|
|
generate: (request) => selectionRewriteGenerate(request),
|
|
},
|
|
viewDocument: document,
|
|
viewWindow: window,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function editorFixture(options: { currentText?: string; currentNoteText?: string } = {}): {
|
|
editor: ConstructorParameters<typeof SelectionRewritePopover>[0]["editor"];
|
|
getRange: ReturnType<typeof vi.fn>;
|
|
getValue: ReturnType<typeof vi.fn>;
|
|
replaceRange: ReturnType<typeof vi.fn>;
|
|
} {
|
|
const getRange = vi.fn(() => options.currentText ?? "Revise this sentence.");
|
|
const getValue = vi.fn(() => options.currentNoteText ?? rewriteState().noteText);
|
|
const replaceRange = vi.fn();
|
|
return {
|
|
editor: {
|
|
getCursor: () => ({ line: 1, ch: 22 }),
|
|
getRange,
|
|
getValue,
|
|
posToOffset: () => 0,
|
|
replaceRange,
|
|
} as unknown as ConstructorParameters<typeof SelectionRewritePopover>[0]["editor"],
|
|
getRange,
|
|
getValue,
|
|
replaceRange,
|
|
};
|
|
}
|
|
|
|
function setTextareaValue(textarea: HTMLTextAreaElement, value: string): void {
|
|
const descriptor = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value");
|
|
if (!descriptor?.set) throw new Error("Expected textarea value setter.");
|
|
const setValue = descriptor.set.bind(textarea) as (nextValue: string) => void;
|
|
setValue(value);
|
|
}
|
|
|
|
function expectPresent<T>(value: T | null | undefined): T {
|
|
if (value === null || value === undefined) throw new Error("Expected value to be present");
|
|
return value;
|
|
}
|
|
|
|
async function flushPromises(): Promise<void> {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
}
|
|
|
|
function runSelectionRewrite(options: SelectionRewriteTestRunOptions) {
|
|
const { clientFactory, ...request } = options;
|
|
return createAppServerSelectionRewriteTransport({
|
|
codexPath: () => "/bin/codex",
|
|
cwd: "/vault",
|
|
clientFactory,
|
|
}).generate(request);
|
|
}
|
|
|
|
function runOptions(clientFactory: SelectionRewriteClientFactory): SelectionRewriteTestRunOptions {
|
|
return {
|
|
prompt: "Rewrite this.",
|
|
runtimeSettings: { rewriteSelectionModel: null, rewriteSelectionEffort: null },
|
|
onActivity: () => undefined,
|
|
onPreview: () => undefined,
|
|
signal: new AbortController().signal,
|
|
clientFactory,
|
|
};
|
|
}
|
|
|
|
function fakeSelectionRewriteClientFactory(configure?: (client: FakeSelectionRewriteClient) => void): {
|
|
clientFactory: SelectionRewriteClientFactory;
|
|
client: { current: FakeSelectionRewriteClient | null };
|
|
} {
|
|
const client: { current: FakeSelectionRewriteClient | null } = { current: null };
|
|
return {
|
|
client,
|
|
clientFactory: (_codexPath, _cwd, handlers) => {
|
|
client.current = new FakeSelectionRewriteClient(handlers);
|
|
configure?.(client.current);
|
|
return client.current;
|
|
},
|
|
};
|
|
}
|
|
|
|
class FakeSelectionRewriteClient implements SelectionRewriteClient {
|
|
disconnected = false;
|
|
modelList: ModelListResponse = { data: [], nextCursor: null };
|
|
startStructuredTurnOptions: AppServerStartStructuredTurnOptions | null = null;
|
|
startStructuredTurnImpl: (() => Promise<TurnStartResponse>) | null = null;
|
|
readonly structuredTurnStarted: Promise<void>;
|
|
private resolveStructuredTurnStarted!: () => void;
|
|
|
|
constructor(private readonly handlers: AppServerClientHandlers) {
|
|
this.structuredTurnStarted = new Promise((resolve) => {
|
|
this.resolveStructuredTurnStarted = resolve;
|
|
});
|
|
}
|
|
|
|
async connect(): Promise<InitializeResponse> {
|
|
return { codexHome: "/tmp/codex" } as InitializeResponse;
|
|
}
|
|
|
|
disconnect(): void {
|
|
this.disconnected = true;
|
|
}
|
|
|
|
rejectServerRequest(_requestId: RequestId, _code: number, _message: string): void {}
|
|
|
|
async request<M extends TypedClientRequestMethod>(
|
|
method: M,
|
|
params: ClientRequestParams<M>,
|
|
options: { timeoutMs?: number } = {},
|
|
): Promise<ClientResponseByMethod[M]> {
|
|
void options;
|
|
switch (method) {
|
|
case "model/list":
|
|
return this.modelList as unknown as ClientResponseByMethod[M];
|
|
case "thread/start":
|
|
return threadStartResponse("thread") as unknown as ClientResponseByMethod[M];
|
|
case "turn/start":
|
|
this.startStructuredTurnOptions = structuredTurnOptionsFromParams(params as ClientRequestParams<"turn/start">);
|
|
this.resolveStructuredTurnStarted();
|
|
return (this.startStructuredTurnImpl
|
|
? await this.startStructuredTurnImpl()
|
|
: { turn: turn([], { id: "turn", status: "inProgress" }) }) as unknown as ClientResponseByMethod[M];
|
|
case "thread/delete":
|
|
return {} as unknown as ClientResponseByMethod[M];
|
|
default:
|
|
throw new Error(`Unexpected app-server request: ${method}`);
|
|
}
|
|
}
|
|
|
|
emit(notification: ServerNotification): void {
|
|
this.handlers.onNotification(notification);
|
|
}
|
|
}
|
|
|
|
function structuredTurnOptionsFromParams(params: ClientRequestParams<"turn/start">): AppServerStartStructuredTurnOptions {
|
|
const textItem = params.input[0];
|
|
if (!params.cwd || !textItem || textItem.type !== "text" || !params.outputSchema) throw new Error("Expected structured turn params.");
|
|
return {
|
|
threadId: params.threadId,
|
|
cwd: params.cwd,
|
|
text: textItem.text,
|
|
outputSchema: params.outputSchema,
|
|
runtime: {
|
|
...(params.serviceTier !== undefined ? { serviceTier: params.serviceTier } : {}),
|
|
...(params.collaborationMode !== undefined && params.collaborationMode !== null
|
|
? { collaborationMode: params.collaborationMode }
|
|
: {}),
|
|
...(params.model !== undefined ? { model: params.model } : {}),
|
|
...(params.effort !== undefined ? { effort: params.effort } : {}),
|
|
...(params.approvalsReviewer !== undefined ? { approvalsReviewer: params.approvalsReviewer } : {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
function threadStartResponse(threadId: string): ThreadStartResponse {
|
|
return {
|
|
thread: thread(threadId),
|
|
model: "gpt-5.1",
|
|
modelProvider: "openai",
|
|
serviceTier: null,
|
|
approvalPolicy: "never",
|
|
cwd: "/vault",
|
|
runtimeWorkspaceRoots: [],
|
|
instructionSources: [],
|
|
approvalsReviewer: "auto_review",
|
|
activePermissionProfile: null,
|
|
sandbox: { type: "readOnly", networkAccess: false },
|
|
reasoningEffort: null,
|
|
multiAgentMode: "explicitRequestOnly",
|
|
};
|
|
}
|
|
|
|
function thread(id: string): ThreadStartResponse["thread"] {
|
|
return {
|
|
id,
|
|
extra: null,
|
|
sessionId: "session",
|
|
forkedFromId: null,
|
|
parentThreadId: null,
|
|
preview: "",
|
|
ephemeral: true,
|
|
historyMode: "paginated",
|
|
modelProvider: "openai",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
recencyAt: null,
|
|
status: { type: "idle" },
|
|
path: null,
|
|
cwd: "/vault",
|
|
cliVersion: "0.0.0",
|
|
source: "unknown",
|
|
threadSource: null,
|
|
agentNickname: null,
|
|
agentRole: null,
|
|
gitInfo: null,
|
|
name: null,
|
|
turns: [],
|
|
};
|
|
}
|
|
|
|
function turn(items: Turn["items"], overrides: Partial<Turn> = {}): Turn {
|
|
return {
|
|
id: "turn",
|
|
items,
|
|
itemsView: "full",
|
|
status: "completed",
|
|
error: null,
|
|
startedAt: null,
|
|
completedAt: null,
|
|
durationMs: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function agentMessage(id: string, text: string): TurnItem {
|
|
return { type: "agentMessage", id, text, phase: "final_answer", memoryCitation: null };
|
|
}
|
|
|
|
function agentDeltaNotification(threadId: string, turnId: string, delta: string): ServerNotification {
|
|
return {
|
|
method: "item/agentMessage/delta",
|
|
params: { threadId, turnId, itemId: "agent", delta },
|
|
};
|
|
}
|
|
|
|
function completedItemNotification(threadId: string, turnId: string, item: TurnItem): ServerNotification {
|
|
type ItemCompletedNotification = Extract<ServerNotification, { method: "item/completed" }>;
|
|
return {
|
|
method: "item/completed",
|
|
params: { threadId, turnId, item, completedAtMs: 1 } as unknown as ItemCompletedNotification["params"],
|
|
};
|
|
}
|
|
|
|
function turnCompletedNotification(threadId: string, completedTurn: Turn): ServerNotification {
|
|
type TurnCompletedNotification = Extract<ServerNotification, { method: "turn/completed" }>;
|
|
return {
|
|
method: "turn/completed",
|
|
params: { threadId, turn: completedTurn } as unknown as TurnCompletedNotification["params"],
|
|
};
|
|
}
|
|
|
|
function modelMetadata(name: string, efforts: ReasoningEffort[]): ModelMetadata {
|
|
return {
|
|
id: name,
|
|
model: name,
|
|
displayName: name,
|
|
description: "",
|
|
hidden: false,
|
|
supportedReasoningEfforts: efforts,
|
|
defaultReasoningEffort: efforts[0] ?? "low",
|
|
inputModalities: ["text"],
|
|
additionalSpeedTiers: [],
|
|
serviceTiers: [],
|
|
defaultServiceTier: null,
|
|
isDefault: false,
|
|
};
|
|
}
|