Make app-server protocol adapters generated-aware

This commit is contained in:
murashit 2026-06-28 14:01:40 +09:00
parent 526b7e981e
commit 207aefdf11
5 changed files with 107 additions and 193 deletions

View file

@ -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",

View file

@ -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<AppServerRequest, { method: "item/commandExecution/requestApproval" }>;
type FileChangeApprovalRequest = Extract<AppServerRequest, { method: "item/fileChange/requestApproval" }>;
type PermissionsApprovalRequest = Extract<AppServerRequest, { method: "item/permissions/requestApproval" }>;
type UserInputRequest = Extract<AppServerRequest, { method: "item/tool/requestUserInput" }>;
type McpElicitationRequest = Extract<AppServerRequest, { method: "mcpServer/elicitation/request" }>;
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<string, unknown> {
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 {

View file

@ -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 extends string> {
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<string, { status?: string | null; message?: string | null } | undefined>;
})
| (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<TurnItem, { type: "userMessage" }>["content"][number];
function transcriptEntriesFromTurnRecord(turn: TurnRecord): ThreadTranscriptEntry[] {
return turn.items.flatMap((item) => transcriptEntriesFromTurnItem(item, turn));

View file

@ -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,

View file

@ -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 () => {