From 207aefdf119ce8d16669a81190f1fcc775f4d7be Mon Sep 17 00:00:00 2001 From: murashit Date: Sun, 28 Jun 2026 14:01:40 +0900 Subject: [PATCH] Make app-server protocol adapters generated-aware --- biome.jsonc | 8 +- src/app-server/protocol/server-requests.ts | 125 ++++++++---------- src/app-server/protocol/turn.ts | 123 +---------------- .../server-requests/mcp-elicitation.test.ts | 35 +++++ tests/scripts/grit-policy.test.mjs | 9 +- 5 files changed, 107 insertions(+), 193 deletions(-) diff --git a/biome.jsonc b/biome.jsonc index 0ad3de07..1c30fd0a 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -81,7 +81,13 @@ }, { "path": "./scripts/grit/import-boundaries/no-generated-app-server-boundary-imports.grit", - "includes": ["**/src/**/*.ts", "**/src/**/*.tsx", "!**/src/app-server/connection/**"] + "includes": [ + "**/src/**/*.ts", + "**/src/**/*.tsx", + "!**/src/app-server/connection/**", + "!**/src/app-server/protocol/server-requests.ts", + "!**/src/app-server/protocol/turn.ts" + ] }, { "path": "./scripts/grit/runtime/no-app-server-projection-rpcs.grit", diff --git a/src/app-server/protocol/server-requests.ts b/src/app-server/protocol/server-requests.ts index 697702f5..93d4c44e 100644 --- a/src/app-server/protocol/server-requests.ts +++ b/src/app-server/protocol/server-requests.ts @@ -12,6 +12,7 @@ import type { PendingUserInput, PendingUserInputQuestion, } from "../../domain/pending-requests/model"; +import type { ServerRequest as GeneratedServerRequest } from "../../generated/app-server/ServerRequest"; import { pathRelativeToRoot } from "../../shared/path/file-paths"; import { jsonPreview } from "../../shared/text/preview"; @@ -26,38 +27,19 @@ type CommandApprovalDecision = | { acceptWithExecpolicyAmendment: unknown } | { applyNetworkPolicyAmendment: { network_policy_amendment: { action?: unknown; host?: unknown } } }; -type AppServerRequest = { - method: string; - id: PendingApproval["requestId"]; - params: unknown; -}; +type AppServerRequest = GeneratedServerRequest; +type CommandApprovalRequest = Extract; +type FileChangeApprovalRequest = Extract; +type PermissionsApprovalRequest = Extract; +type UserInputRequest = Extract; +type McpElicitationRequest = Extract; -interface CommandApprovalParams { - command?: unknown; - cwd?: unknown; - turnId?: unknown; - reason?: unknown; - networkApprovalContext?: unknown; - commandActions?: unknown; - additionalPermissions?: unknown; - proposedExecpolicyAmendment?: unknown; - proposedNetworkPolicyAmendments?: unknown; - availableDecisions?: unknown; -} - -interface FileChangeApprovalParams { - turnId?: unknown; - reason?: unknown; - grantRoot?: unknown; -} - -interface PermissionsApprovalParams { - cwd?: unknown; - turnId?: unknown; - reason?: unknown; - environmentId?: unknown; - permissions?: unknown; -} +type CommandApprovalParams = CommandApprovalRequest["params"]; +type FileChangeApprovalParams = FileChangeApprovalRequest["params"]; +type PermissionsApprovalParams = PermissionsApprovalRequest["params"]; +type UserInputParams = UserInputRequest["params"]; +type UserInputQuestion = UserInputParams["questions"][number]; +type McpElicitationParams = McpElicitationRequest["params"]; type AppServerMcpElicitationPrimitiveSchema = | { @@ -125,11 +107,11 @@ export interface AppServerMcpElicitationResponse { export function appServerApprovalRequest(request: AppServerRequest): PendingApproval | null { switch (request.method) { case "item/commandExecution/requestApproval": - return commandApprovalRequest(request.id, asRecordOrEmpty(request.params)); + return commandApprovalRequest(request.id, request.params); case "item/fileChange/requestApproval": - return fileChangeApprovalRequest(request.id, asRecordOrEmpty(request.params)); + return fileChangeApprovalRequest(request.id, request.params); case "item/permissions/requestApproval": - return permissionsApprovalRequest(request.id, asRecordOrEmpty(request.params)); + return permissionsApprovalRequest(request.id, request.params); default: return null; } @@ -168,7 +150,7 @@ export function appServerUserInputResponse( export function appServerMcpElicitationRequest(request: AppServerRequest): PendingMcpElicitation | null { if (request.method !== "mcpServer/elicitation/request") return null; - const params = mcpElicitationParams(request.params); + const params = normalizeMcpElicitationParams(request.params); if (!params) return null; if (params.mode === "url") { return { @@ -568,28 +550,26 @@ function asRecordOrEmpty(value: unknown): Record { return asRecordOrNull(value) ?? {}; } -function pendingUserInputParams(value: unknown): PendingUserInput["params"] | null { - const params = asRecordOrNull(value); - const questions = params?.["questions"]; - if (!params || !Array.isArray(questions)) return null; +function pendingUserInputParams(params: UserInputParams): PendingUserInput["params"] | null { + const questions: unknown = params.questions; + if (!Array.isArray(questions)) return null; return { - threadId: stringValue(params["threadId"]), - turnId: stringValue(params["turnId"]), - itemId: stringValue(params["itemId"]), + threadId: stringValue(params.threadId), + turnId: stringValue(params.turnId), + itemId: stringValue(params.itemId), questions: questions.map(pendingUserInputQuestion), - autoResolutionMs: numberOrNull(params["autoResolutionMs"]), + autoResolutionMs: numberOrNull(params.autoResolutionMs), }; } -function pendingUserInputQuestion(value: unknown): PendingUserInputQuestion { - const question = asRecordOrEmpty(value); - const options = question["options"]; +function pendingUserInputQuestion(question: UserInputQuestion): PendingUserInputQuestion { + const options: unknown = question.options; return { - id: stringValue(question["id"]), - header: stringValue(question["header"]), - question: stringValue(question["question"]), - isOther: question["isOther"] === true, - isSecret: question["isSecret"] === true, + id: stringValue(question.id), + header: stringValue(question.header), + question: stringValue(question.question), + isOther: question.isOther, + isSecret: question.isSecret, options: Array.isArray(options) ? options.map((option) => { const record = asRecordOrEmpty(option); @@ -599,22 +579,33 @@ function pendingUserInputQuestion(value: unknown): PendingUserInputQuestion { }; } -function mcpElicitationParams(value: unknown): NormalizedMcpElicitationParams | null { - const params = asRecordOrNull(value); - if (!params) return null; - const mode = params["mode"]; - if (mode !== "form" && mode !== "url") return null; - return { - threadId: stringValue(params["threadId"]), - turnId: nullableString(params["turnId"]), - serverName: stringValue(params["serverName"]), - mode, - message: stringValue(params["message"]), - meta: params["_meta"] ?? null, - requestedSchema: params["requestedSchema"], - url: stringValue(params["url"]), - elicitationId: stringValue(params["elicitationId"]), - }; +function normalizeMcpElicitationParams(params: McpElicitationParams): NormalizedMcpElicitationParams | null { + switch (params.mode) { + case "form": + case "openai/form": + return { + threadId: stringValue(params.threadId), + turnId: nullableString(params.turnId), + serverName: stringValue(params.serverName), + mode: "form", + message: stringValue(params.message), + meta: params._meta ?? null, + requestedSchema: params.requestedSchema, + url: "", + elicitationId: "", + }; + case "url": + return { + threadId: stringValue(params.threadId), + turnId: nullableString(params.turnId), + serverName: stringValue(params.serverName), + mode: "url", + message: stringValue(params.message), + meta: params._meta ?? null, + url: stringValue(params.url), + elicitationId: stringValue(params.elicitationId), + }; + } } function numberOrNull(value: unknown): number | null { diff --git a/src/app-server/protocol/turn.ts b/src/app-server/protocol/turn.ts index b005caff..3aa4d91d 100644 --- a/src/app-server/protocol/turn.ts +++ b/src/app-server/protocol/turn.ts @@ -4,126 +4,13 @@ import { type ThreadConversationSummary, type ThreadTranscriptEntry, } from "../../domain/threads/transcript"; +import type { ThreadItem as GeneratedThreadItem } from "../../generated/app-server/v2/ThreadItem"; +import type { Turn as GeneratedTurn } from "../../generated/app-server/v2/Turn"; -type AppServerUserInput = - | { type: "text"; text: string; text_elements: AppServerTextElement[] } - | { type: "image"; url: string; detail?: "auto" | "low" | "high" | "original" } - | { type: "localImage"; path: string; detail?: "auto" | "low" | "high" | "original" } - | { type: "mention"; name: string; path: string } - | { type: "skill"; name: string; path: string }; -interface AppServerTextElement { - byteRange: { start: number; end: number }; - placeholder: string | null; -} -type TurnItemsView = "notLoaded" | "summary" | "full"; -type TurnStatus = "completed" | "interrupted" | "failed" | "inProgress"; -type CommandAction = - | { type: "read"; command: string; name: string; path: string | null } - | { type: "search"; command: string; query: string | null; path: string | null } - | { type: "listFiles"; command: string; path: string | null } - | { type: "unknown"; command: string }; -type WebSearchAction = - | { type: "search"; query: string | null; queries: string[] | null } - | { type: "openPage"; url: string | null } - | { type: "findInPage"; url: string | null; pattern: string | null } - | { type: "other" }; -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" } }; +export type TurnItem = GeneratedThreadItem; +export type TurnRecord = GeneratedTurn; -interface TurnError { - message: string; - codexErrorInfo: AppServerCodexErrorInfo | null; - additionalDetails: string | null; -} - -interface BaseTurnItem { - type: Type; - id: string; -} - -export type TurnItem = - | (BaseTurnItem<"userMessage"> & { clientId: string | null; content: AppServerUserInput[] }) - | (BaseTurnItem<"hookPrompt"> & { fragments: { text: string; [key: string]: unknown }[] }) - | (BaseTurnItem<"agentMessage"> & { text: string; phase: string | null; memoryCitation: unknown }) - | (BaseTurnItem<"plan"> & { text: string }) - | (BaseTurnItem<"reasoning"> & { summary: string[]; content: string[] }) - | (BaseTurnItem<"commandExecution"> & { - command: string; - cwd: string; - processId: string | null; - source: string; - status: string; - commandActions: CommandAction[]; - aggregatedOutput: string | null; - exitCode: number | null; - durationMs: number | null; - }) - | (BaseTurnItem<"fileChange"> & { changes: { path: string; kind: { type: string }; diff: string }[]; status: string }) - | (BaseTurnItem<"mcpToolCall"> & { - server: string; - tool: string; - status: string; - arguments: unknown; - appContext: unknown; - pluginId: string | null; - result: unknown; - error: { message?: string; [key: string]: unknown } | null; - durationMs: number | null; - }) - | (BaseTurnItem<"dynamicToolCall"> & { - namespace: string | null; - tool: string; - arguments: unknown; - status: string; - contentItems: unknown[] | null; - success: boolean | null; - durationMs: number | null; - }) - | (BaseTurnItem<"collabAgentToolCall"> & { - tool: string; - status: string; - senderThreadId: string; - receiverThreadIds: string[]; - prompt: string | null; - model: string | null; - reasoningEffort: string | null; - agentsStates: Record; - }) - | (BaseTurnItem<"subAgentActivity"> & { kind: string; agentThreadId: string; agentPath: string }) - | (BaseTurnItem<"webSearch"> & { query: string; action: WebSearchAction | null }) - | (BaseTurnItem<"imageView"> & { path: string }) - | (BaseTurnItem<"sleep"> & { durationMs: number }) - | (BaseTurnItem<"imageGeneration"> & { status: string; revisedPrompt: string | null; result: string; savedPath?: string }) - | (BaseTurnItem<"enteredReviewMode"> & { review: string }) - | (BaseTurnItem<"exitedReviewMode"> & { review: string }) - | BaseTurnItem<"contextCompaction">; - -export interface TurnRecord { - id: string; - items: TurnItem[]; - itemsView: TurnItemsView; - status: TurnStatus; - error: TurnError | null; - startedAt: number | null; - completedAt: number | null; - durationMs: number | null; -} +type AppServerUserInput = Extract["content"][number]; function transcriptEntriesFromTurnRecord(turn: TurnRecord): ThreadTranscriptEntry[] { return turn.items.flatMap((item) => transcriptEntriesFromTurnItem(item, turn)); diff --git a/tests/app-server/protocol/server-requests/mcp-elicitation.test.ts b/tests/app-server/protocol/server-requests/mcp-elicitation.test.ts index 7ea6a233..e4ebc7d3 100644 --- a/tests/app-server/protocol/server-requests/mcp-elicitation.test.ts +++ b/tests/app-server/protocol/server-requests/mcp-elicitation.test.ts @@ -91,6 +91,20 @@ describe("MCP elicitation request model", () => { expect(contentForPendingMcpElicitation(input, new Map())).toBeNull(); }); + it("maps OpenAI form mode through the normal form model", () => { + const input = expectPresent(toPendingMcpElicitation(openAiFormRequest())); + + expect(input).toMatchObject({ + requestId: 45, + params: { + mode: "form", + serverName: "github", + }, + }); + if (input.params.mode !== "form") throw new Error("Expected form mode"); + expect(input.params.fields).toEqual([expect.objectContaining({ id: "title", type: "string" })]); + }); + it("normalizes malformed schema fields without leaking invalid values", () => { const input = expectPresent(toPendingMcpElicitation(malformedSchemaRequest())); if (input.params.mode !== "form") throw new Error("Expected form mode"); @@ -187,6 +201,27 @@ function urlRequest(): ServerRequest { }; } +function openAiFormRequest(): ServerRequest { + return { + id: 45, + method: "mcpServer/elicitation/request", + params: { + threadId: "thread", + turnId: null, + serverName: "github", + mode: "openai/form", + _meta: null, + message: "Provide issue details", + requestedSchema: { + type: "object", + properties: { + title: { type: "string", title: "Title" }, + }, + }, + }, + }; +} + function malformedSchemaRequest(): ServerRequest { return { id: 44, diff --git a/tests/scripts/grit-policy.test.mjs b/tests/scripts/grit-policy.test.mjs index 3efafbab..f682cf56 100644 --- a/tests/scripts/grit-policy.test.mjs +++ b/tests/scripts/grit-policy.test.mjs @@ -731,13 +731,8 @@ export const value = statusText; expect(pluginMessages(report, "src/app-server/services/runtime-overrides.ts")).toEqual([ "Keep generated app-server types behind src/app-server adapters; expose Panel-owned models outside raw app-server boundaries.", ]); - expect(pluginMessages(report, "src/app-server/protocol/turn.ts")).toEqual([ - "Keep generated app-server types behind src/app-server adapters; expose Panel-owned models outside raw app-server boundaries.", - ]); - expect(pluginMessages(report, "src/app-server/protocol/server-requests.ts")).toEqual([ - "Keep generated app-server types behind src/app-server adapters; expose Panel-owned models outside raw app-server boundaries.", - "Keep generated app-server types behind src/app-server adapters; expose Panel-owned models outside raw app-server boundaries.", - ]); + expect(pluginDiagnostics(report, "src/app-server/protocol/turn.ts")).toEqual([]); + expect(pluginDiagnostics(report, "src/app-server/protocol/server-requests.ts")).toEqual([]); }); it("keeps TSX files in rendering-owned source folders", async () => {