refactor(app-server): inject structured turn runner

This commit is contained in:
murashit 2026-07-16 21:44:37 +09:00
parent cb172edfb7
commit 359bf1c21e
7 changed files with 281 additions and 609 deletions

View file

@ -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);
}

View file

@ -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;

View file

@ -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;

View file

@ -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

View 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,
};
}

View file

@ -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,
};
}

View file

@ -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,
};
}