Tighten structured ephemeral turn adapter

This commit is contained in:
murashit 2026-06-09 20:26:31 +09:00
parent bcb3569734
commit 3f9a54af38
9 changed files with 325 additions and 47 deletions

View file

@ -79,6 +79,7 @@ const chatImperativeDomBridgeFiles = [
"src/features/chat/ui/turn-diff.tsx",
];
const nonChatImperativeDomBridgeFiles = [
"src/app-server/structured-ephemeral-turn.ts",
"src/features/selection-rewrite/popover.tsx",
"src/features/selection-rewrite/runner.ts",
"src/features/thread-picker/modal.ts",

View file

@ -0,0 +1,23 @@
import { AppServerClient } from "./client";
import { panelModelOptionsFromAppServerModels } from "./catalog-model";
import type { PanelModelOption } from "../domain/catalog/metadata";
export async function loadPanelModelOptions(codexPath: string, cwd: string, includeHidden = false): Promise<PanelModelOption[]> {
let client!: AppServerClient;
client = new AppServerClient(codexPath, cwd, {
onNotification: () => undefined,
onServerRequest: (request) => {
client.rejectServerRequest(request.id, -32601, "Model option loading does not handle server requests.");
},
onLog: () => undefined,
onExit: () => undefined,
});
try {
await client.connect();
const response = await client.listModels(includeHidden);
return panelModelOptionsFromAppServerModels(response.data);
} finally {
client.disconnect();
}
}

View file

@ -9,7 +9,6 @@ import type { RequestId } from "../generated/app-server/RequestId";
import type { ReasoningEffort } from "../generated/app-server/ReasoningEffort";
import type { ServerNotification } from "../generated/app-server/ServerNotification";
import type { JsonValue } from "../generated/app-server/serde_json/JsonValue";
import type { ModelListResponse } from "../generated/app-server/v2/ModelListResponse";
import type { ThreadItem } from "../generated/app-server/v2/ThreadItem";
import type { ThreadStartResponse } from "../generated/app-server/v2/ThreadStartResponse";
import type { Turn } from "../generated/app-server/v2/Turn";
@ -22,12 +21,11 @@ interface StructuredTurnRuntimeOverride {
effort?: ReasoningEffort;
}
type StructuredEphemeralTurnEvent = { type: "agent-message-delta"; delta: string } | { type: "reasoning-activity" };
type StructuredTurnProgressEvent = { type: "agent-message-delta"; delta: string } | { type: "reasoning-activity" };
export interface StructuredEphemeralTurnClient {
connect(): Promise<InitializeResponse>;
disconnect(): void;
listModels(includeHidden?: boolean): Promise<ModelListResponse>;
rejectServerRequest(requestId: RequestId, code: number, message: string): void;
startEphemeralThread(cwd: string, serviceName: string, developerInstructions: string): Promise<ThreadStartResponse>;
startStructuredTurn(
@ -58,10 +56,9 @@ export interface RunStructuredEphemeralTurnOptions {
exitedMessage: string;
timedOutMessage: string;
abortMessage?: string;
runtime?: StructuredTurnRuntimeOverride;
runtime?: StructuredTurnRuntimeOverride | undefined;
signal?: AbortSignal | undefined;
onEvent?: (event: StructuredEphemeralTurnEvent) => void;
prepare?: ((client: StructuredEphemeralTurnClient) => Promise<StructuredTurnRuntimeOverride>) | undefined;
onProgress?: (event: StructuredTurnProgressEvent) => void;
clientFactory?: StructuredEphemeralTurnClientFactory | undefined;
}
@ -83,7 +80,7 @@ export async function runStructuredEphemeralTurn(options: RunStructuredEphemeral
handleNotification = (notification): void => {
const result = transitionStructuredEphemeralTurnNotification(state, notification);
state = result.state;
if (result.event) options.onEvent?.(result.event);
if (result.progress) options.onProgress?.(result.progress);
if (result.completedTurn) resolve(result.completedTurn);
};
});
@ -107,9 +104,7 @@ export async function runStructuredEphemeralTurn(options: RunStructuredEphemeral
try {
await abortable(client.connect(), options.signal, options.abortMessage);
const runtime = options.prepare
? await abortable(options.prepare(client), options.signal, options.abortMessage)
: (options.runtime ?? {});
const runtime = options.runtime ?? {};
const threadResponse = await abortable(
client.startEphemeralThread(options.cwd, options.serviceName, options.developerInstructions),
options.signal,
@ -156,7 +151,7 @@ interface StructuredEphemeralTurnState {
interface StructuredEphemeralTurnNotificationResult {
state: StructuredEphemeralTurnState;
event?: StructuredEphemeralTurnEvent;
progress?: StructuredTurnProgressEvent;
completedTurn?: Turn;
}
@ -174,7 +169,7 @@ function transitionStructuredEphemeralTurnNotification(
if (state.lifecycle.kind === "completed") return { state };
if (notification.method === "item/agentMessage/delta") {
if (!structuredTurnRunMatches(state.lifecycle, notification.params.threadId, notification.params.turnId)) return { state };
return { state, event: { type: "agent-message-delta", delta: notification.params.delta } };
return { state, progress: { type: "agent-message-delta", delta: notification.params.delta } };
}
if (
notification.method === "item/reasoning/summaryTextDelta" ||
@ -182,7 +177,7 @@ function transitionStructuredEphemeralTurnNotification(
notification.method === "item/reasoning/summaryPartAdded"
) {
if (!structuredTurnRunMatches(state.lifecycle, notification.params.threadId, notification.params.turnId)) return { state };
return { state, event: { type: "reasoning-activity" } };
return { state, progress: { type: "reasoning-activity" } };
}
if (notification.method === "item/completed") {
if (!structuredTurnRunMatches(state.lifecycle, notification.params.threadId, notification.params.turnId)) return { state };
@ -218,17 +213,12 @@ function abortable<T>(promise: Promise<T>, signal: AbortSignal | undefined, mess
if (!signal) return promise;
throwIfAborted(signal, message);
return new Promise<T>((resolve, reject) => {
const previousAbortHandler = signal.onabort;
const rejectAbort = (): void => {
const onAbort = (): void => {
reject(structuredEphemeralTurnAbortError(message));
};
const abortHandler = (event: Event): void => {
if (typeof previousAbortHandler === "function") previousAbortHandler.call(signal, event);
rejectAbort();
};
signal.onabort = abortHandler;
signal.addEventListener("abort", onAbort, { once: true });
promise.then(resolve, reject).finally(() => {
if (signal.onabort === abortHandler) signal.onabort = previousAbortHandler;
signal.removeEventListener("abort", onAbort);
});
});
}

View file

@ -5,7 +5,7 @@ import {
type StructuredTurnOutputSchema,
} from "../../app-server/structured-ephemeral-turn";
import type { ReasoningEffort } from "../../domain/catalog/metadata";
import { panelModelOptionsFromAppServerModels } from "../../app-server/catalog-model";
import { loadPanelModelOptions } from "../../app-server/model-options";
import type { PanelModelOption } from "../../domain/catalog/metadata";
import { runtimeOverride, validatedRuntimeOverrideForModelOptions } from "../../domain/catalog/runtime-overrides";
import type { SelectionRewriteRuntimeSettings } from "./model";
@ -44,6 +44,9 @@ export type SelectionRewriteClientFactory = StructuredEphemeralTurnClientFactory
export async function runSelectionRewrite(options: RunSelectionRewriteOptions): Promise<SelectionRewriteOutput> {
let preview = "";
const runtimeSettings = options.runtimeSettings;
const runtime = runtimeSettings
? await selectionRewriteRuntimeOverrideForCodex(options.codexPath, options.cwd, runtimeSettings)
: undefined;
const turn = await runStructuredEphemeralTurn({
codexPath: options.codexPath,
cwd: options.cwd,
@ -57,9 +60,9 @@ export async function runSelectionRewrite(options: RunSelectionRewriteOptions):
timedOutMessage: "Timed out while rewriting the selection.",
abortMessage: "Selection rewrite cancelled.",
signal: options.signal,
prepare: runtimeSettings ? (client) => selectionRewriteRuntimeOverrideForClient(client, runtimeSettings) : undefined,
runtime,
clientFactory: options.clientFactory,
onEvent: (event) => {
onProgress: (event) => {
if (event.type === "reasoning-activity") {
options.onActivity?.("reasoning");
return;
@ -93,14 +96,15 @@ export function validatedSelectionRewriteRuntimeOverride(
);
}
async function selectionRewriteRuntimeOverrideForClient(
client: SelectionRewriteClient,
async function selectionRewriteRuntimeOverrideForCodex(
codexPath: string,
cwd: string,
settings: SelectionRewriteRuntimeSettings,
): Promise<SelectionRewriteRuntimeOverride> {
const runtime = selectionRewriteRuntimeOverride(settings);
if (!runtime.model || !runtime.effort) return runtime;
try {
return validatedSelectionRewriteRuntimeOverride(settings, panelModelOptionsFromAppServerModels((await client.listModels(false)).data));
return validatedSelectionRewriteRuntimeOverride(settings, await loadPanelModelOptions(codexPath, cwd, false));
} catch {
return runtime;
}

View file

@ -4,7 +4,6 @@ import { withShortLivedAppServerClient } from "../app-server/short-lived-client"
export interface ShortLivedFallbackAppServerClientOptions {
codexPath: string;
cwd: string;
currentClient?: () => AppServerClient | null;
unhandledServerRequestMessage: string;
}
@ -12,9 +11,6 @@ export async function withShortLivedFallbackAppServerClient<T>(
options: ShortLivedFallbackAppServerClientOptions,
operation: (client: AppServerClient) => Promise<T>,
): Promise<T> {
const currentClient = options.currentClient?.();
if (currentClient?.isConnected()) return operation(currentClient);
return withShortLivedAppServerClient(options.codexPath, options.cwd, operation, {
unhandledServerRequestMessage: options.unhandledServerRequestMessage,
});

View file

@ -1,10 +1,10 @@
import { panelModelOptionsFromAppServerModels } from "../app-server/catalog-model";
import {
runStructuredEphemeralTurn,
type StructuredEphemeralTurnClient,
type StructuredEphemeralTurnClientFactory,
type StructuredTurnOutputSchema,
} from "../app-server/structured-ephemeral-turn";
import { loadPanelModelOptions } from "../app-server/model-options";
import type { PanelModelOption, ReasoningEffort } from "../domain/catalog/metadata";
import { runtimeOverride, validatedRuntimeOverrideForModelOptions } from "../domain/catalog/runtime-overrides";
import { namingPrompt, titleFromNamingTurn, type ThreadNamingContext } from "../domain/threads/naming";
@ -48,6 +48,7 @@ export async function generateThreadTitleWithCodex(
runtimeSettings: ThreadNamingRuntimeSettings,
clientFactory?: ThreadNamingClientFactory,
): Promise<string | null> {
const runtime = await threadNamingRuntimeOverrideForCodex(codexPath, cwd, runtimeSettings);
const turn = await runStructuredEphemeralTurn({
codexPath,
cwd,
@ -59,7 +60,7 @@ export async function generateThreadTitleWithCodex(
unhandledServerRequestMessage: "Thread title generation does not handle server requests.",
exitedMessage: "Codex title generation app-server exited.",
timedOutMessage: "Timed out while generating a Codex thread title.",
prepare: (client) => threadNamingRuntimeOverrideForClient(client, runtimeSettings),
runtime,
clientFactory,
});
return titleFromNamingTurn(turn);
@ -81,15 +82,15 @@ export function validatedThreadNamingRuntimeOverride(
return validatedRuntimeOverrideForModelOptions({ model: settings.threadNamingModel, effort: settings.threadNamingEffort }, models);
}
async function threadNamingRuntimeOverrideForClient(
client: ThreadNamingClient,
async function threadNamingRuntimeOverrideForCodex(
codexPath: string,
cwd: string,
settings: ThreadNamingRuntimeSettings,
): Promise<ThreadNamingRuntimeOverride> {
const runtime = threadNamingRuntimeOverride(settings);
if (!runtime.model || !runtime.effort) return runtime;
try {
const response = await client.listModels(false);
return validatedThreadNamingRuntimeOverride(settings, panelModelOptionsFromAppServerModels(response.data));
return validatedThreadNamingRuntimeOverride(settings, await loadPanelModelOptions(codexPath, cwd, false));
} catch {
return runtime;
}

View file

@ -0,0 +1,273 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import {
runStructuredEphemeralTurn,
type StructuredEphemeralTurnClient,
type StructuredEphemeralTurnClientFactory,
} from "../../src/app-server/structured-ephemeral-turn";
import type { AppServerClientHandlers } from "../../src/app-server/client";
import type { InitializeResponse } from "../../src/generated/app-server/InitializeResponse";
import type { JsonValue } from "../../src/generated/app-server/serde_json/JsonValue";
import type { RequestId } from "../../src/generated/app-server/RequestId";
import type { ServerNotification } from "../../src/generated/app-server/ServerNotification";
import type { ServerRequest } from "../../src/generated/app-server/ServerRequest";
import type { ReasoningEffort } from "../../src/generated/app-server/ReasoningEffort";
import type { Thread } from "../../src/generated/app-server/v2/Thread";
import type { ThreadItem } from "../../src/generated/app-server/v2/ThreadItem";
import type { ThreadStartResponse } from "../../src/generated/app-server/v2/ThreadStartResponse";
import type { Turn } from "../../src/generated/app-server/v2/Turn";
import type { TurnStartResponse } from "../../src/generated/app-server/v2/TurnStartResponse";
describe("runStructuredEphemeralTurn", () => {
it("fills completed turn items from item completion notifications", async () => {
const { clientFactory } = fakeStructuredTurnClientFactory((fake) => {
fake.startStructuredTurnImpl = async () => {
fake.emit(completedItemNotification("thread", "turn", agentMessage("answer", '{"ok":true}')));
fake.emit(turnCompletedNotification("thread", turn([], { id: "turn", status: "completed" })));
return { turn: turn([], { id: "turn", status: "inProgress" }) };
};
});
const result = await runStructuredEphemeralTurn(runOptions(clientFactory));
expect(result.items).toEqual([agentMessage("answer", '{"ok":true}')]);
expect(result.itemsView).toBe("full");
});
it("reports matched structured turn progress without exposing raw notifications", async () => {
const progress: unknown[] = [];
const { clientFactory, client } = fakeStructuredTurnClientFactory();
const running = runStructuredEphemeralTurn({
...runOptions(clientFactory),
onProgress: (event) => {
progress.push(event);
},
});
await expectPresent(client.current).structuredTurnStarted;
const fake = expectPresent(client.current);
fake.emit(agentDeltaNotification("other-thread", "turn", "ignored"));
fake.emit(reasoningNotification("thread", "turn"));
fake.emit(agentDeltaNotification("thread", "turn", "draft"));
fake.emit(completedItemNotification("thread", "turn", agentMessage("answer", '{"ok":true}')));
fake.emit(turnCompletedNotification("thread", turn([], { id: "turn", status: "completed" })));
await running;
expect(progress).toEqual([{ type: "reasoning-activity" }, { type: "agent-message-delta", delta: "draft" }]);
});
it("cleans up abort listeners after an operation settles", async () => {
const controller = new AbortController();
const add = vi.spyOn(controller.signal, "addEventListener");
const remove = vi.spyOn(controller.signal, "removeEventListener");
const { clientFactory } = fakeStructuredTurnClientFactory((fake) => {
fake.startStructuredTurnImpl = async () => ({ turn: turn([agentMessage("answer", '{"ok":true}')]) });
});
await runStructuredEphemeralTurn({
...runOptions(clientFactory),
signal: controller.signal,
});
expect(add).toHaveBeenCalledWith("abort", expect.any(Function), { once: true });
expect(remove).toHaveBeenCalledTimes(add.mock.calls.length);
});
it("rejects server requests with the configured message", async () => {
const { clientFactory, client } = fakeStructuredTurnClientFactory((fake) => {
fake.connectImpl = async () => {
fake.request(serverRequest(123));
return { codexHome: "/tmp/codex" } as InitializeResponse;
};
fake.startStructuredTurnImpl = async () => ({ turn: turn([agentMessage("answer", '{"ok":true}')]) });
});
await runStructuredEphemeralTurn(runOptions(clientFactory));
expect(expectPresent(client.current).rejectServerRequest).toHaveBeenCalledWith(
123,
-32601,
"Structured test does not handle server requests.",
);
});
});
function runOptions(clientFactory: StructuredEphemeralTurnClientFactory): Parameters<typeof runStructuredEphemeralTurn>[0] {
return {
codexPath: "/bin/codex",
cwd: "/vault",
serviceName: "structured-test",
developerInstructions: "Return JSON.",
prompt: "Run.",
outputSchema: { type: "object" },
timeoutMs: 10_000,
unhandledServerRequestMessage: "Structured test does not handle server requests.",
exitedMessage: "Structured test app-server exited.",
timedOutMessage: "Structured test timed out.",
clientFactory,
};
}
function fakeStructuredTurnClientFactory(configure?: (client: FakeStructuredTurnClient) => void): {
clientFactory: StructuredEphemeralTurnClientFactory;
client: { current: FakeStructuredTurnClient | null };
} {
const client: { current: FakeStructuredTurnClient | null } = { current: null };
return {
client,
clientFactory: (_codexPath, _cwd, handlers) => {
client.current = new FakeStructuredTurnClient(handlers);
configure?.(client.current);
return client.current;
},
};
}
class FakeStructuredTurnClient implements StructuredEphemeralTurnClient {
connectImpl: (() => Promise<InitializeResponse>) | null = null;
startStructuredTurnImpl: (() => Promise<TurnStartResponse>) | null = null;
readonly rejectServerRequest = vi.fn();
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 this.connectImpl ? this.connectImpl() : ({ codexHome: "/tmp/codex" } as InitializeResponse);
}
disconnect(): void {
return undefined;
}
async startEphemeralThread(): Promise<ThreadStartResponse> {
return threadStartResponse("thread");
}
async startStructuredTurn(
_threadId: string,
_cwd: string,
_text: string,
_outputSchema: JsonValue,
_model?: string,
_effort?: ReasoningEffort,
): Promise<TurnStartResponse> {
this.resolveStructuredTurnStarted();
return this.startStructuredTurnImpl ? this.startStructuredTurnImpl() : { turn: turn([], { id: "turn", status: "inProgress" }) };
}
emit(notification: ServerNotification): void {
this.handlers.onNotification(notification);
}
request(request: ServerRequest): void {
this.handlers.onServerRequest(request);
}
}
function threadStartResponse(threadId: string): ThreadStartResponse {
return {
thread: thread(threadId),
model: "gpt-5.1",
modelProvider: "openai",
serviceTier: null,
cwd: "/vault",
runtimeWorkspaceRoots: [],
instructionSources: [],
approvalPolicy: "never",
approvalsReviewer: "auto_review",
sandbox: { type: "readOnly", networkAccess: false },
activePermissionProfile: null,
reasoningEffort: null,
};
}
function thread(id: string): Thread {
return {
id,
sessionId: "session",
forkedFromId: null,
parentThreadId: null,
preview: "",
ephemeral: true,
modelProvider: "openai",
createdAt: 1,
updatedAt: 1,
status: { type: "idle" },
path: null,
cwd: "/vault",
cliVersion: "0.0.0",
source: "appServer",
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): ThreadItem {
return { type: "agentMessage", id, text, phase: "final_answer", memoryCitation: null };
}
function completedItemNotification(threadId: string, turnId: string, item: ThreadItem): ServerNotification {
return {
method: "item/completed",
params: { threadId, turnId, item, completedAtMs: 1 },
};
}
function turnCompletedNotification(threadId: string, completedTurn: Turn): ServerNotification {
return {
method: "turn/completed",
params: { threadId, turn: completedTurn },
};
}
function agentDeltaNotification(threadId: string, turnId: string, delta: string): ServerNotification {
return {
method: "item/agentMessage/delta",
params: { threadId, turnId, itemId: "agent", delta },
};
}
function reasoningNotification(threadId: string, turnId: string): ServerNotification {
return {
method: "item/reasoning/textDelta",
params: { threadId, turnId, itemId: "reasoning", contentIndex: 0, delta: "thinking" },
};
}
function serverRequest(id: RequestId): ServerRequest {
return {
id,
method: "item/tool/requestUserInput",
params: {},
} as ServerRequest;
}
function expectPresent<T>(value: T | null | undefined): T {
if (value == null) throw new Error("Expected value to be present.");
return value;
}

View file

@ -23,7 +23,6 @@ import type { JsonValue } from "../../../../src/generated/app-server/serde_json/
import type { RequestId } from "../../../../src/generated/app-server/RequestId";
import type { ReasoningEffort } from "../../../../src/generated/app-server/ReasoningEffort";
import type { Model } from "../../../../src/generated/app-server/v2/Model";
import type { ModelListResponse } from "../../../../src/generated/app-server/v2/ModelListResponse";
import type { ServerNotification } from "../../../../src/generated/app-server/ServerNotification";
import type { Thread } from "../../../../src/generated/app-server/v2/Thread";
import type { ThreadItem } from "../../../../src/generated/app-server/v2/ThreadItem";
@ -330,10 +329,6 @@ class FakeThreadNamingClient implements ThreadNamingClient {
return undefined;
}
async listModels(): Promise<ModelListResponse> {
return { data: [], nextCursor: null };
}
rejectServerRequest(_requestId: RequestId, _code: number, _message: string): void {
return undefined;
}

View file

@ -28,7 +28,6 @@ import {
import type { AppServerClientHandlers } from "../../../src/app-server/client";
import type { PanelModelOption } from "../../../src/domain/catalog/metadata";
import type { InitializeResponse } from "../../../src/generated/app-server/InitializeResponse";
import type { ModelListResponse } from "../../../src/generated/app-server/v2/ModelListResponse";
import type { ServerNotification } from "../../../src/generated/app-server/ServerNotification";
import type { JsonValue } from "../../../src/generated/app-server/serde_json/JsonValue";
import type { RequestId } from "../../../src/generated/app-server/RequestId";
@ -665,10 +664,6 @@ class FakeSelectionRewriteClient implements SelectionRewriteClient {
this.disconnected = true;
}
async listModels(): Promise<ModelListResponse> {
return { data: [], nextCursor: null };
}
rejectServerRequest(_requestId: RequestId, _code: number, _message: string): void {
return undefined;
}