diff --git a/eslint.config.mjs b/eslint.config.mjs index 4165226e..fa1970f4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -67,9 +67,12 @@ const uiRootImportRestrictions = [ const generatedAppServerSourceImportPatterns = importBoundaryPatterns("generated/app-server", "src/generated/app-server", 6); const generatedAppServerTestImportPatterns = importBoundaryPatterns("src/generated/app-server", "src/generated/app-server", 6); const lowerLevelFeatureImportPatterns = importBoundaryPatterns("features", "src/features", 6); +const appServerConnectionImportPatterns = importBoundaryPatterns("app-server/connection", "src/app-server/connection", 6); +const appServerProtocolConnectionImportPatterns = [...appServerConnectionImportPatterns, "../connection", "../connection/**"]; const nonAppServerBannedAppServerProtocolModules = [ "catalog", "diagnostics", + "file-change", "initialization", "request-input", "runtime-config", @@ -78,11 +81,16 @@ const nonAppServerBannedAppServerProtocolModules = [ "thread", "thread-goal", "thread-settings", + "turn", "turn-history", ]; const nonAppServerBannedAppServerProtocolImportPatterns = nonAppServerBannedAppServerProtocolModules.flatMap((moduleName) => importBoundaryPatterns(`app-server/protocol/${moduleName}`, `src/app-server/protocol/${moduleName}`, 6), ); +const chatAppServerProtocolBoundaryBannedModules = nonAppServerBannedAppServerProtocolModules.filter((moduleName) => moduleName !== "turn"); +const chatAppServerProtocolBoundaryBannedImportPatterns = chatAppServerProtocolBoundaryBannedModules.flatMap((moduleName) => + importBoundaryPatterns(`app-server/protocol/${moduleName}`, `src/app-server/protocol/${moduleName}`, 6), +); const generatedAppServerThreadImportRestrictions = [ { selector: @@ -90,6 +98,17 @@ const generatedAppServerThreadImportRestrictions = [ message: "Import generated app-server Thread as AppServerThread, or use the Panel-owned domain Thread model.", }, ]; +const turnProtocolGeneratedImportRestrictions = [ + { + selector: "ImportDeclaration[source.value=/generated\\/app-server\\//]:not([source.value=/generated\\/app-server\\/v2\\/ThreadItem$/])", + message: "Only generated ThreadItem is allowed in the turn protocol exception. Model other turn payload shapes locally.", + }, +]; +const chatAppServerProtocolBoundaryFiles = [ + "src/features/chat/app-server/inbound/notification-plan.ts", + "src/features/chat/app-server/mappers/message-stream/streaming-items.ts", + "src/features/chat/app-server/mappers/message-stream/turn-items.ts", +]; const unsafeIteratorRestrictions = [ { selector: "MemberExpression[property.name='value'][object.type='CallExpression'][object.callee.property.name='next']", @@ -718,6 +737,11 @@ export default defineConfig([ "error", { patterns: [ + { + group: appServerProtocolConnectionImportPatterns, + message: + "Keep app-server protocol modules as connection-independent adapters. Put protocol-local projections in protocol modules and let connection/client consume them.", + }, { group: lowerLevelFeatureImportPatterns, message: "Lower-level modules must not import feature modules. Move shared behavior to shared, domain, or app-server.", @@ -727,6 +751,69 @@ export default defineConfig([ ], }, }, + { + files: ["src/app-server/**/*.{ts,tsx}"], + ignores: ["src/app-server/connection/**/*.{ts,tsx}", "src/app-server/protocol/**/*.{ts,tsx}"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: generatedAppServerSourceImportPatterns, + message: + "Keep generated app-server bindings in the raw connection layer or explicit protocol adapters. App-server services, queries, catalogs, and thread helpers should expose domain models or local projections.", + }, + ], + }, + ], + }, + }, + { + files: ["src/app-server/protocol/**/*.{ts,tsx}"], + ignores: ["src/app-server/protocol/turn.ts"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: appServerProtocolConnectionImportPatterns, + message: + "Keep app-server protocol modules as connection-independent adapters. Put protocol-local projections in protocol modules and let connection/client consume them.", + }, + { + group: lowerLevelFeatureImportPatterns, + message: "Lower-level modules must not import feature modules. Move shared behavior to shared, domain, or app-server.", + }, + { + group: generatedAppServerSourceImportPatterns, + message: + "Keep generated app-server bindings out of protocol modules except the explicit turn protocol exception. Model app-server payloads with protocol-local projections.", + }, + ], + }, + ], + }, + }, + { + files: ["src/app-server/protocol/turn.ts"], + rules: { + ...restrictedSyntaxRule(turnProtocolGeneratedImportRestrictions), + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: appServerProtocolConnectionImportPatterns, + message: + "Keep app-server protocol modules as connection-independent adapters. Put protocol-local projections in protocol modules and let connection/client consume them.", + }, + ], + }, + ], + }, + }, { files: ["src/**/*.{ts,tsx}"], ignores: ["src/app-server/**/*.{ts,tsx}"], @@ -749,6 +836,27 @@ export default defineConfig([ ], }, }, + { + files: chatAppServerProtocolBoundaryFiles, + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: chatAppServerProtocolBoundaryBannedImportPatterns, + message: + "Chat app-server ingestion and message-stream conversion may consume the app-server turn protocol only. Convert other protocol payloads to local or domain models at the boundary.", + }, + { + group: generatedAppServerSourceImportPatterns, + message: "Keep generated app-server types behind src/app-server; expose Panel-owned models to feature, UI, and reducer code.", + }, + ], + }, + ], + }, + }, { files: ["tests/**/*.{ts,tsx}"], ignores: ["tests/app-server/**/*.{ts,tsx}"], diff --git a/src/app-server/catalog/data.ts b/src/app-server/catalog/data.ts index 357501a1..eb891fbb 100644 --- a/src/app-server/catalog/data.ts +++ b/src/app-server/catalog/data.ts @@ -1,7 +1,7 @@ import type { AppServerClient } from "../connection/client"; -import type { ModelListResponse } from "../../generated/app-server/v2/ModelListResponse"; import { appServerHookOperationFromHookItem, + type CatalogModel, hookItemsFromCatalogHooks, modelMetadataFromCatalogModels, skillMetadataFromCatalogSkills, @@ -14,8 +14,8 @@ export interface HookData { errors: string[]; } -interface ModelMetadataClient { - listModels(includeHidden: boolean): Promise; +export interface ModelMetadataClient { + listModels(includeHidden: boolean): Promise<{ data: readonly CatalogModel[] }>; } export async function listModelMetadata(client: ModelMetadataClient, options: { includeHidden?: boolean } = {}): Promise { diff --git a/src/app-server/connection/client.ts b/src/app-server/connection/client.ts index 4c31c4ed..19173c01 100644 --- a/src/app-server/connection/client.ts +++ b/src/app-server/connection/client.ts @@ -7,7 +7,6 @@ import type { ConfigReadResponse } from "../../generated/app-server/v2/ConfigRea import type { ConfigWriteResponse } from "../../generated/app-server/v2/ConfigWriteResponse"; import type { FsReadFileResponse } from "../../generated/app-server/v2/FsReadFileResponse"; import type { GetAccountRateLimitsResponse } from "../../generated/app-server/v2/GetAccountRateLimitsResponse"; -import type { HookTrustStatus } from "../../generated/app-server/v2/HookTrustStatus"; import type { HooksListResponse } from "../../generated/app-server/v2/HooksListResponse"; import type { CollaborationModeListResponse } from "../../generated/app-server/v2/CollaborationModeListResponse"; import type { ListMcpServerStatusParams } from "../../generated/app-server/v2/ListMcpServerStatusParams"; @@ -46,9 +45,12 @@ import type { ServerNotification } from "../../generated/app-server/ServerNotifi import type { ServerRequest } from "../../generated/app-server/ServerRequest"; import type { JsonValue } from "../../generated/app-server/serde_json/JsonValue"; import type { CodexInput } from "../../domain/chat/input"; +import type { ThreadGoalUpdate } from "../../domain/threads/goal"; +import type { RuntimeServiceTierRequest, RuntimeSettingsPatch } from "../../domain/runtime/thread-settings"; +import type { AppServerHookOperation } from "../protocol/catalog"; import { additionalContextFromCodexInput, toAppServerUserInput } from "../protocol/request-input"; -import { appServerThreadGoalUpdate, type ThreadGoalUpdate } from "../protocol/thread-goal"; -import { appServerRuntimeSettingsPatch, type RuntimeServiceTierRequest, type RuntimeSettingsPatch } from "../protocol/thread-settings"; +import { appServerThreadGoalUpdate } from "../protocol/thread-goal"; +import { appServerRuntimeSettingsPatch } from "../protocol/thread-settings"; const DEFAULT_REQUEST_TIMEOUT_MS = 120_000; @@ -61,12 +63,6 @@ export interface AppServerClientHandlers { export type AppServerTransportFactory = (handlers: AppServerTransportHandlers) => AppServerTransport; -export interface AppServerHookOperation { - key: string; - currentHash: string; - trustStatus: HookTrustStatus; -} - interface AppServerTurnRuntimeOverrides { serviceTier?: RuntimeServiceTierRequest; collaborationMode?: CollaborationMode; diff --git a/src/app-server/protocol/catalog.ts b/src/app-server/protocol/catalog.ts index a8f8b042..1eaa7731 100644 --- a/src/app-server/protocol/catalog.ts +++ b/src/app-server/protocol/catalog.ts @@ -1,4 +1,3 @@ -import type { AppServerHookOperation } from "../connection/client"; import type { HookItem, ModelMetadata, SkillMetadata } from "../../domain/catalog/metadata"; export interface CatalogModel { @@ -41,6 +40,12 @@ export interface CatalogHookMetadata { [key: string]: unknown; } +export interface AppServerHookOperation { + key: string; + currentHash: string; + trustStatus: HookItem["trustStatus"]; +} + function modelMetadataFromCatalogModel(model: CatalogModel): ModelMetadata { return { id: model.id, diff --git a/src/app-server/protocol/file-change.ts b/src/app-server/protocol/file-change.ts deleted file mode 100644 index b98c94b6..00000000 --- a/src/app-server/protocol/file-change.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { FileUpdateChange as GeneratedFileUpdateChange } from "../../generated/app-server/v2/FileUpdateChange"; - -export type FileUpdateChange = GeneratedFileUpdateChange; diff --git a/src/app-server/protocol/initialization.ts b/src/app-server/protocol/initialization.ts index 181bf4ff..69d450c5 100644 --- a/src/app-server/protocol/initialization.ts +++ b/src/app-server/protocol/initialization.ts @@ -1,6 +1,12 @@ -import type { InitializeResponse as AppServerInitializeResponse } from "../../generated/app-server/InitializeResponse"; import type { ServerInitialization } from "../../domain/server/initialization"; +interface AppServerInitializeResponse { + userAgent: string; + codexHome: string; + platformFamily: string; + platformOs: string; +} + export function appServerInitializationFromResponse(response: AppServerInitializeResponse): ServerInitialization { return { userAgent: response.userAgent, diff --git a/src/app-server/protocol/request-input.ts b/src/app-server/protocol/request-input.ts index f7465aa8..52b0e51b 100644 --- a/src/app-server/protocol/request-input.ts +++ b/src/app-server/protocol/request-input.ts @@ -1,10 +1,18 @@ -import type { AdditionalContextEntry } from "../../generated/app-server/v2/AdditionalContextEntry"; -import type { UserInput } from "../../generated/app-server/v2/UserInput"; import type { CodexInputItem } from "../../domain/chat/input"; -export type { CodexInput, CodexInputItem } from "../../domain/chat/input"; +type AppServerUserInput = + | { type: "text"; text: string; text_elements: [] } + | { type: "image"; detail?: "auto" | "low" | "high" | "original"; url: string } + | { type: "localImage"; detail?: "auto" | "low" | "high" | "original"; path: string } + | { type: "skill"; name: string; path: string } + | { type: "mention"; name: string; path: string }; -export function toAppServerUserInput(input: readonly CodexInputItem[]): UserInput[] { +interface AppServerAdditionalContextEntry { + value: string; + kind: "untrusted" | "application"; +} + +export function toAppServerUserInput(input: readonly CodexInputItem[]): AppServerUserInput[] { return input.flatMap((item) => { if (item.type === "text") return { type: "text", text: item.text, text_elements: [] }; if (item.type === "additionalContext") return []; @@ -12,8 +20,10 @@ export function toAppServerUserInput(input: readonly CodexInputItem[]): UserInpu }); } -export function additionalContextFromCodexInput(input: readonly CodexInputItem[]): Record | undefined { - const additionalContext: Record = {}; +export function additionalContextFromCodexInput( + input: readonly CodexInputItem[], +): Record | undefined { + const additionalContext: Record = {}; for (const item of input) { if (item.type !== "additionalContext") continue; if (!item.key || !item.value) continue; diff --git a/src/app-server/protocol/runtime-metrics.ts b/src/app-server/protocol/runtime-metrics.ts index 00c73e48..addd02d5 100644 --- a/src/app-server/protocol/runtime-metrics.ts +++ b/src/app-server/protocol/runtime-metrics.ts @@ -1,10 +1,31 @@ -import type { GetAccountRateLimitsResponse as AppServerAccountRateLimitsResponse } from "../../generated/app-server/v2/GetAccountRateLimitsResponse"; -import type { RateLimitSnapshot as AppServerRateLimitSnapshot } from "../../generated/app-server/v2/RateLimitSnapshot"; -import type { RateLimitWindow as AppServerRateLimitWindow } from "../../generated/app-server/v2/RateLimitWindow"; -import type { SpendControlLimitSnapshot as AppServerSpendControlLimitSnapshot } from "../../generated/app-server/v2/SpendControlLimitSnapshot"; import type { RateLimitSnapshot, RateLimitWindow, SpendControlLimitSnapshot } from "../../domain/runtime/metrics"; -export type { RateLimitSnapshot, ThreadTokenUsage } from "../../domain/runtime/metrics"; +interface AppServerRateLimitWindow { + usedPercent: number; + windowDurationMins: number | null; + resetsAt: number | null; +} + +interface AppServerSpendControlLimitSnapshot { + limit: string; + used: string; + remainingPercent: number; + resetsAt: number; +} + +interface AppServerRateLimitSnapshot extends Record { + limitId: string | null; + limitName: string | null; + primary: AppServerRateLimitWindow | null; + secondary: AppServerRateLimitWindow | null; + individualLimit: AppServerSpendControlLimitSnapshot | null; + rateLimitReachedType: string | null; +} + +interface AppServerAccountRateLimitsResponse { + rateLimits: AppServerRateLimitSnapshot; + rateLimitsByLimitId: Record | null; +} function rateLimitSnapshotFromAppServerSnapshot(snapshot: AppServerRateLimitSnapshot): RateLimitSnapshot { return { diff --git a/src/app-server/protocol/thread-goal.ts b/src/app-server/protocol/thread-goal.ts index 8e5893fc..8fa39873 100644 --- a/src/app-server/protocol/thread-goal.ts +++ b/src/app-server/protocol/thread-goal.ts @@ -1,9 +1,18 @@ -import type { ThreadGoal as AppServerThreadGoal } from "../../generated/app-server/v2/ThreadGoal"; -import type { ThreadGoalStatus as AppServerThreadGoalStatus } from "../../generated/app-server/v2/ThreadGoalStatus"; -import type { JsonValue } from "../../generated/app-server/serde_json/JsonValue"; import type { ThreadGoal, ThreadGoalStatus, ThreadGoalUpdate } from "../../domain/threads/goal"; -export type { ThreadGoal, ThreadGoalUpdate } from "../../domain/threads/goal"; +interface AppServerThreadGoal { + threadId: string; + objective: string; + status: AppServerThreadGoalStatus; + tokenBudget: number | null; + tokensUsed: number; + timeUsedSeconds: number; + createdAt: number; + updatedAt: number; +} + +type AppServerThreadGoalStatus = ThreadGoalStatus; +type AppServerJsonValue = number | string | boolean | AppServerJsonValue[] | { [key: string]: AppServerJsonValue | undefined } | null; export function threadGoalFromAppServerGoal(goal: AppServerThreadGoal | null): ThreadGoal | null { if (!goal) return null; @@ -31,7 +40,7 @@ export function appServerThreadGoalUpdate(update: ThreadGoalUpdate): { }; } -export function appServerThreadGoalUserHistoryItem(text: string): JsonValue { +export function appServerThreadGoalUserHistoryItem(text: string): AppServerJsonValue { return { type: "message", role: "user", diff --git a/src/app-server/protocol/thread-settings.ts b/src/app-server/protocol/thread-settings.ts index 90ff0535..e768a69e 100644 --- a/src/app-server/protocol/thread-settings.ts +++ b/src/app-server/protocol/thread-settings.ts @@ -1,9 +1,15 @@ -import type { ThreadSettingsUpdateParams } from "../../generated/app-server/v2/ThreadSettingsUpdateParams"; import type { RuntimeSettingsPatch } from "../../domain/runtime/thread-settings"; -export type { RuntimeServiceTierRequest, RuntimeSettingsPatch } from "../../domain/runtime/thread-settings"; - -type AppServerRuntimeSettingsPatch = Omit; +type AppServerRuntimeSettingsPatch = Omit & { + collaborationMode?: { + mode: "plan" | "default"; + settings: { + model: string; + reasoning_effort: string | null; + developer_instructions: string | null; + }; + } | null; +}; export function appServerRuntimeSettingsPatch(update: RuntimeSettingsPatch): AppServerRuntimeSettingsPatch { const { collaborationMode, ...settings } = update; diff --git a/src/app-server/protocol/turn.ts b/src/app-server/protocol/turn.ts index 74b8b722..c936c63b 100644 --- a/src/app-server/protocol/turn.ts +++ b/src/app-server/protocol/turn.ts @@ -1,6 +1,4 @@ import type { ThreadItem as GeneratedTurnItem } from "../../generated/app-server/v2/ThreadItem"; -import type { Turn as GeneratedTurnRecord } from "../../generated/app-server/v2/Turn"; -import type { UserInput } from "../../generated/app-server/v2/UserInput"; import { conversationSummaryFromTranscriptEntries, nonEmptyConversationSummaries, @@ -9,7 +7,50 @@ import { } from "../../domain/threads/transcript"; export type TurnItem = GeneratedTurnItem; -export type TurnRecord = GeneratedTurnRecord; + +type AppServerUserInput = + | { type: "text"; text: string } + | { type: "image"; url: string } + | { type: "localImage"; path: string } + | { type: "mention"; name: string; path: string } + | { type: "skill"; name: string; path: string }; +type TurnItemsView = "notLoaded" | "summary" | "full"; +type TurnStatus = "completed" | "interrupted" | "failed" | "inProgress"; +type HttpCodexErrorInfo = + | { httpConnectionFailed: { httpStatusCode: number | null } } + | { responseStreamConnectionFailed: { httpStatusCode: number | null } } + | { responseStreamDisconnected: { httpStatusCode: number | null } } + | { responseTooManyFailedAttempts: { httpStatusCode: number | null } }; +type AppServerCodexErrorInfo = + | "contextWindowExceeded" + | "usageLimitExceeded" + | "serverOverloaded" + | "cyberPolicy" + | "internalServerError" + | "unauthorized" + | "badRequest" + | "threadRollbackFailed" + | "sandboxError" + | "other" + | HttpCodexErrorInfo + | { activeTurnNotSteerable: { turnKind: "review" | "compact" } }; + +interface TurnError { + message: string; + codexErrorInfo: AppServerCodexErrorInfo | null; + additionalDetails: string | null; +} + +export interface TurnRecord { + id: string; + items: TurnItem[]; + itemsView: TurnItemsView; + status: TurnStatus; + error: TurnError | null; + startedAt: number | null; + completedAt: number | null; + durationMs: number | null; +} function transcriptEntriesFromTurnRecord(turn: TurnRecord): ThreadTranscriptEntry[] { return turn.items.flatMap((item) => transcriptEntriesFromTurnItem(item, turn)); @@ -79,7 +120,7 @@ function transcriptEntriesFromTurnItem(item: TurnItem, turn: TurnRecord): Thread return []; } -function userInputText(content: UserInput[]): string { +function userInputText(content: readonly AppServerUserInput[]): string { const hasText = content.some((item) => item.type === "text" && item.text.length > 0); return content .map((item) => { diff --git a/src/app-server/query/cache.ts b/src/app-server/query/cache.ts index c65dc95e..b6928aa0 100644 --- a/src/app-server/query/cache.ts +++ b/src/app-server/query/cache.ts @@ -6,6 +6,7 @@ import { readRateLimitMetadataProbe, readRuntimeConfigSnapshot, readSkillMetadat import { listThreads } from "../threads/data"; import type { ModelMetadata } from "../../domain/catalog/metadata"; import { createServerDiagnostics, diagnosticProbeError, diagnosticProbeOk, diagnosticsWithProbe } from "../../domain/server/diagnostics"; +import type { SharedServerMetadata } from "../../domain/server/metadata"; import type { Thread } from "../../domain/threads/model"; import { activeThreadsQueryKey, @@ -16,7 +17,7 @@ import { cloneAppServerQueryContext, type AppServerQueryContext, } from "./keys"; -import { cloneModelMetadata, cloneSharedServerMetadata, cloneThreads, type SharedServerMetadata } from "./snapshots"; +import { cloneModelMetadata, cloneSharedServerMetadata, cloneThreads } from "./snapshots"; const ACTIVE_THREADS_STALE_TIME_MS = 10_000; const APP_SERVER_METADATA_STALE_TIME_MS = 10_000; diff --git a/src/app-server/query/shared-queries.ts b/src/app-server/query/shared-queries.ts index a5c86b4b..e9e35570 100644 --- a/src/app-server/query/shared-queries.ts +++ b/src/app-server/query/shared-queries.ts @@ -1,4 +1,5 @@ import type { ModelMetadata } from "../../domain/catalog/metadata"; +import type { SharedServerMetadata } from "../../domain/server/metadata"; import type { Thread } from "../../domain/threads/model"; import type { AppServerObservedQueryResult, AppServerQueryCache } from "./cache"; import { @@ -7,7 +8,6 @@ import { cloneAppServerQueryContext, type AppServerQueryContext, } from "./keys"; -import type { SharedServerMetadata } from "./snapshots"; export interface AppServerSharedQueriesOptions { cache: AppServerQueryCache; diff --git a/src/app-server/query/snapshots.ts b/src/app-server/query/snapshots.ts index ccc67ea8..50cc1685 100644 --- a/src/app-server/query/snapshots.ts +++ b/src/app-server/query/snapshots.ts @@ -4,8 +4,6 @@ import type { RateLimitSnapshot } from "../../domain/runtime/metrics"; import type { SharedServerMetadata } from "../../domain/server/metadata"; import type { Thread } from "../../domain/threads/model"; -export type { SharedServerMetadata } from "../../domain/server/metadata"; - export function cloneThreads(threads: readonly Thread[]): Thread[] { return threads.map((thread) => ({ ...thread })); } diff --git a/src/app-server/services/ephemeral-structured-turn.ts b/src/app-server/services/ephemeral-structured-turn.ts index cfe6b2dd..e4ca5833 100644 --- a/src/app-server/services/ephemeral-structured-turn.ts +++ b/src/app-server/services/ephemeral-structured-turn.ts @@ -5,16 +5,11 @@ import { type AppServerStartStructuredTurnOptions, } from "../connection/client"; import { abortablePromise, throwIfAbortSignalAborted } from "../../shared/lifecycle/abortable"; -import type { InitializeResponse } from "../../generated/app-server/InitializeResponse"; -import type { RequestId } from "../../generated/app-server/RequestId"; -import type { ServerNotification } from "../../generated/app-server/ServerNotification"; -import type { JsonValue } from "../../generated/app-server/serde_json/JsonValue"; -import type { ModelListResponse } from "../../generated/app-server/v2/ModelListResponse"; -import type { ThreadStartResponse } from "../../generated/app-server/v2/ThreadStartResponse"; -import type { TurnStartResponse } from "../../generated/app-server/v2/TurnStartResponse"; +import type { RequestId, ServerNotification } from "../connection/rpc-messages"; +import type { ModelMetadataClient } from "../catalog/data"; import { lastAgentMessageTextFromTurnRecord, type TurnItem, type TurnRecord } from "../protocol/turn"; -export type StructuredTurnOutputSchema = JsonValue; +export type StructuredTurnOutputSchema = AppServerStartStructuredTurnOptions["outputSchema"]; type StructuredTurnRuntimeOverride = NonNullable; @@ -33,16 +28,14 @@ const DEFAULT_EPHEMERAL_STRUCTURED_TURN_TIMERS: EphemeralStructuredTurnTimers = }; export interface EphemeralStructuredTurnClient { - connect(): Promise; + connect(): Promise; disconnect(): void; rejectServerRequest(requestId: RequestId, code: number, message: string): void; - startEphemeralThread(options: AppServerStartEphemeralThreadOptions): Promise; - startStructuredTurn(options: AppServerStartStructuredTurnOptions): Promise; + startEphemeralThread(options: AppServerStartEphemeralThreadOptions): Promise<{ thread: { id: string } }>; + startStructuredTurn(options: AppServerStartStructuredTurnOptions): Promise<{ turn: TurnRecord }>; } -export interface EphemeralStructuredTurnRuntimeClient { - listModels(includeHidden?: boolean): Promise; -} +export type EphemeralStructuredTurnRuntimeClient = ModelMetadataClient; type EphemeralStructuredTurnRuntimeCapableClient = EphemeralStructuredTurnClient & EphemeralStructuredTurnRuntimeClient; diff --git a/src/app-server/services/runtime-overrides.ts b/src/app-server/services/runtime-overrides.ts index bb07dc8f..2f74dea7 100644 --- a/src/app-server/services/runtime-overrides.ts +++ b/src/app-server/services/runtime-overrides.ts @@ -1,14 +1,9 @@ import type { RuntimeOverride, RuntimeOverrideSettings } from "../../domain/runtime/overrides"; import { runtimeOverride, validatedRuntimeOverrideForModelMetadata } from "../../domain/runtime/overrides"; -import type { ModelListResponse } from "../../generated/app-server/v2/ModelListResponse"; -import { listModelMetadata } from "../catalog/data"; - -interface RuntimeOverrideModelClient { - listModels(includeHidden: boolean): Promise; -} +import { listModelMetadata, type ModelMetadataClient } from "../catalog/data"; export async function resolvedRuntimeOverrideForClient( - client: RuntimeOverrideModelClient, + client: ModelMetadataClient, settings: RuntimeOverrideSettings, ): Promise { const runtime = runtimeOverride(settings); diff --git a/src/app-server/services/thread-goals.ts b/src/app-server/services/thread-goals.ts index fe27ac1c..932e914b 100644 --- a/src/app-server/services/thread-goals.ts +++ b/src/app-server/services/thread-goals.ts @@ -1,10 +1,6 @@ import type { AppServerClient } from "../connection/client"; -import { - appServerThreadGoalUserHistoryItem, - threadGoalFromAppServerGoal, - type ThreadGoal, - type ThreadGoalUpdate, -} from "../protocol/thread-goal"; +import type { ThreadGoal, ThreadGoalUpdate } from "../../domain/threads/goal"; +import { appServerThreadGoalUserHistoryItem, threadGoalFromAppServerGoal } from "../protocol/thread-goal"; export async function readThreadGoal(client: AppServerClient, threadId: string): Promise { const response = await client.getThreadGoal(threadId); diff --git a/src/app-server/threads/data.ts b/src/app-server/threads/data.ts index d09d2bc8..765fcc60 100644 --- a/src/app-server/threads/data.ts +++ b/src/app-server/threads/data.ts @@ -1,12 +1,9 @@ import type { AppServerClient } from "../connection/client"; -import type { SortDirection } from "../../generated/app-server/v2/SortDirection"; -import type { ThreadRollbackResponse } from "../../generated/app-server/v2/ThreadRollbackResponse"; import { threadFromThreadRecord, threadsFromThreadRecords, type ThreadRecord } from "../protocol/thread"; import { chronologicalConversationSummariesFromTurnRecords, completedConversationSummariesFromTurnRecords, transcriptEntriesFromTurnRecords, - type TurnItem, } from "../protocol/turn"; import type { HistoricalTurn } from "../../domain/threads/history"; import type { Thread } from "../../domain/threads/model"; @@ -16,6 +13,8 @@ import type { ThreadConversationSummary } from "../../domain/threads/transcript" const THREAD_LIST_PAGE_LIMIT = 100; +export type ThreadTurnSortDirection = "asc" | "desc"; + export async function listThreads(client: AppServerClient, cwd: string, options: { archived?: boolean } = {}): Promise { const archived = options.archived ?? false; const records: ThreadRecord[] = []; @@ -54,7 +53,7 @@ export async function readCompletedConversationSummariesPage( threadId: string, cursor: string | null, limit: number, - sortDirection: SortDirection = "asc", + sortDirection: ThreadTurnSortDirection = "asc", ): Promise { const response = await client.threadTurnsList(threadId, cursor, limit, sortDirection); return { @@ -75,10 +74,17 @@ export async function readReferencedThreadConversationSummaries( export interface ThreadRollbackSnapshot { thread: Thread; cwd: string; - turns: readonly HistoricalTurn[]; + turns: readonly HistoricalTurn[]; } -function threadRollbackSnapshotFromAppServerResponse(response: ThreadRollbackResponse): ThreadRollbackSnapshot { +interface ThreadRollbackResult { + readonly thread: ThreadRecord & { + readonly cwd: string; + readonly turns: readonly HistoricalTurn[]; + }; +} + +function threadRollbackSnapshotFromAppServerResponse(response: ThreadRollbackResult): ThreadRollbackSnapshot { return { thread: threadFromThreadRecord(response.thread), cwd: response.thread.cwd, diff --git a/src/features/chat/app-server/inbound/notification-plan.ts b/src/features/chat/app-server/inbound/notification-plan.ts index 802d7b54..35ca0f2a 100644 --- a/src/features/chat/app-server/inbound/notification-plan.ts +++ b/src/features/chat/app-server/inbound/notification-plan.ts @@ -1,7 +1,6 @@ import { activeThreadSettingsAppliedAction } from "../../application/state/actions"; import type { McpServerStartupStatus } from "../../../../domain/server/diagnostics"; import { threadTokenUsageFromRuntimeUsage } from "../../../../domain/runtime/metrics"; -import type { FileUpdateChange } from "../../../../app-server/protocol/file-change"; import { completedConversationSummaryFromTurnRecord, type TurnItem } from "../../../../app-server/protocol/turn"; import type { ServerNotification } from "../../../../app-server/connection/rpc-messages"; import { normalizeExplicitThreadName } from "../../../../domain/threads/model"; @@ -19,6 +18,7 @@ import { messageStreamItemsFromTurns, shouldSuppressLifecycleItem, } from "../mappers/message-stream/turn-items"; +import { normalizeFileChanges, type AppServerFileChange } from "../mappers/message-stream/file-changes"; import { taskProgressMessageStreamItem } from "../../domain/message-stream/factories/task-progress"; import type { MessageStreamItem, MessageStreamItemKind } from "../../domain/message-stream/items"; import { goalChangeItem } from "../../domain/message-stream/factories/goal-items"; @@ -425,10 +425,10 @@ function completedItemPlan(state: ChatState, item: TurnItem, turnId: string): Ch }; } -function fileChangePlan(itemId: string, turnId: string, changes: FileUpdateChange[], status: string): ChatNotificationPlan { +function fileChangePlan(itemId: string, turnId: string, changes: readonly AppServerFileChange[], status: string): ChatNotificationPlan { return actionPlan({ type: "message-stream/item-upserted", - item: streamingFileChangeMessageStreamItem(itemId, turnId, changes, status), + item: streamingFileChangeMessageStreamItem(itemId, turnId, normalizeFileChanges(changes), status), }); } diff --git a/src/features/chat/app-server/mappers/message-stream/file-changes.ts b/src/features/chat/app-server/mappers/message-stream/file-changes.ts new file mode 100644 index 00000000..e9e000ef --- /dev/null +++ b/src/features/chat/app-server/mappers/message-stream/file-changes.ts @@ -0,0 +1,17 @@ +import type { MessageStreamFileChange } from "../../../domain/message-stream/items"; + +export interface AppServerFileChange { + readonly path: string; + readonly kind: { + readonly type: string; + }; + readonly diff: string; +} + +export function normalizeFileChanges(changes: readonly AppServerFileChange[]): MessageStreamFileChange[] { + return changes.map((change) => ({ + kind: change.kind.type, + path: change.path, + diff: change.diff, + })); +} diff --git a/src/features/chat/app-server/mappers/message-stream/streaming-items.ts b/src/features/chat/app-server/mappers/message-stream/streaming-items.ts index 52f4171e..3609992d 100644 --- a/src/features/chat/app-server/mappers/message-stream/streaming-items.ts +++ b/src/features/chat/app-server/mappers/message-stream/streaming-items.ts @@ -1,11 +1,9 @@ -import type { FileUpdateChange } from "../../../../../app-server/protocol/file-change"; -import type { MessageStreamItem } from "../../../domain/message-stream/items"; -import { normalizeFileChanges } from "./turn-items"; +import type { MessageStreamFileChange, MessageStreamItem } from "../../../domain/message-stream/items"; export function streamingFileChangeMessageStreamItem( itemId: string, turnId: string, - changes: FileUpdateChange[], + changes: readonly MessageStreamFileChange[], status: string, ): MessageStreamItem { return { @@ -16,6 +14,6 @@ export function streamingFileChangeMessageStreamItem( sourceItemId: itemId, provenance: { source: "appServer", channel: "notification", event: "streamingDelta", sourceItemId: itemId }, status, - changes: normalizeFileChanges(changes), + changes, }; } diff --git a/src/features/chat/app-server/mappers/message-stream/turn-items.ts b/src/features/chat/app-server/mappers/message-stream/turn-items.ts index 1f8f7400..e7afee71 100644 --- a/src/features/chat/app-server/mappers/message-stream/turn-items.ts +++ b/src/features/chat/app-server/mappers/message-stream/turn-items.ts @@ -8,7 +8,6 @@ import type { } from "../../../domain/message-stream/items"; import type { MessageStreamItemProvenance } from "../../../domain/message-stream/provenance"; import type { HistoricalTurn } from "../../../../../domain/threads/history"; -import type { FileUpdateChange } from "../../../../../app-server/protocol/file-change"; import type { TurnItem } from "../../../../../app-server/protocol/turn"; import { definedProp } from "../../../../../utils"; import { referencedThreadMetadataFromPrompt, type ReferencedThreadMetadata } from "../../../../../domain/threads/reference"; @@ -18,6 +17,7 @@ import { fileMentionsFromInput } from "../../../domain/message-stream/format/fil import { normalizeProposedPlanMarkdown } from "../../../domain/message-stream/format/proposed-plan"; import { userMessageDisplayText } from "../../../domain/message-stream/format/user-message-text"; import { failedStatusLabel, jsonTargetLabel } from "../../../domain/message-stream/format/item-labels"; +import { normalizeFileChanges } from "./file-changes"; type UserMessageItem = Extract; type AgentMessageItem = Extract; @@ -108,11 +108,11 @@ const STANDARD_TOOL_STATES = { failed: "failed", } as const satisfies ExecutionStateByStatus; -export function messageStreamItemsFromTurns(turns: readonly HistoricalTurn[]): MessageStreamItem[] { +export function messageStreamItemsFromTurns(turns: readonly HistoricalTurn[]): MessageStreamItem[] { const sortedTurns = [...turns].sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0)); const items: MessageStreamItem[] = []; for (const turn of sortedTurns) { - for (const item of turn.items) { + for (const item of turn.items as readonly TurnItem[]) { const streamItem = messageStreamItemFromTurnItem(item, turn.id); if (streamItem) items.push(streamItem); } @@ -650,14 +650,6 @@ function fileChangeMessageStreamItemFromData(data: FileChangeMessageStreamData, }; } -export function normalizeFileChanges(changes: FileUpdateChange[]): MessageStreamFileChange[] { - return changes.map((change) => ({ - kind: change.kind.type, - path: change.path, - diff: change.diff, - })); -} - export function shouldSuppressLifecycleItem(item: TurnItem): boolean { return item.type === "agentMessage" || item.type === "userMessage"; } diff --git a/src/features/chat/application/threads/history-controller.ts b/src/features/chat/application/threads/history-controller.ts index 5a8887cd..3d11e4fd 100644 --- a/src/features/chat/application/threads/history-controller.ts +++ b/src/features/chat/application/threads/history-controller.ts @@ -1,5 +1,4 @@ import type { AppServerClient } from "../../../../app-server/connection/client"; -import type { TurnItem } from "../../../../app-server/protocol/turn"; import type { ThreadTurnsPage } from "../../../../domain/threads/history"; import type { ChatAction, ChatState } from "../state/root-reducer"; import type { ChatStateStore } from "../state/store"; @@ -56,7 +55,7 @@ export class HistoryController { } } - applyLatestPage(threadId: string, response: ThreadTurnsPage): boolean { + applyLatestPage(threadId: string, response: ThreadTurnsPage): boolean { if (this.state.activeThread.id !== threadId) return false; this.host.setThreadTurnPresence(response.data.length > 0); this.host.showLatestPageAtBottom(); diff --git a/tests/app-server/query-cache.test.ts b/tests/app-server/query-cache.test.ts index 2ec68426..1156e8f2 100644 --- a/tests/app-server/query-cache.test.ts +++ b/tests/app-server/query-cache.test.ts @@ -9,8 +9,8 @@ import { import { AppServerQueryCache } from "../../src/app-server/query/cache"; import type { AppServerQueryContext } from "../../src/app-server/query/keys"; import { emptyRuntimeConfigSnapshot, type RuntimeConfigSnapshot } from "../../src/domain/runtime/config"; -import type { RateLimitSnapshot } from "../../src/app-server/protocol/runtime-metrics"; -import type { SharedServerMetadata } from "../../src/app-server/query/snapshots"; +import type { RateLimitSnapshot } from "../../src/domain/runtime/metrics"; +import type { SharedServerMetadata } from "../../src/domain/server/metadata"; import type { ModelMetadata, SkillMetadata } from "../../src/domain/catalog/metadata"; import type { CatalogModel, CatalogSkillMetadata } from "../../src/app-server/protocol/catalog"; diff --git a/tests/features/chat/connection/server-actions/server-actions.test.ts b/tests/features/chat/connection/server-actions/server-actions.test.ts index 7caafc26..fdfc93fd 100644 --- a/tests/features/chat/connection/server-actions/server-actions.test.ts +++ b/tests/features/chat/connection/server-actions/server-actions.test.ts @@ -11,7 +11,7 @@ import { } from "../../../../../src/domain/server/diagnostics"; import type { SharedServerMetadata } from "../../../../../src/domain/server/metadata"; import { emptyRuntimeConfigSnapshot } from "../../../../../src/domain/runtime/config"; -import type { RateLimitSnapshot } from "../../../../../src/app-server/protocol/runtime-metrics"; +import type { RateLimitSnapshot } from "../../../../../src/domain/runtime/metrics"; import { threadFromThreadRecord } from "../../../../../src/app-server/protocol/thread"; import { createChatServerDiagnosticsActions } from "../../../../../src/features/chat/app-server/actions/diagnostics"; import { createChatServerMetadataActions } from "../../../../../src/features/chat/app-server/actions/metadata"; diff --git a/tests/features/chat/conversation/turns/slash-command-handler.test.ts b/tests/features/chat/conversation/turns/slash-command-handler.test.ts index 9188a79c..c71860fc 100644 --- a/tests/features/chat/conversation/turns/slash-command-handler.test.ts +++ b/tests/features/chat/conversation/turns/slash-command-handler.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { AppServerClient } from "../../../../../src/app-server/connection/client"; -import type { CodexInput } from "../../../../../src/app-server/protocol/request-input"; +import type { CodexInput } from "../../../../../src/domain/chat/input"; import type { TurnItem, TurnRecord } from "../../../../../src/app-server/protocol/turn"; import { createChatState } from "../../../../../src/features/chat/application/state/root-reducer"; import { createChatStateStore } from "../../../../../src/features/chat/application/state/store"; diff --git a/tests/features/chat/conversation/turns/turn-submission-controller.test.ts b/tests/features/chat/conversation/turns/turn-submission-controller.test.ts index f13041c4..40993866 100644 --- a/tests/features/chat/conversation/turns/turn-submission-controller.test.ts +++ b/tests/features/chat/conversation/turns/turn-submission-controller.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { AppServerClient } from "../../../../../src/app-server/connection/client"; -import type { CodexInput } from "../../../../../src/app-server/protocol/request-input"; +import type { CodexInput } from "../../../../../src/domain/chat/input"; import { createChatState } from "../../../../../src/features/chat/application/state/root-reducer"; import { createChatStateStore } from "../../../../../src/features/chat/application/state/store"; import { diff --git a/tests/features/chat/threads/resume-controller.test.ts b/tests/features/chat/threads/resume-controller.test.ts index 29a2331b..c1b7be54 100644 --- a/tests/features/chat/threads/resume-controller.test.ts +++ b/tests/features/chat/threads/resume-controller.test.ts @@ -8,7 +8,7 @@ import { createResumeController, type ResumeControllerHost } from "../../../../s import type { HistoryController } from "../../../../src/features/chat/application/threads/history-controller"; import { ChatResumeWorkTracker } from "../../../../src/features/chat/application/lifecycle"; import type { Thread as PanelThread } from "../../../../src/domain/threads/model"; -import type { ThreadTokenUsage } from "../../../../src/app-server/protocol/runtime-metrics"; +import type { ThreadTokenUsage } from "../../../../src/domain/runtime/metrics"; import type { TurnItem, TurnRecord } from "../../../../src/app-server/protocol/turn"; type ThreadResumeResponse = Awaited>; diff --git a/tests/scripts/eslint-config.test.ts b/tests/scripts/eslint-config.test.ts index 7d780c9d..113fa03d 100644 --- a/tests/scripts/eslint-config.test.ts +++ b/tests/scripts/eslint-config.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; const repoRoot = process.cwd(); const ESLINT_STARTUP_TEST_TIMEOUT_MS = 10_000; +const generatedAppServerImportRoot = "../../generated" + "/app-server"; describe("eslint config", () => { it( @@ -131,6 +132,100 @@ export type Turn = TurnRecord; expect(ingestionMessages).not.toContain("no-restricted-imports"); }); + it("reports turn protocol imports outside chat app-server ingestion and conversion boundaries", async () => { + const messages = await lintSource( + "src/features/chat/application/threads/history-controller.ts", + ` +import type { TurnItem } from "../../../../app-server/protocol/turn"; + +export type Item = TurnItem; +`, + ); + + expect(messages).toContain("no-restricted-imports"); + }); + + it("reports non-turn protocol imports at chat app-server ingestion and conversion boundaries", async () => { + const messages = await lintSource( + "src/features/chat/app-server/inbound/notification-plan.ts", + ` +import type { FileUpdateChange } from "../../../../app-server/protocol/file-change"; + +export type Change = FileUpdateChange; +`, + ); + + expect(messages).toContain("no-restricted-imports"); + }); + + it("keeps generated app-server bindings out of non-turn protocol modules", async () => { + const messages = await lintSource( + "src/app-server/protocol/request-input.ts", + ` +import type { UserInput } from "${generatedAppServerImportRoot}/v2/UserInput"; + +export type Input = UserInput; +`, + ); + + expect(messages).toContain("no-restricted-imports"); + }); + + it("keeps app-server protocol adapters independent from the connection layer", async () => { + const messages = await lintSource( + "src/app-server/protocol/catalog.ts", + ` +import type { AppServerClient } from "../connection/client"; + +export type Client = AppServerClient; +`, + ); + + expect(messages).toContain("no-restricted-imports"); + }); + + it("keeps generated app-server bindings out of app-server service seams", async () => { + const messages = await lintSource( + "src/app-server/services/runtime-overrides.ts", + ` +import type { ModelListResponse } from "${generatedAppServerImportRoot}/v2/ModelListResponse"; + +export interface RuntimeOverrideModelClient { + listModels(includeHidden: boolean): Promise; +} +`, + ); + + expect(messages).toContain("no-restricted-imports"); + }); + + it("allows generated app-server bindings in the explicit turn protocol exception", async () => { + const messages = await lintSource( + "src/app-server/protocol/turn.ts", + ` +import type { ThreadItem as GeneratedTurnItem } from "${generatedAppServerImportRoot}/v2/ThreadItem"; + +export type TurnItem = GeneratedTurnItem; +`, + ); + + expect(messages).not.toContain("no-restricted-imports"); + expect(messages).not.toContain("no-restricted-syntax"); + }); + + it("reports non-ThreadItem generated app-server imports in the turn protocol exception", async () => { + const messages = await lintSource( + "src/app-server/protocol/turn.ts", + ` +import type { UserInput } from "${generatedAppServerImportRoot}/v2/UserInput"; + +export type Input = UserInput; +`, + ); + + expect(messages).toContain("no-restricted-syntax"); + }); + it("reports direct ChatState alias mutation", async () => { const messages = await lintSource( "src/features/chat/domain/runtime/effective.ts",