mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
refactor(app-server): inject structured turn runner
This commit is contained in:
parent
cb172edfb7
commit
359bf1c21e
7 changed files with 281 additions and 609 deletions
|
|
@ -6,6 +6,7 @@ import type { ServerNotification } from "../connection/rpc-messages";
|
|||
import { lastAgentMessageTextFromTurnRecord, type TurnItem, type TurnRecord } from "../protocol/turn";
|
||||
import type { ModelMetadataClient } from "./catalog";
|
||||
import type { AppServerRequestClient } from "./request-client";
|
||||
import { type RuntimeOverrideSettings, resolvedRuntimeOverrideForClient } from "./runtime-overrides";
|
||||
import { deleteThread, startEphemeralThread } from "./threads";
|
||||
import { type AppServerStartStructuredTurnOptions, startStructuredTurn } from "./turns";
|
||||
|
||||
|
|
@ -56,17 +57,25 @@ export interface RunEphemeralStructuredTurnOptions {
|
|||
timedOutMessage: string;
|
||||
abortMessage?: string;
|
||||
runtime?: StructuredTurnRuntimeOverride | undefined;
|
||||
resolveRuntime?: ((client: ModelMetadataClient) => Promise<StructuredTurnRuntimeOverride>) | undefined;
|
||||
runtimeSettings?: RuntimeOverrideSettings | undefined;
|
||||
signal?: AbortSignal | undefined;
|
||||
onProgress?: (event: StructuredTurnProgressEvent) => void;
|
||||
}
|
||||
|
||||
export type EphemeralStructuredTurnRunner = (options: RunEphemeralStructuredTurnOptions) => Promise<TurnRecord>;
|
||||
|
||||
export interface EphemeralStructuredTurnDependencies {
|
||||
clientFactory?: EphemeralStructuredTurnClientFactory | undefined;
|
||||
timers?: EphemeralStructuredTurnTimers | undefined;
|
||||
}
|
||||
|
||||
export async function runEphemeralStructuredTurn(options: RunEphemeralStructuredTurnOptions): Promise<TurnRecord> {
|
||||
export async function runEphemeralStructuredTurn(
|
||||
options: RunEphemeralStructuredTurnOptions,
|
||||
dependencies: EphemeralStructuredTurnDependencies = {},
|
||||
): Promise<TurnRecord> {
|
||||
throwIfAborted(options.signal, options.abortMessage);
|
||||
let state = createEphemeralStructuredTurnState();
|
||||
const timers = options.timers ?? DEFAULT_EPHEMERAL_STRUCTURED_TURN_TIMERS;
|
||||
const timers = dependencies.timers ?? DEFAULT_EPHEMERAL_STRUCTURED_TURN_TIMERS;
|
||||
let handleNotification: (notification: ServerNotification) => void = () => undefined;
|
||||
let operationAbortError: Error | null = null;
|
||||
const operationAbort = new AbortController();
|
||||
|
|
@ -98,7 +107,7 @@ export async function runEphemeralStructuredTurn(options: RunEphemeralStructured
|
|||
});
|
||||
|
||||
const clientFactory =
|
||||
options.clientFactory ??
|
||||
dependencies.clientFactory ??
|
||||
((codexPath, cwd, handlers) =>
|
||||
new AppServerClient({
|
||||
codexPath,
|
||||
|
|
@ -125,7 +134,9 @@ export async function runEphemeralStructuredTurn(options: RunEphemeralStructured
|
|||
|
||||
try {
|
||||
await runAbortable(client.connect());
|
||||
const runtime = options.resolveRuntime ? await runAbortable(options.resolveRuntime(client)) : (options.runtime ?? {});
|
||||
const runtime = options.runtimeSettings
|
||||
? await runAbortable(resolvedRuntimeOverrideForClient(client, options.runtimeSettings))
|
||||
: (options.runtime ?? {});
|
||||
const threadResponse = await runAbortable(
|
||||
startEphemeralThread(client, {
|
||||
cwd: options.cwd,
|
||||
|
|
@ -169,8 +180,11 @@ export async function runEphemeralStructuredTurn(options: RunEphemeralStructured
|
|||
}
|
||||
}
|
||||
|
||||
export async function runEphemeralStructuredTurnForLastAgentText(options: RunEphemeralStructuredTurnOptions): Promise<string | null> {
|
||||
const turn = await runEphemeralStructuredTurn(options);
|
||||
export async function runEphemeralStructuredTurnForLastAgentText(
|
||||
options: RunEphemeralStructuredTurnOptions,
|
||||
runner: EphemeralStructuredTurnRunner = runEphemeralStructuredTurn,
|
||||
): Promise<string | null> {
|
||||
const turn = await runner(options);
|
||||
return lastAgentMessageTextFromTurnRecord(turn);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,11 +7,10 @@ import {
|
|||
} from "../../domain/threads/title-generation-model";
|
||||
import { turnTranscriptAssistantTextFromTurnRecord } from "../protocol/turn";
|
||||
import {
|
||||
type EphemeralStructuredTurnClientFactory,
|
||||
type EphemeralStructuredTurnRunner,
|
||||
runEphemeralStructuredTurn,
|
||||
type StructuredTurnOutputSchema,
|
||||
} from "./ephemeral-structured-turn";
|
||||
import { resolvedRuntimeOverrideForClient } from "./runtime-overrides";
|
||||
|
||||
const THREAD_TITLE_SERVICE_NAME = "codex-panel-naming";
|
||||
const THREAD_TITLE_TIMEOUT_MS = 60_000;
|
||||
|
|
@ -41,14 +40,20 @@ export interface ThreadTitleRuntimeSettings {
|
|||
threadNamingEffort: ReasoningEffort | null;
|
||||
}
|
||||
|
||||
export interface GenerateThreadTitleWithCodexOptions {
|
||||
runner?: EphemeralStructuredTurnRunner;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export async function generateThreadTitleWithCodex(
|
||||
codexPath: string,
|
||||
cwd: string,
|
||||
context: ThreadTitleContext,
|
||||
runtimeSettings: ThreadTitleRuntimeSettings,
|
||||
clientFactory?: EphemeralStructuredTurnClientFactory,
|
||||
options: GenerateThreadTitleWithCodexOptions = {},
|
||||
): Promise<string | null> {
|
||||
const turn = await runEphemeralStructuredTurn({
|
||||
const runner = options.runner ?? runEphemeralStructuredTurn;
|
||||
const turn = await runner({
|
||||
codexPath,
|
||||
cwd,
|
||||
serviceName: THREAD_TITLE_SERVICE_NAME,
|
||||
|
|
@ -59,9 +64,9 @@ export async function generateThreadTitleWithCodex(
|
|||
serverRequests: { kind: "reject", message: "Thread title generation does not handle server requests." },
|
||||
exitedMessage: "Codex title generation app-server exited.",
|
||||
timedOutMessage: "Timed out while generating a Codex thread title.",
|
||||
resolveRuntime: (client) =>
|
||||
resolvedRuntimeOverrideForClient(client, { model: runtimeSettings.threadNamingModel, effort: runtimeSettings.threadNamingEffort }),
|
||||
clientFactory,
|
||||
abortMessage: "Thread title generation cancelled.",
|
||||
runtimeSettings: { model: runtimeSettings.threadNamingModel, effort: runtimeSettings.threadNamingEffort },
|
||||
signal: options.signal,
|
||||
});
|
||||
const response = turnTranscriptAssistantTextFromTurnRecord(turn);
|
||||
return response ? threadTitleFromGeneratedText(response) : null;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
import type { AppServerClientHandlers } from "../../app-server/connection/client";
|
||||
import type { ModelMetadataClient } from "../../app-server/services/catalog";
|
||||
import {
|
||||
type EphemeralStructuredTurnClient,
|
||||
type EphemeralStructuredTurnRunner,
|
||||
runEphemeralStructuredTurnForLastAgentText,
|
||||
type StructuredTurnOutputSchema,
|
||||
} from "../../app-server/services/ephemeral-structured-turn";
|
||||
import { resolvedRuntimeOverrideForClient } from "../../app-server/services/runtime-overrides";
|
||||
import { SelectionRewriteOutputError, selectionRewriteOutputParseResultFromText } from "./output";
|
||||
import { SELECTION_REWRITE_DEVELOPER_INSTRUCTIONS, SELECTION_REWRITE_SERVICE_NAME } from "./prompt";
|
||||
import type { SelectionRewriteTransport, SelectionRewriteTransportRequest } from "./transport";
|
||||
|
|
@ -19,13 +16,10 @@ const SELECTION_REWRITE_OUTPUT_SCHEMA: StructuredTurnOutputSchema = {
|
|||
additionalProperties: false,
|
||||
};
|
||||
|
||||
type SelectionRewriteClient = EphemeralStructuredTurnClient & ModelMetadataClient;
|
||||
type SelectionRewriteClientFactory = (codexPath: string, cwd: string, handlers: AppServerClientHandlers) => SelectionRewriteClient;
|
||||
|
||||
export interface AppServerSelectionRewriteTransportOptions {
|
||||
codexPath(): string;
|
||||
cwd: string;
|
||||
clientFactory?: SelectionRewriteClientFactory;
|
||||
runner?: EphemeralStructuredTurnRunner;
|
||||
}
|
||||
|
||||
export function createAppServerSelectionRewriteTransport(options: AppServerSelectionRewriteTransportOptions): SelectionRewriteTransport {
|
||||
|
|
@ -36,35 +30,36 @@ export function createAppServerSelectionRewriteTransport(options: AppServerSelec
|
|||
|
||||
async function runAppServerSelectionRewrite(options: AppServerSelectionRewriteTransportOptions, request: SelectionRewriteTransportRequest) {
|
||||
let preview = "";
|
||||
const lastAgentText = await runEphemeralStructuredTurnForLastAgentText({
|
||||
codexPath: options.codexPath(),
|
||||
cwd: options.cwd,
|
||||
serviceName: SELECTION_REWRITE_SERVICE_NAME,
|
||||
developerInstructions: SELECTION_REWRITE_DEVELOPER_INSTRUCTIONS,
|
||||
prompt: request.prompt,
|
||||
outputSchema: SELECTION_REWRITE_OUTPUT_SCHEMA,
|
||||
timeoutMs: SELECTION_REWRITE_TIMEOUT_MS,
|
||||
serverRequests: { kind: "reject", message: "Selection rewrite does not handle server requests." },
|
||||
exitedMessage: "Selection rewrite app-server exited.",
|
||||
timedOutMessage: "Timed out while rewriting the selection.",
|
||||
abortMessage: "Selection rewrite cancelled.",
|
||||
signal: request.signal,
|
||||
resolveRuntime: (client) =>
|
||||
resolvedRuntimeOverrideForClient(client, {
|
||||
const lastAgentText = await runEphemeralStructuredTurnForLastAgentText(
|
||||
{
|
||||
codexPath: options.codexPath(),
|
||||
cwd: options.cwd,
|
||||
serviceName: SELECTION_REWRITE_SERVICE_NAME,
|
||||
developerInstructions: SELECTION_REWRITE_DEVELOPER_INSTRUCTIONS,
|
||||
prompt: request.prompt,
|
||||
outputSchema: SELECTION_REWRITE_OUTPUT_SCHEMA,
|
||||
timeoutMs: SELECTION_REWRITE_TIMEOUT_MS,
|
||||
serverRequests: { kind: "reject", message: "Selection rewrite does not handle server requests." },
|
||||
exitedMessage: "Selection rewrite app-server exited.",
|
||||
timedOutMessage: "Timed out while rewriting the selection.",
|
||||
abortMessage: "Selection rewrite cancelled.",
|
||||
signal: request.signal,
|
||||
runtimeSettings: {
|
||||
model: request.runtimeSettings.rewriteSelectionModel,
|
||||
effort: request.runtimeSettings.rewriteSelectionEffort,
|
||||
}),
|
||||
clientFactory: options.clientFactory,
|
||||
onProgress: (event) => {
|
||||
if (event.type === "reasoning-activity") {
|
||||
request.onActivity("reasoning");
|
||||
return;
|
||||
}
|
||||
preview = `${preview}${event.delta}`;
|
||||
request.onActivity("writing");
|
||||
request.onPreview(preview);
|
||||
},
|
||||
onProgress: (event) => {
|
||||
if (event.type === "reasoning-activity") {
|
||||
request.onActivity("reasoning");
|
||||
return;
|
||||
}
|
||||
preview = `${preview}${event.delta}`;
|
||||
request.onActivity("writing");
|
||||
request.onPreview(preview);
|
||||
},
|
||||
},
|
||||
});
|
||||
options.runner,
|
||||
);
|
||||
const { output, rawText } = selectionRewriteOutputParseResultFromText(lastAgentText);
|
||||
if (!output) throw new SelectionRewriteOutputError("Codex did not return a valid selection rewrite response.", rawText);
|
||||
return output;
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ describe("runEphemeralStructuredTurn", () => {
|
|||
};
|
||||
});
|
||||
|
||||
const result = await runEphemeralStructuredTurn(runOptions(clientFactory));
|
||||
const result = await runEphemeralStructuredTurn(runOptions(), { clientFactory });
|
||||
|
||||
expect(result.items).toEqual([agentMessage("answer", '{"ok":true}')]);
|
||||
expect(result.itemsView).toBe("full");
|
||||
|
|
@ -41,12 +41,15 @@ describe("runEphemeralStructuredTurn", () => {
|
|||
it("reports matched structured turn progress without exposing raw notifications", async () => {
|
||||
const progress: unknown[] = [];
|
||||
const { clientFactory, client } = fakeStructuredTurnClientFactory();
|
||||
const running = runEphemeralStructuredTurn({
|
||||
...runOptions(clientFactory),
|
||||
onProgress: (event) => {
|
||||
progress.push(event);
|
||||
const running = runEphemeralStructuredTurn(
|
||||
{
|
||||
...runOptions(),
|
||||
onProgress: (event) => {
|
||||
progress.push(event);
|
||||
},
|
||||
},
|
||||
});
|
||||
{ clientFactory },
|
||||
);
|
||||
|
||||
await expectPresent(client.current).structuredTurnStarted;
|
||||
const fake = expectPresent(client.current);
|
||||
|
|
@ -69,7 +72,7 @@ describe("runEphemeralStructuredTurn", () => {
|
|||
};
|
||||
});
|
||||
|
||||
const result = await runEphemeralStructuredTurn(runOptions(clientFactory));
|
||||
const result = await runEphemeralStructuredTurn(runOptions(), { clientFactory });
|
||||
|
||||
expect(result.items).toEqual([agentMessage("answer", '{"ok":true}')]);
|
||||
});
|
||||
|
|
@ -82,10 +85,7 @@ describe("runEphemeralStructuredTurn", () => {
|
|||
fake.startStructuredTurnImpl = async () => ({ turn: turn([agentMessage("answer", '{"ok":true}')]) });
|
||||
});
|
||||
|
||||
await runEphemeralStructuredTurn({
|
||||
...runOptions(clientFactory),
|
||||
signal: controller.signal,
|
||||
});
|
||||
await runEphemeralStructuredTurn({ ...runOptions(), signal: controller.signal }, { clientFactory });
|
||||
|
||||
expect(add).toHaveBeenCalledWith("abort", expect.any(Function), { once: true });
|
||||
expect(remove).toHaveBeenCalledTimes(add.mock.calls.length);
|
||||
|
|
@ -100,10 +100,7 @@ describe("runEphemeralStructuredTurn", () => {
|
|||
fake.startStructuredTurnImpl = async () => ({ turn: turn([agentMessage("answer", '{"ok":true}')]) });
|
||||
});
|
||||
|
||||
await runEphemeralStructuredTurn({
|
||||
...runOptions(clientFactory),
|
||||
timers,
|
||||
});
|
||||
await runEphemeralStructuredTurn(runOptions(), { clientFactory, timers });
|
||||
|
||||
expect(timers.setTimeout).toHaveBeenCalledWith(expect.any(Function), 10_000);
|
||||
expect(timers.clearTimeout).toHaveBeenCalledWith(123);
|
||||
|
|
@ -114,7 +111,7 @@ describe("runEphemeralStructuredTurn", () => {
|
|||
fake.startStructuredTurnImpl = async () => ({ turn: turn([agentMessage("answer", '{"ok":true}')]) });
|
||||
});
|
||||
|
||||
await runEphemeralStructuredTurn(runOptions(clientFactory));
|
||||
await runEphemeralStructuredTurn(runOptions(), { clientFactory });
|
||||
|
||||
const fake = expectPresent(client.current);
|
||||
expect(fake.deleteThreadRequest).toHaveBeenCalledWith({ threadId: "thread" }, { timeoutMs: 5_000 });
|
||||
|
|
@ -129,10 +126,7 @@ describe("runEphemeralStructuredTurn", () => {
|
|||
fake.connectImpl = () => new Promise<InitializeResponse>(() => undefined);
|
||||
});
|
||||
|
||||
const running = runEphemeralStructuredTurn({
|
||||
...runOptions(clientFactory),
|
||||
timers,
|
||||
});
|
||||
const running = runEphemeralStructuredTurn(runOptions(), { clientFactory, timers });
|
||||
await Promise.resolve();
|
||||
|
||||
timers.fireTimeout();
|
||||
|
|
@ -147,7 +141,7 @@ describe("runEphemeralStructuredTurn", () => {
|
|||
fake.startStructuredTurnImpl = async () => ({ turn: turn([agentMessage("answer", '{"ok":true}')]) });
|
||||
});
|
||||
|
||||
await expect(runEphemeralStructuredTurn(runOptions(clientFactory))).resolves.toMatchObject({
|
||||
await expect(runEphemeralStructuredTurn(runOptions(), { clientFactory })).resolves.toMatchObject({
|
||||
items: [agentMessage("answer", '{"ok":true}')],
|
||||
});
|
||||
expect(expectPresent(client.current).disconnect).toHaveBeenCalledOnce();
|
||||
|
|
@ -162,7 +156,7 @@ describe("runEphemeralStructuredTurn", () => {
|
|||
fake.startStructuredTurnImpl = async () => ({ turn: turn([agentMessage("answer", '{"ok":true}')]) });
|
||||
});
|
||||
|
||||
await runEphemeralStructuredTurn(runOptions(clientFactory));
|
||||
await runEphemeralStructuredTurn(runOptions(), { clientFactory });
|
||||
|
||||
expect(expectPresent(client.current).rejectServerRequest).toHaveBeenCalledWith(
|
||||
123,
|
||||
|
|
@ -174,6 +168,10 @@ describe("runEphemeralStructuredTurn", () => {
|
|||
it("resolves runtime on the same client before starting the ephemeral thread", async () => {
|
||||
const callOrder: string[] = [];
|
||||
const { clientFactory, client } = fakeStructuredTurnClientFactory((fake) => {
|
||||
fake.modelListImpl = async () => {
|
||||
callOrder.push("model-list");
|
||||
return { data: [], nextCursor: null };
|
||||
};
|
||||
fake.startEphemeralThreadImpl = async () => {
|
||||
callOrder.push("start-thread");
|
||||
return threadStartResponse("thread");
|
||||
|
|
@ -181,18 +179,16 @@ describe("runEphemeralStructuredTurn", () => {
|
|||
fake.startStructuredTurnImpl = async () => ({ turn: turn([agentMessage("answer", '{"ok":true}')]) });
|
||||
});
|
||||
|
||||
await runEphemeralStructuredTurn({
|
||||
...runOptions(clientFactory),
|
||||
resolveRuntime: async (runtimeClient) => {
|
||||
callOrder.push("resolve-runtime");
|
||||
expect(runtimeClient).toBe(expectPresent(client.current));
|
||||
await runtimeClient.request("model/list", { includeHidden: false, limit: 100 });
|
||||
return { model: "gpt-5.1", effort: "low" };
|
||||
await runEphemeralStructuredTurn(
|
||||
{
|
||||
...runOptions(),
|
||||
runtimeSettings: { model: "gpt-5.1", effort: "low" },
|
||||
},
|
||||
});
|
||||
{ clientFactory },
|
||||
);
|
||||
|
||||
expect(callOrder).toEqual(["resolve-runtime", "start-thread"]);
|
||||
expect(expectPresent(client.current).modelListRequests).toEqual([{ includeHidden: false, limit: 100 }]);
|
||||
expect(callOrder).toEqual(["model-list", "start-thread"]);
|
||||
expect(expectPresent(client.current).modelListRequests).toEqual([{ includeHidden: false, cursor: null, limit: 100 }]);
|
||||
expect(expectPresent(client.current).startStructuredTurnOptions).toEqual({
|
||||
threadId: "thread",
|
||||
cwd: "/vault",
|
||||
|
|
@ -207,7 +203,7 @@ describe("runEphemeralStructuredTurn", () => {
|
|||
fake.startStructuredTurnImpl = async () => ({ turn: turn([agentMessage("answer", '{"ok":true}')]) });
|
||||
});
|
||||
|
||||
await runEphemeralStructuredTurn(runOptions(clientFactory));
|
||||
await runEphemeralStructuredTurn(runOptions(), { clientFactory });
|
||||
|
||||
expect(expectPresent(client.current).startEphemeralThreadOptions).toEqual({
|
||||
cwd: "/vault",
|
||||
|
|
@ -225,9 +221,10 @@ describe("runEphemeralStructuredTurn", () => {
|
|||
},
|
||||
{
|
||||
stage: "runtime resolution",
|
||||
configure: (_fake: FakeStructuredTurnClient): void => undefined,
|
||||
resolveRuntime: (): Promise<NonNullable<AppServerStartStructuredTurnOptions["runtime"]>> =>
|
||||
new Promise<NonNullable<AppServerStartStructuredTurnOptions["runtime"]>>(() => undefined),
|
||||
configure: (fake: FakeStructuredTurnClient): void => {
|
||||
fake.modelListImpl = () => new Promise<ClientResponseByMethod["model/list"]>(() => undefined);
|
||||
},
|
||||
runtimeSettings: { model: "gpt-5.1", effort: "low" } as const,
|
||||
},
|
||||
{
|
||||
stage: "ephemeral thread start",
|
||||
|
|
@ -242,15 +239,17 @@ describe("runEphemeralStructuredTurn", () => {
|
|||
},
|
||||
},
|
||||
{ stage: "completion wait", configure: (_fake: FakeStructuredTurnClient): void => undefined },
|
||||
])("times out during $stage and disconnects the client", async ({ configure, resolveRuntime }) => {
|
||||
])("times out during $stage and disconnects the client", async ({ configure, runtimeSettings }) => {
|
||||
const timers = timerHarness();
|
||||
const { clientFactory, client } = fakeStructuredTurnClientFactory(configure);
|
||||
|
||||
const running = runEphemeralStructuredTurn({
|
||||
...runOptions(clientFactory),
|
||||
...(resolveRuntime ? { resolveRuntime } : {}),
|
||||
timers,
|
||||
});
|
||||
const running = runEphemeralStructuredTurn(
|
||||
{
|
||||
...runOptions(),
|
||||
...(runtimeSettings ? { runtimeSettings } : {}),
|
||||
},
|
||||
{ clientFactory, timers },
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
timers.fireTimeout();
|
||||
|
|
@ -266,10 +265,7 @@ describe("runEphemeralStructuredTurn", () => {
|
|||
fake.startStructuredTurnImpl = () => new Promise<TurnStartResponse>(() => undefined);
|
||||
});
|
||||
|
||||
const running = runEphemeralStructuredTurn({
|
||||
...runOptions(clientFactory),
|
||||
timers,
|
||||
});
|
||||
const running = runEphemeralStructuredTurn(runOptions(), { clientFactory, timers });
|
||||
await expectPresent(client.current).structuredTurnStarted;
|
||||
|
||||
timers.fireTimeout();
|
||||
|
|
@ -279,7 +275,7 @@ describe("runEphemeralStructuredTurn", () => {
|
|||
});
|
||||
});
|
||||
|
||||
function runOptions(clientFactory: EphemeralStructuredTurnClientFactory): Parameters<typeof runEphemeralStructuredTurn>[0] {
|
||||
function runOptions(): Parameters<typeof runEphemeralStructuredTurn>[0] {
|
||||
return {
|
||||
codexPath: "/bin/codex",
|
||||
cwd: "/vault",
|
||||
|
|
@ -291,7 +287,6 @@ function runOptions(clientFactory: EphemeralStructuredTurnClientFactory): Parame
|
|||
serverRequests: { kind: "reject", message: "Structured test does not handle server requests." },
|
||||
exitedMessage: "Structured test app-server exited.",
|
||||
timedOutMessage: "Structured test timed out.",
|
||||
clientFactory,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -312,6 +307,7 @@ function fakeStructuredTurnClientFactory(configure?: (client: FakeStructuredTurn
|
|||
|
||||
class FakeStructuredTurnClient implements EphemeralStructuredTurnClient {
|
||||
connectImpl: (() => Promise<InitializeResponse>) | null = null;
|
||||
modelListImpl: (() => Promise<ClientResponseByMethod["model/list"]>) | null = null;
|
||||
startEphemeralThreadImpl: (() => Promise<ThreadStartResponse>) | null = null;
|
||||
startStructuredTurnImpl: (() => Promise<TurnStartResponse>) | null = null;
|
||||
startEphemeralThreadOptions: AppServerStartEphemeralThreadOptions | null = null;
|
||||
|
|
@ -343,7 +339,7 @@ class FakeStructuredTurnClient implements EphemeralStructuredTurnClient {
|
|||
switch (method) {
|
||||
case "model/list":
|
||||
this.modelListRequests.push(params as ClientRequestParams<"model/list">);
|
||||
return { data: [], nextCursor: null } as unknown as ClientResponseByMethod[M];
|
||||
return (this.modelListImpl ? await this.modelListImpl() : { data: [], nextCursor: null }) as ClientResponseByMethod[M];
|
||||
case "thread/start":
|
||||
this.startEphemeralThreadOptions = ephemeralThreadOptionsFromParams(params as ClientRequestParams<"thread/start">);
|
||||
return (this.startEphemeralThreadImpl
|
||||
|
|
|
|||
77
tests/app-server/runtime-overrides.test.ts
Normal file
77
tests/app-server/runtime-overrides.test.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ModelMetadataClient } from "../../src/app-server/services/catalog";
|
||||
import { resolvedRuntimeOverrideForClient } from "../../src/app-server/services/runtime-overrides";
|
||||
import type { ModelListResponse } from "../../src/generated/app-server/v2/ModelListResponse";
|
||||
|
||||
describe("resolvedRuntimeOverrideForClient", () => {
|
||||
it("uses Codex defaults without loading model metadata", async () => {
|
||||
const client = modelClient([]);
|
||||
|
||||
await expect(resolvedRuntimeOverrideForClient(client, { model: null, effort: null })).resolves.toEqual({});
|
||||
expect(client.request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes a model-only override without loading model metadata", async () => {
|
||||
const client = modelClient([]);
|
||||
|
||||
await expect(resolvedRuntimeOverrideForClient(client, { model: "gpt-5.4-mini", effort: null })).resolves.toEqual({
|
||||
model: "gpt-5.4-mini",
|
||||
});
|
||||
expect(client.request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps only efforts supported by the selected model", async () => {
|
||||
const client = modelClient([appServerModel("gpt-5.4-mini", ["low", "medium", "high"])]);
|
||||
|
||||
await expect(resolvedRuntimeOverrideForClient(client, { model: "gpt-5.4-mini", effort: "minimal" })).resolves.toEqual({
|
||||
model: "gpt-5.4-mini",
|
||||
});
|
||||
await expect(resolvedRuntimeOverrideForClient(client, { model: "gpt-5.4-mini", effort: "low" })).resolves.toEqual({
|
||||
model: "gpt-5.4-mini",
|
||||
effort: "low",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit settings when model metadata cannot be loaded", async () => {
|
||||
const request = vi.fn(async () => {
|
||||
throw new Error("unavailable");
|
||||
}) as unknown as ModelMetadataClient["request"];
|
||||
|
||||
await expect(resolvedRuntimeOverrideForClient({ request }, { model: "gpt-5.4-mini", effort: "minimal" })).resolves.toEqual({
|
||||
model: "gpt-5.4-mini",
|
||||
effort: "minimal",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function modelClient(models: ModelListResponse["data"]): ModelMetadataClient {
|
||||
return {
|
||||
request: vi.fn(async () => ({ data: models, nextCursor: null })) as unknown as ModelMetadataClient["request"],
|
||||
};
|
||||
}
|
||||
|
||||
function appServerModel(name: string, efforts: string[]): ModelListResponse["data"][number] {
|
||||
return {
|
||||
id: name,
|
||||
model: name,
|
||||
upgrade: null,
|
||||
upgradeInfo: null,
|
||||
availabilityNux: null,
|
||||
displayName: name,
|
||||
description: "",
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: efforts.map((reasoningEffort) => ({
|
||||
reasoningEffort: reasoningEffort as never,
|
||||
label: reasoningEffort,
|
||||
description: "",
|
||||
})),
|
||||
defaultReasoningEffort: (efforts[0] ?? "low") as never,
|
||||
inputModalities: ["text"],
|
||||
supportsPersonality: false,
|
||||
additionalSpeedTiers: [],
|
||||
serviceTiers: [],
|
||||
defaultServiceTier: null,
|
||||
isDefault: false,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,17 +1,10 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { 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 {
|
||||
EphemeralStructuredTurnClient,
|
||||
EphemeralStructuredTurnClientFactory,
|
||||
} from "../../src/app-server/services/ephemeral-structured-turn";
|
||||
import type { TurnRecord } from "../../src/app-server/protocol/turn";
|
||||
import type { EphemeralStructuredTurnRunner } from "../../src/app-server/services/ephemeral-structured-turn";
|
||||
import { generateThreadTitleWithCodex } from "../../src/app-server/services/thread-title-generation";
|
||||
import type { AppServerStartStructuredTurnOptions } from "../../src/app-server/services/turns";
|
||||
import type { ServerInitialization } from "../../src/domain/server/initialization";
|
||||
import {
|
||||
findThreadTitleContext,
|
||||
THREAD_TITLE_MAX_CHARS,
|
||||
|
|
@ -19,14 +12,6 @@ import {
|
|||
threadTitleFromGeneratedText,
|
||||
threadTitlePrompt,
|
||||
} from "../../src/domain/threads/title-generation-model";
|
||||
import type { ModelListResponse } from "../../src/generated/app-server/v2/ModelListResponse";
|
||||
import type { ThreadStartResponse } from "../../src/generated/app-server/v2/ThreadStartResponse";
|
||||
|
||||
type InitializeResponse = ServerInitialization;
|
||||
type Turn = TurnRecord;
|
||||
interface TurnStartResponse {
|
||||
turn: TurnRecord;
|
||||
}
|
||||
|
||||
describe("thread title", () => {
|
||||
it("builds title context from a turn transcript summary", () => {
|
||||
|
|
@ -76,27 +61,38 @@ describe("thread title", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("parses structured title responses", async () => {
|
||||
const { clientFactory } = fakeThreadTitleClientFactory((fake) => {
|
||||
fake.startStructuredTurnImpl = async () => ({
|
||||
turn: turn(
|
||||
[
|
||||
{
|
||||
type: "agentMessage",
|
||||
id: "a1",
|
||||
text: '```json\n{"title":"Codex Panelの自動命名"}\n```',
|
||||
phase: "final_answer",
|
||||
memoryCitation: null,
|
||||
},
|
||||
],
|
||||
{ status: "completed" },
|
||||
),
|
||||
});
|
||||
});
|
||||
it("runs a structured title request and parses its assistant transcript", async () => {
|
||||
const runner = vi.fn<EphemeralStructuredTurnRunner>(async () =>
|
||||
turn([
|
||||
{
|
||||
type: "agentMessage",
|
||||
id: "a1",
|
||||
text: '```json\n{"title":"Codex Panelの自動命名"}\n```',
|
||||
phase: "final_answer",
|
||||
memoryCitation: null,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
await expect(generateThreadTitleWithCodex("/bin/codex", "/vault", titleContext(), runtimeSettings(), clientFactory)).resolves.toBe(
|
||||
const signal = new AbortController().signal;
|
||||
await expect(generateThreadTitleWithCodex("/bin/codex", "/vault", titleContext(), runtimeSettings(), { runner, signal })).resolves.toBe(
|
||||
"Codex Panelの自動命名",
|
||||
);
|
||||
expect(runner).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
codexPath: "/bin/codex",
|
||||
cwd: "/vault",
|
||||
serviceName: "codex-panel-naming",
|
||||
developerInstructions: expect.stringContaining("Return only a JSON object"),
|
||||
prompt: threadTitlePrompt(titleContext()),
|
||||
outputSchema: expect.objectContaining({ required: ["title"], additionalProperties: false }),
|
||||
timeoutMs: 60_000,
|
||||
serverRequests: { kind: "reject", message: "Thread title generation does not handle server requests." },
|
||||
abortMessage: "Thread title generation cancelled.",
|
||||
runtimeSettings: { model: null, effort: null },
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes generated titles", () => {
|
||||
|
|
@ -126,80 +122,32 @@ describe("thread title", () => {
|
|||
});
|
||||
|
||||
it("uses explicit title runtime overrides", async () => {
|
||||
const { clientFactory, client } = fakeThreadTitleClientFactory((fake) => {
|
||||
fake.startStructuredTurnImpl = async () => ({ turn: turn([], { status: "completed" }) });
|
||||
});
|
||||
const runner = vi.fn<EphemeralStructuredTurnRunner>(async () => turn([]));
|
||||
|
||||
await generateThreadTitleWithCodex(
|
||||
"/bin/codex",
|
||||
"/vault",
|
||||
titleContext(),
|
||||
{ threadNamingModel: "gpt-5.4-mini", threadNamingEffort: "minimal" },
|
||||
clientFactory,
|
||||
{ runner },
|
||||
);
|
||||
|
||||
expect(client.current?.startStructuredTurnOptions?.runtime).toEqual({
|
||||
model: "gpt-5.4-mini",
|
||||
effort: "minimal",
|
||||
});
|
||||
expect(runner).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runtimeSettings: {
|
||||
model: "gpt-5.4-mini",
|
||||
effort: "minimal",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits title runtime overrides that are set to Codex default", async () => {
|
||||
const { clientFactory, client } = fakeThreadTitleClientFactory((fake) => {
|
||||
fake.startStructuredTurnImpl = async () => ({ turn: turn([], { status: "completed" }) });
|
||||
});
|
||||
const runner = vi.fn<EphemeralStructuredTurnRunner>(async () => turn([]));
|
||||
|
||||
await generateThreadTitleWithCodex("/bin/codex", "/vault", titleContext(), runtimeSettings(), clientFactory);
|
||||
await generateThreadTitleWithCodex("/bin/codex", "/vault", titleContext(), runtimeSettings(), { runner });
|
||||
|
||||
expect(client.current?.startStructuredTurnOptions?.runtime).toEqual({});
|
||||
});
|
||||
|
||||
it("omits an explicit title effort when the selected model does not support it", async () => {
|
||||
const { clientFactory, client } = fakeThreadTitleClientFactory((fake) => {
|
||||
fake.modelList = [appServerModel("gpt-5.4-mini", ["low", "medium", "high", "xhigh"])];
|
||||
fake.startStructuredTurnImpl = async () => ({ turn: turn([], { status: "completed" }) });
|
||||
});
|
||||
|
||||
await generateThreadTitleWithCodex(
|
||||
"/bin/codex",
|
||||
"/vault",
|
||||
titleContext(),
|
||||
{ threadNamingModel: "gpt-5.4-mini", threadNamingEffort: "minimal" },
|
||||
clientFactory,
|
||||
);
|
||||
|
||||
expect(client.current?.startStructuredTurnOptions?.runtime).toEqual({ model: "gpt-5.4-mini" });
|
||||
});
|
||||
|
||||
it("keeps an explicit title effort when the selected model supports it", async () => {
|
||||
const { clientFactory, client } = fakeThreadTitleClientFactory((fake) => {
|
||||
fake.modelList = [appServerModel("gpt-5.4-mini", ["low", "medium", "high", "xhigh"])];
|
||||
fake.startStructuredTurnImpl = async () => ({ turn: turn([], { status: "completed" }) });
|
||||
});
|
||||
|
||||
await generateThreadTitleWithCodex(
|
||||
"/bin/codex",
|
||||
"/vault",
|
||||
titleContext(),
|
||||
{ threadNamingModel: "gpt-5.4-mini", threadNamingEffort: "low" },
|
||||
clientFactory,
|
||||
);
|
||||
|
||||
expect(client.current?.startStructuredTurnOptions?.runtime).toEqual({ model: "gpt-5.4-mini", effort: "low" });
|
||||
});
|
||||
|
||||
it("keeps pre-acknowledgement completion when title notifications arrive before turn/start resolves", async () => {
|
||||
const { clientFactory } = fakeThreadTitleClientFactory((fake) => {
|
||||
fake.startStructuredTurnImpl = async () => {
|
||||
fake.emit(completedItemNotification("thread", "turn", assistantMessage("answer", '{"title":"Early title"}')));
|
||||
fake.emit(turnCompletedNotification("thread", turn([], { id: "turn", status: "completed" })));
|
||||
return { turn: turn([], { id: "turn", status: "inProgress" }) };
|
||||
};
|
||||
});
|
||||
|
||||
await expect(generateThreadTitleWithCodex("/bin/codex", "/vault", titleContext(), runtimeSettings(), clientFactory)).resolves.toBe(
|
||||
"Early title",
|
||||
);
|
||||
expect(runner).toHaveBeenCalledWith(expect.objectContaining({ runtimeSettings: { model: null, effort: null } }));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -217,131 +165,7 @@ function runtimeSettings() {
|
|||
};
|
||||
}
|
||||
|
||||
function fakeThreadTitleClientFactory(configure?: (client: FakeThreadTitleClient) => void): {
|
||||
clientFactory: EphemeralStructuredTurnClientFactory;
|
||||
client: { current: FakeThreadTitleClient | null };
|
||||
} {
|
||||
const client: { current: FakeThreadTitleClient | null } = { current: null };
|
||||
return {
|
||||
client,
|
||||
clientFactory: (_codexPath, _cwd, handlers) => {
|
||||
client.current = new FakeThreadTitleClient(handlers);
|
||||
configure?.(client.current);
|
||||
return client.current;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class FakeThreadTitleClient implements EphemeralStructuredTurnClient {
|
||||
startStructuredTurnImpl: (() => Promise<TurnStartResponse>) | null = null;
|
||||
startStructuredTurnOptions: AppServerStartStructuredTurnOptions | null = null;
|
||||
modelList: ModelListResponse["data"] = [];
|
||||
|
||||
constructor(private readonly handlers: AppServerClientHandlers) {}
|
||||
|
||||
async connect(): Promise<InitializeResponse> {
|
||||
return { codexHome: "/tmp/codex" } as InitializeResponse;
|
||||
}
|
||||
|
||||
disconnect(): void {}
|
||||
|
||||
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 { data: this.modelList, nextCursor: null } 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">);
|
||||
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: threadFixture(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 threadFixture(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 {
|
||||
function turn(items: TurnRecord["items"], overrides: Partial<TurnRecord> = {}): TurnRecord {
|
||||
return {
|
||||
id: "turn",
|
||||
items,
|
||||
|
|
@ -354,48 +178,3 @@ function turn(items: Turn["items"], overrides: Partial<Turn> = {}): Turn {
|
|||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function assistantMessage(id: string, text: string): TurnItem {
|
||||
return { type: "agentMessage", id, text, phase: "final_answer", memoryCitation: null };
|
||||
}
|
||||
|
||||
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 appServerModel(name: string, efforts: string[]): ModelListResponse["data"][number] {
|
||||
return {
|
||||
id: name,
|
||||
model: name,
|
||||
upgrade: null,
|
||||
upgradeInfo: null,
|
||||
availabilityNux: null,
|
||||
displayName: name,
|
||||
description: "",
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: efforts.map((reasoningEffort) => ({
|
||||
reasoningEffort: reasoningEffort as never,
|
||||
label: reasoningEffort,
|
||||
description: "",
|
||||
})),
|
||||
defaultReasoningEffort: (efforts[0] ?? "low") as never,
|
||||
inputModalities: ["text"],
|
||||
supportsPersonality: false,
|
||||
additionalSpeedTiers: [],
|
||||
serviceTiers: [],
|
||||
defaultServiceTier: null,
|
||||
isDefault: false,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,16 +2,9 @@
|
|||
|
||||
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 type { TurnRecord } from "../../../src/app-server/protocol/turn";
|
||||
import type { EphemeralStructuredTurnRunner } from "../../../src/app-server/services/ephemeral-structured-turn";
|
||||
import { createAppServerSelectionRewriteTransport } from "../../../src/features/selection-rewrite/app-server-transport";
|
||||
import { buildSelectionUnifiedDiff } from "../../../src/features/selection-rewrite/diff";
|
||||
import { canApplySelectionRewrite, type SelectionRewriteState } from "../../../src/features/selection-rewrite/model";
|
||||
import { SelectionRewriteOutputError, selectionRewriteOutputParseResultFromText } from "../../../src/features/selection-rewrite/output";
|
||||
|
|
@ -19,19 +12,10 @@ import { SelectionRewritePopover } from "../../../src/features/selection-rewrite
|
|||
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 };
|
||||
type SelectionRewriteTestRunOptions = SelectionRewriteTransportRequest & { runner: EphemeralStructuredTurnRunner };
|
||||
|
||||
const selectionRewriteGenerate = vi.fn();
|
||||
|
||||
|
|
@ -209,61 +193,56 @@ describe("selection rewrite positioning", () => {
|
|||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
describe("selection rewrite app-server transport", () => {
|
||||
it("maps the rewrite request, progress, and structured output through the runner", async () => {
|
||||
const activities: string[] = [];
|
||||
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" }) };
|
||||
};
|
||||
const signal = new AbortController().signal;
|
||||
const runner = vi.fn<EphemeralStructuredTurnRunner>(async (options) => {
|
||||
options.onProgress?.({ type: "reasoning-activity" });
|
||||
options.onProgress?.({ type: "agent-message-delta", delta: '{"replacementText":"stream' });
|
||||
options.onProgress?.({ type: "agent-message-delta", delta: 'ed"}' });
|
||||
return turn([agentMessage("answer", '{"replacementText":"final"}')]);
|
||||
});
|
||||
|
||||
await expect(
|
||||
runSelectionRewrite({
|
||||
...runOptions(clientFactory),
|
||||
...runOptions(runner),
|
||||
signal,
|
||||
runtimeSettings: { rewriteSelectionModel: "gpt-5.4-mini", rewriteSelectionEffort: "minimal" },
|
||||
onActivity: (activity) => activities.push(activity),
|
||||
onPreview: (text) => previews.push(text),
|
||||
}),
|
||||
).resolves.toEqual({ replacementText: "final" });
|
||||
|
||||
expect(client.current?.startStructuredTurnOptions?.runtime).toEqual({ model: "gpt-5.4-mini" });
|
||||
expect(activities).toEqual(["reasoning", "writing", "writing"]);
|
||||
expect(previews).toEqual(['{"replacementText":"stream', '{"replacementText":"streamed"}']);
|
||||
expect(runner).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
codexPath: "/bin/codex",
|
||||
cwd: "/vault",
|
||||
serviceName: "codex-panel-rewrite-selection",
|
||||
developerInstructions: expect.stringContaining("The only editable target is the selected text"),
|
||||
prompt: "Rewrite this.",
|
||||
outputSchema: {
|
||||
type: "object",
|
||||
properties: { replacementText: { type: "string" } },
|
||||
required: ["replacementText"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
timeoutMs: 120_000,
|
||||
serverRequests: { kind: "reject", message: "Selection rewrite does not handle server requests." },
|
||||
abortMessage: "Selection rewrite cancelled.",
|
||||
signal,
|
||||
runtimeSettings: { model: "gpt-5.4-mini", effort: "minimal" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports invalid structured output with the raw assistant text", async () => {
|
||||
const runner = vi.fn<EphemeralStructuredTurnRunner>(async () => turn([agentMessage("answer", "invalid raw output")]));
|
||||
|
||||
await expect(runSelectionRewrite(runOptions(runner))).rejects.toMatchObject({ rawText: "invalid raw output" });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -736,160 +715,26 @@ async function flushPromises(): Promise<void> {
|
|||
}
|
||||
|
||||
function runSelectionRewrite(options: SelectionRewriteTestRunOptions) {
|
||||
const { clientFactory, ...request } = options;
|
||||
const { runner, ...request } = options;
|
||||
return createAppServerSelectionRewriteTransport({
|
||||
codexPath: () => "/bin/codex",
|
||||
cwd: "/vault",
|
||||
clientFactory,
|
||||
runner,
|
||||
}).generate(request);
|
||||
}
|
||||
|
||||
function runOptions(clientFactory: SelectionRewriteClientFactory): SelectionRewriteTestRunOptions {
|
||||
function runOptions(runner: EphemeralStructuredTurnRunner): SelectionRewriteTestRunOptions {
|
||||
return {
|
||||
prompt: "Rewrite this.",
|
||||
runtimeSettings: { rewriteSelectionModel: null, rewriteSelectionEffort: null },
|
||||
onActivity: () => undefined,
|
||||
onPreview: () => undefined,
|
||||
signal: new AbortController().signal,
|
||||
clientFactory,
|
||||
runner,
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
function turn(items: TurnRecord["items"], overrides: Partial<TurnRecord> = {}): TurnRecord {
|
||||
return {
|
||||
id: "turn",
|
||||
items,
|
||||
|
|
@ -903,45 +748,6 @@ function turn(items: Turn["items"], overrides: Partial<Turn> = {}): Turn {
|
|||
};
|
||||
}
|
||||
|
||||
function agentMessage(id: string, text: string): TurnItem {
|
||||
function agentMessage(id: string, text: string): Extract<TurnRecord["items"][number], { type: "agentMessage" }> {
|
||||
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"],
|
||||
serviceTiers: [],
|
||||
defaultServiceTier: null,
|
||||
isDefault: false,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue