Share structured turn run lifecycle

This commit is contained in:
murashit 2026-05-28 21:34:27 +09:00
parent c1b82de870
commit ee372d0ba5
4 changed files with 109 additions and 50 deletions

View file

@ -0,0 +1,35 @@
export type StructuredTurnRunLifecycleState =
| { kind: "starting" }
| { kind: "thread-started"; threadId: string }
| { kind: "turn-started"; threadId: string; turnId: string }
| { kind: "completed" };
export type StructuredTurnRunLifecycleEvent =
| { type: "thread-started"; threadId: string }
| { type: "turn-started"; threadId: string; turnId: string }
| { type: "completed" };
export function createStructuredTurnRunLifecycle(): StructuredTurnRunLifecycleState {
return { kind: "starting" };
}
export function transitionStructuredTurnRunLifecycle(
state: StructuredTurnRunLifecycleState,
event: StructuredTurnRunLifecycleEvent,
): StructuredTurnRunLifecycleState {
if (state.kind === "completed") return state;
switch (event.type) {
case "thread-started":
return { kind: "thread-started", threadId: event.threadId };
case "turn-started":
return { kind: "turn-started", threadId: event.threadId, turnId: event.turnId };
case "completed":
return { kind: "completed" };
}
}
export function structuredTurnRunMatches(state: StructuredTurnRunLifecycleState, threadId: string, turnId: string): boolean {
if (state.kind === "thread-started") return state.threadId === threadId;
if (state.kind === "turn-started") return state.threadId === threadId && state.turnId === turnId;
return false;
}

View file

@ -12,6 +12,12 @@ import type { Turn } from "../generated/app-server/v2/Turn";
import type { TurnStartResponse } from "../generated/app-server/v2/TurnStartResponse";
import { namingPrompt, titleFromNamingTurn, type ThreadNamingContext } from "../domain/threads/naming";
import { runtimeOverride, validatedRuntimeOverride } from "../runtime/model";
import {
createStructuredTurnRunLifecycle,
structuredTurnRunMatches,
transitionStructuredTurnRunLifecycle,
type StructuredTurnRunLifecycleState,
} from "./structured-turn-run-lifecycle";
const NAMING_SERVICE_NAME = "codex-panel-naming";
const NAMING_TIMEOUT_MS = 60_000;
@ -60,12 +66,6 @@ export interface ThreadNamingClient {
export type ThreadNamingClientFactory = (codexPath: string, cwd: string, handlers: AppServerClientHandlers) => ThreadNamingClient;
type ThreadNamingRunLifecycleState =
| { kind: "starting" }
| { kind: "thread-started"; threadId: string }
| { kind: "turn-started"; threadId: string; turnId: string }
| { kind: "completed" };
export async function generateThreadTitleWithCodex(
codexPath: string,
cwd: string,
@ -73,7 +73,7 @@ export async function generateThreadTitleWithCodex(
runtimeSettings: ThreadNamingRuntimeSettings,
clientFactory: ThreadNamingClientFactory = (codexPath, cwd, handlers) => new AppServerClient(codexPath, cwd, handlers),
): Promise<string | null> {
let lifecycle: ThreadNamingRunLifecycleState = { kind: "starting" };
let lifecycle = createStructuredTurnRunLifecycle();
let timeout: number | undefined;
let rejectCompletedTurn: ((error: Error) => void) | null = null;
let handleNamingNotification: (notification: ServerNotification) => void = () => undefined;
@ -83,7 +83,7 @@ export async function generateThreadTitleWithCodex(
rejectCompletedTurn = reject;
timeout = window.setTimeout(() => {
if (lifecycle.kind === "completed") return;
lifecycle = { kind: "completed" };
lifecycle = transitionStructuredTurnRunLifecycle(lifecycle, { type: "completed" });
reject(new Error("Timed out while generating a Codex thread title."));
}, NAMING_TIMEOUT_MS);
@ -96,7 +96,7 @@ export async function generateThreadTitleWithCodex(
}
if (notification.method === "turn/completed") {
if (!notificationMatchesThreadNamingTurn(lifecycle, notification.params.threadId, notification.params.turn.id)) return;
lifecycle = { kind: "completed" };
lifecycle = transitionStructuredTurnRunLifecycle(lifecycle, { type: "completed" });
resolve(turnWithCollectedItems(notification.params.turn, completedItems));
}
};
@ -115,7 +115,7 @@ export async function generateThreadTitleWithCodex(
onLog: () => undefined,
onExit: () => {
if (lifecycle.kind === "completed") return;
lifecycle = { kind: "completed" };
lifecycle = transitionStructuredTurnRunLifecycle(lifecycle, { type: "completed" });
rejectCompletedTurn?.(new Error("Codex title generation app-server exited."));
},
});
@ -124,7 +124,7 @@ export async function generateThreadTitleWithCodex(
await client.connect();
const runtime = await namingRuntimeForClient(client, runtimeSettings);
const threadResponse = await client.startEphemeralThread(cwd, NAMING_SERVICE_NAME, TITLE_DEVELOPER_INSTRUCTIONS);
lifecycle = { kind: "thread-started", threadId: threadResponse.thread.id };
lifecycle = transitionStructuredTurnRunLifecycle(lifecycle, { type: "thread-started", threadId: threadResponse.thread.id });
const turnResponse = await client.startStructuredTurn(
threadResponse.thread.id,
cwd,
@ -133,11 +133,15 @@ export async function generateThreadTitleWithCodex(
runtime.model,
runtime.effort,
);
lifecycle = acknowledgeThreadNamingTurn(lifecycle, threadResponse.thread.id, turnResponse.turn.id);
lifecycle = transitionStructuredTurnRunLifecycle(lifecycle, {
type: "turn-started",
threadId: threadResponse.thread.id,
turnId: turnResponse.turn.id,
});
const turn = turnResponse.turn.status === "completed" ? turnWithCollectedItems(turnResponse.turn, completedItems) : await completedTurn;
return titleFromNamingTurn(turn);
} finally {
lifecycle = { kind: "completed" };
lifecycle = transitionStructuredTurnRunLifecycle(lifecycle, { type: "completed" });
if (timeout !== undefined) window.clearTimeout(timeout);
client.disconnect();
}
@ -161,18 +165,8 @@ function turnWithCollectedItems(turn: Turn, items: ThreadItem[]): Turn {
return { ...turn, items: [...items], itemsView: "full" };
}
function notificationMatchesThreadNamingTurn(lifecycle: ThreadNamingRunLifecycleState, threadId: string, turnId: string): boolean {
if (lifecycle.kind === "thread-started") return lifecycle.threadId === threadId;
if (lifecycle.kind === "turn-started") return lifecycle.threadId === threadId && lifecycle.turnId === turnId;
return false;
}
function acknowledgeThreadNamingTurn(
lifecycle: ThreadNamingRunLifecycleState,
threadId: string,
turnId: string,
): ThreadNamingRunLifecycleState {
return lifecycle.kind === "completed" ? lifecycle : { kind: "turn-started", threadId, turnId };
function notificationMatchesThreadNamingTurn(lifecycle: StructuredTurnRunLifecycleState, threadId: string, turnId: string): boolean {
return structuredTurnRunMatches(lifecycle, threadId, turnId);
}
async function namingRuntimeForClient(client: ThreadNamingClient, settings: ThreadNamingRuntimeSettings): Promise<NamingRuntime> {

View file

@ -1,4 +1,10 @@
import { AppServerClient, type AppServerClientHandlers } from "../../app-server/client";
import {
createStructuredTurnRunLifecycle,
structuredTurnRunMatches,
transitionStructuredTurnRunLifecycle,
type StructuredTurnRunLifecycleState,
} from "../../app-server/structured-turn-run-lifecycle";
import type { InitializeResponse } from "../../generated/app-server/InitializeResponse";
import type { RequestId } from "../../generated/app-server/RequestId";
import type { ServerNotification } from "../../generated/app-server/ServerNotification";
@ -59,15 +65,9 @@ export interface SelectionRewriteClient {
export type SelectionRewriteClientFactory = (codexPath: string, cwd: string, handlers: AppServerClientHandlers) => SelectionRewriteClient;
type SelectionRewriteRunLifecycleState =
| { kind: "starting" }
| { kind: "thread-started"; threadId: string }
| { kind: "turn-started"; threadId: string; turnId: string }
| { kind: "completed" };
export async function runSelectionRewrite(options: RunSelectionRewriteOptions): Promise<SelectionRewriteOutput> {
throwIfAborted(options.signal);
let lifecycle: SelectionRewriteRunLifecycleState = { kind: "starting" };
let lifecycle = createStructuredTurnRunLifecycle();
let preview = "";
let timeout: number | undefined;
let rejectCompletedTurn: ((error: Error) => void) | null = null;
@ -78,7 +78,7 @@ export async function runSelectionRewrite(options: RunSelectionRewriteOptions):
rejectCompletedTurn = reject;
timeout = window.setTimeout(() => {
if (lifecycle.kind === "completed") return;
lifecycle = { kind: "completed" };
lifecycle = transitionStructuredTurnRunLifecycle(lifecycle, { type: "completed" });
reject(new Error("Timed out while rewriting the selection."));
}, SELECTION_REWRITE_TIMEOUT_MS);
@ -107,7 +107,7 @@ export async function runSelectionRewrite(options: RunSelectionRewriteOptions):
}
if (notification.method === "turn/completed") {
if (!notificationMatchesSelectionRewriteTurn(lifecycle, notification.params.threadId, notification.params.turn.id)) return;
lifecycle = { kind: "completed" };
lifecycle = transitionStructuredTurnRunLifecycle(lifecycle, { type: "completed" });
resolve(turnWithCollectedItems(notification.params.turn, completedItems));
}
};
@ -125,7 +125,7 @@ export async function runSelectionRewrite(options: RunSelectionRewriteOptions):
onLog: () => undefined,
onExit: () => {
if (lifecycle.kind === "completed") return;
lifecycle = { kind: "completed" };
lifecycle = transitionStructuredTurnRunLifecycle(lifecycle, { type: "completed" });
rejectCompletedTurn?.(new Error("Selection rewrite app-server exited."));
},
});
@ -139,7 +139,7 @@ export async function runSelectionRewrite(options: RunSelectionRewriteOptions):
client.startEphemeralThread(options.cwd, SELECTION_REWRITE_SERVICE_NAME, SELECTION_REWRITE_DEVELOPER_INSTRUCTIONS),
options.signal,
);
lifecycle = { kind: "thread-started", threadId: threadResponse.thread.id };
lifecycle = transitionStructuredTurnRunLifecycle(lifecycle, { type: "thread-started", threadId: threadResponse.thread.id });
const turnResponse = await abortable(
client.startStructuredTurn(
threadResponse.thread.id,
@ -151,7 +151,11 @@ export async function runSelectionRewrite(options: RunSelectionRewriteOptions):
),
options.signal,
);
lifecycle = acknowledgeSelectionRewriteTurn(lifecycle, threadResponse.thread.id, turnResponse.turn.id);
lifecycle = transitionStructuredTurnRunLifecycle(lifecycle, {
type: "turn-started",
threadId: threadResponse.thread.id,
turnId: turnResponse.turn.id,
});
const turn =
turnResponse.turn.status === "completed"
? turnWithCollectedItems(turnResponse.turn, completedItems)
@ -160,24 +164,14 @@ export async function runSelectionRewrite(options: RunSelectionRewriteOptions):
if (!output) throw new SelectionRewriteOutputError("Codex did not return a valid selection rewrite response.", rawText);
return output;
} finally {
lifecycle = { kind: "completed" };
lifecycle = transitionStructuredTurnRunLifecycle(lifecycle, { type: "completed" });
if (timeout !== undefined) window.clearTimeout(timeout);
client.disconnect();
}
}
function notificationMatchesSelectionRewriteTurn(lifecycle: SelectionRewriteRunLifecycleState, threadId: string, turnId: string): boolean {
if (lifecycle.kind === "thread-started") return lifecycle.threadId === threadId;
if (lifecycle.kind === "turn-started") return lifecycle.threadId === threadId && lifecycle.turnId === turnId;
return false;
}
function acknowledgeSelectionRewriteTurn(
lifecycle: SelectionRewriteRunLifecycleState,
threadId: string,
turnId: string,
): SelectionRewriteRunLifecycleState {
return lifecycle.kind === "completed" ? lifecycle : { kind: "turn-started", threadId, turnId };
function notificationMatchesSelectionRewriteTurn(lifecycle: StructuredTurnRunLifecycleState, threadId: string, turnId: string): boolean {
return structuredTurnRunMatches(lifecycle, threadId, turnId);
}
function throwIfAborted(signal: AbortSignal | undefined): void {

View file

@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import {
createStructuredTurnRunLifecycle,
structuredTurnRunMatches,
transitionStructuredTurnRunLifecycle,
} from "../../src/app-server/structured-turn-run-lifecycle";
describe("structured turn run lifecycle", () => {
it("tracks the active thread before the turn id is acknowledged", () => {
const threadStarted = transitionStructuredTurnRunLifecycle(createStructuredTurnRunLifecycle(), {
type: "thread-started",
threadId: "thread",
});
expect(structuredTurnRunMatches(threadStarted, "thread", "early-turn")).toBe(true);
expect(structuredTurnRunMatches(threadStarted, "other-thread", "early-turn")).toBe(false);
});
it("narrows notifications to the acknowledged turn and keeps completed terminal", () => {
const threadStarted = transitionStructuredTurnRunLifecycle(createStructuredTurnRunLifecycle(), {
type: "thread-started",
threadId: "thread",
});
const turnStarted = transitionStructuredTurnRunLifecycle(threadStarted, {
type: "turn-started",
threadId: "thread",
turnId: "turn",
});
const completed = transitionStructuredTurnRunLifecycle(turnStarted, { type: "completed" });
expect(structuredTurnRunMatches(turnStarted, "thread", "turn")).toBe(true);
expect(structuredTurnRunMatches(turnStarted, "thread", "other-turn")).toBe(false);
expect(transitionStructuredTurnRunLifecycle(completed, { type: "thread-started", threadId: "late" })).toBe(completed);
});
});