mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Share structured turn run lifecycle
This commit is contained in:
parent
c1b82de870
commit
ee372d0ba5
4 changed files with 109 additions and 50 deletions
35
src/app-server/structured-turn-run-lifecycle.ts
Normal file
35
src/app-server/structured-turn-run-lifecycle.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
36
tests/app-server/structured-turn-run-lifecycle.test.ts
Normal file
36
tests/app-server/structured-turn-run-lifecycle.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue