From 359bf1c21e61dee9a695460ef00f9007b53152db Mon Sep 17 00:00:00 2001 From: murashit Date: Thu, 16 Jul 2026 21:44:37 +0900 Subject: [PATCH] refactor(app-server): inject structured turn runner --- .../services/ephemeral-structured-turn.ts | 28 +- .../services/thread-title-generation.ts | 19 +- .../selection-rewrite/app-server-transport.ts | 63 ++-- .../ephemeral-structured-turn.test.ts | 94 +++--- tests/app-server/runtime-overrides.test.ts | 77 +++++ .../thread-title-generation.test.ts | 313 +++--------------- .../selection-rewrite.test.ts | 296 +++-------------- 7 files changed, 281 insertions(+), 609 deletions(-) create mode 100644 tests/app-server/runtime-overrides.test.ts diff --git a/src/app-server/services/ephemeral-structured-turn.ts b/src/app-server/services/ephemeral-structured-turn.ts index ea7c30d8..8634ffd6 100644 --- a/src/app-server/services/ephemeral-structured-turn.ts +++ b/src/app-server/services/ephemeral-structured-turn.ts @@ -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) | undefined; + runtimeSettings?: RuntimeOverrideSettings | undefined; signal?: AbortSignal | undefined; onProgress?: (event: StructuredTurnProgressEvent) => void; +} + +export type EphemeralStructuredTurnRunner = (options: RunEphemeralStructuredTurnOptions) => Promise; + +export interface EphemeralStructuredTurnDependencies { clientFactory?: EphemeralStructuredTurnClientFactory | undefined; timers?: EphemeralStructuredTurnTimers | undefined; } -export async function runEphemeralStructuredTurn(options: RunEphemeralStructuredTurnOptions): Promise { +export async function runEphemeralStructuredTurn( + options: RunEphemeralStructuredTurnOptions, + dependencies: EphemeralStructuredTurnDependencies = {}, +): Promise { 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 { - const turn = await runEphemeralStructuredTurn(options); +export async function runEphemeralStructuredTurnForLastAgentText( + options: RunEphemeralStructuredTurnOptions, + runner: EphemeralStructuredTurnRunner = runEphemeralStructuredTurn, +): Promise { + const turn = await runner(options); return lastAgentMessageTextFromTurnRecord(turn); } diff --git a/src/app-server/services/thread-title-generation.ts b/src/app-server/services/thread-title-generation.ts index 597ea91c..920b2105 100644 --- a/src/app-server/services/thread-title-generation.ts +++ b/src/app-server/services/thread-title-generation.ts @@ -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 { - 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; diff --git a/src/features/selection-rewrite/app-server-transport.ts b/src/features/selection-rewrite/app-server-transport.ts index f4705ce0..ab995ff9 100644 --- a/src/features/selection-rewrite/app-server-transport.ts +++ b/src/features/selection-rewrite/app-server-transport.ts @@ -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; diff --git a/tests/app-server/ephemeral-structured-turn.test.ts b/tests/app-server/ephemeral-structured-turn.test.ts index 056d9b8e..63db8702 100644 --- a/tests/app-server/ephemeral-structured-turn.test.ts +++ b/tests/app-server/ephemeral-structured-turn.test.ts @@ -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(() => 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> => - new Promise>(() => undefined), + configure: (fake: FakeStructuredTurnClient): void => { + fake.modelListImpl = () => new Promise(() => 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(() => 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[0] { +function runOptions(): Parameters[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) | null = null; + modelListImpl: (() => Promise) | null = null; startEphemeralThreadImpl: (() => Promise) | null = null; startStructuredTurnImpl: (() => Promise) | 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 diff --git a/tests/app-server/runtime-overrides.test.ts b/tests/app-server/runtime-overrides.test.ts new file mode 100644 index 00000000..dca5cfb2 --- /dev/null +++ b/tests/app-server/runtime-overrides.test.ts @@ -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, + }; +} diff --git a/tests/app-server/thread-title-generation.test.ts b/tests/app-server/thread-title-generation.test.ts index 08e10dd5..49aa68e7 100644 --- a/tests/app-server/thread-title-generation.test.ts +++ b/tests/app-server/thread-title-generation.test.ts @@ -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(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(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(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) | null = null; - startStructuredTurnOptions: AppServerStartStructuredTurnOptions | null = null; - modelList: ModelListResponse["data"] = []; - - constructor(private readonly handlers: AppServerClientHandlers) {} - - async connect(): Promise { - return { codexHome: "/tmp/codex" } as InitializeResponse; - } - - disconnect(): void {} - - rejectServerRequest(_requestId: RequestId, _code: number, _message: string): void {} - - async request( - method: M, - params: ClientRequestParams, - options: { timeoutMs?: number } = {}, - ): Promise { - 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 { +function turn(items: TurnRecord["items"], overrides: Partial = {}): TurnRecord { return { id: "turn", items, @@ -354,48 +178,3 @@ function turn(items: Turn["items"], overrides: Partial = {}): 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; - 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; - 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, - }; -} diff --git a/tests/features/selection-rewrite/selection-rewrite.test.ts b/tests/features/selection-rewrite/selection-rewrite.test.ts index d15730a6..1b507255 100644 --- a/tests/features/selection-rewrite/selection-rewrite.test.ts +++ b/tests/features/selection-rewrite/selection-rewrite.test.ts @@ -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; -type SelectionRewriteClient = ReturnType; -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(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(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 { } 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) | null = null; - readonly structuredTurnStarted: Promise; - private resolveStructuredTurnStarted!: () => void; - - constructor(private readonly handlers: AppServerClientHandlers) { - this.structuredTurnStarted = new Promise((resolve) => { - this.resolveStructuredTurnStarted = resolve; - }); - } - - async connect(): Promise { - return { codexHome: "/tmp/codex" } as InitializeResponse; - } - - disconnect(): void { - this.disconnected = true; - } - - rejectServerRequest(_requestId: RequestId, _code: number, _message: string): void {} - - async request( - method: M, - params: ClientRequestParams, - options: { timeoutMs?: number } = {}, - ): Promise { - 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 { +function turn(items: TurnRecord["items"], overrides: Partial = {}): TurnRecord { return { id: "turn", items, @@ -903,45 +748,6 @@ function turn(items: Turn["items"], overrides: Partial = {}): Turn { }; } -function agentMessage(id: string, text: string): TurnItem { +function agentMessage(id: string, text: string): Extract { 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; - 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; - 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, - }; -}