Move chat server request protocol behind app-server boundary

This commit is contained in:
murashit 2026-06-26 17:29:05 +09:00
parent 638cecb991
commit bcf7e10e6e
11 changed files with 394 additions and 145 deletions

View file

@ -17,12 +17,7 @@
},
{
"path": "./scripts/lint/no-generated-app-server-boundary-imports.grit",
"includes": [
"**/src/**/*.ts",
"**/src/**/*.tsx",
"!**/src/app-server/connection/**",
"!**/src/app-server/protocol/server-requests.ts"
]
"includes": ["**/src/**/*.ts", "**/src/**/*.tsx", "!**/src/app-server/connection/**"]
},
// Shared source layering boundaries.

View file

@ -16,11 +16,7 @@ js_module_reference() as $stmt where {
not {
$filename <: r".*/src/features/chat/app-server/mappers/message-stream/turn-items\.ts$",
$source <: r"^[\"'](?:(?:\.\./)+app-server/protocol/turn|src/app-server/protocol/turn)(?:/.*)?[\"']$"
},
not {
$filename <: r".*/src/features/chat/app-server/inbound/server-requests/.*",
$source <: r"^[\"'](?:(?:\.\./)+app-server/protocol/server-requests|src/app-server/protocol/server-requests)(?:/.*)?[\"']$"
}
},
register_diagnostic(span=$stmt, message="Source modules outside root src/app-server must use domain models and app-server services instead of app-server protocol modules. Chat turn-item conversion may consume turn protocol, and chat request handling may consume server request protocol at their app-server boundaries; feature state and UI must use Panel-owned models.", severity="error")
register_diagnostic(span=$stmt, message="Source modules outside root src/app-server must use domain models and app-server services instead of app-server protocol modules. Chat turn-item conversion may consume turn protocol at its app-server boundary; feature state and UI must use Panel-owned models.", severity="error")
}

View file

@ -10,24 +10,103 @@ import type {
PendingMcpElicitationField,
PendingMcpElicitationOption,
PendingUserInput,
PendingUserInputQuestion,
} from "../../domain/pending-requests/model";
import type { ServerRequest } from "../../generated/app-server/ServerRequest";
import { pathRelativeToRoot } from "../../shared/path/file-paths";
import { jsonPreview } from "../../shared/text/preview";
type AppServerRequestByMethod<Method extends ServerRequest["method"]> = Extract<ServerRequest, { method: Method }>;
interface AppServerGrantedPermissionProfile {
network?: unknown;
fileSystem?: unknown;
}
type SimpleApprovalDecision = "accept" | "acceptForSession" | "decline" | "cancel";
type CommandApprovalRequest = AppServerRequestByMethod<"item/commandExecution/requestApproval">;
type CommandApprovalParams = CommandApprovalRequest["params"];
type CommandApprovalDecision = NonNullable<CommandApprovalParams["availableDecisions"]>[number];
type FileChangeApprovalParams = AppServerRequestByMethod<"item/fileChange/requestApproval">["params"];
type PermissionsApprovalParams = AppServerRequestByMethod<"item/permissions/requestApproval">["params"];
type CommandApprovalDecision =
| SimpleApprovalDecision
| { acceptWithExecpolicyAmendment: unknown }
| { applyNetworkPolicyAmendment: { network_policy_amendment: { action?: unknown; host?: unknown } } };
type AppServerRequest = {
method: string;
id: PendingApproval["requestId"];
params: unknown;
};
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 AppServerMcpElicitationPrimitiveSchema =
| {
type: "boolean";
title?: unknown;
description?: unknown;
default?: unknown;
}
| {
type: "number" | "integer";
title?: unknown;
description?: unknown;
default?: unknown;
minimum?: unknown;
maximum?: unknown;
}
| {
type: "array";
title?: unknown;
description?: unknown;
default?: unknown;
minItems?: unknown;
maxItems?: unknown;
items?: unknown;
}
| {
type: "string";
title?: unknown;
description?: unknown;
default?: unknown;
format?: unknown;
minLength?: unknown;
maxLength?: unknown;
oneOf?: unknown;
enum?: unknown;
enumNames?: unknown;
};
interface NormalizedMcpElicitationParams {
threadId: string;
turnId: string | null;
serverName: string;
mode: "form" | "url";
message: string;
meta: unknown;
requestedSchema?: unknown;
url: string;
elicitationId: string;
}
export type AppServerApprovalResponse =
| { decision: CommandApprovalDecision }
@ -37,27 +116,20 @@ export interface AppServerUserInputResponse {
answers: Record<string, { answers: string[] }>;
}
type AppServerMcpElicitationRequest = AppServerRequestByMethod<"mcpServer/elicitation/request">;
type AppServerMcpElicitationFormParams = Extract<AppServerMcpElicitationRequest["params"], { mode: "form" }>;
type AppServerMcpElicitationSchema = NonNullable<AppServerMcpElicitationFormParams["requestedSchema"]>;
type AppServerMcpElicitationPrimitiveSchema = NonNullable<
Extract<AppServerMcpElicitationSchema, { properties: unknown }>["properties"][string]
>;
export interface AppServerMcpElicitationResponse {
action: McpElicitationAction;
content: unknown;
_meta: unknown;
}
export function appServerApprovalRequest(request: ServerRequest): PendingApproval | null {
export function appServerApprovalRequest(request: AppServerRequest): PendingApproval | null {
switch (request.method) {
case "item/commandExecution/requestApproval":
return commandApprovalRequest(request.id, request.params);
return commandApprovalRequest(request.id, asRecordOrEmpty(request.params));
case "item/fileChange/requestApproval":
return fileChangeApprovalRequest(request.id, request.params);
return fileChangeApprovalRequest(request.id, asRecordOrEmpty(request.params));
case "item/permissions/requestApproval":
return permissionsApprovalRequest(request.id, request.params);
return permissionsApprovalRequest(request.id, asRecordOrEmpty(request.params));
default:
return null;
}
@ -68,11 +140,13 @@ export function appServerApprovalResponse(approval: PendingApproval, action: App
return approvalResponseForIntent(approval, action);
}
export function appServerUserInputRequest(request: ServerRequest): PendingUserInput | null {
export function appServerUserInputRequest(request: AppServerRequest): PendingUserInput | null {
if (request.method !== "item/tool/requestUserInput") return null;
const params = pendingUserInputParams(request.params);
if (!params) return null;
return {
requestId: request.id,
params: request.params,
params,
};
}
@ -92,9 +166,10 @@ export function appServerUserInputResponse(
};
}
export function appServerMcpElicitationRequest(request: ServerRequest): PendingMcpElicitation | null {
export function appServerMcpElicitationRequest(request: AppServerRequest): PendingMcpElicitation | null {
if (request.method !== "mcpServer/elicitation/request") return null;
const params = request.params;
const params = mcpElicitationParams(request.params);
if (!params) return null;
if (params.mode === "url") {
return {
requestId: request.id,
@ -104,7 +179,7 @@ export function appServerMcpElicitationRequest(request: ServerRequest): PendingM
serverName: params.serverName,
mode: "url",
message: params.message,
meta: params._meta,
meta: params.meta,
url: params.url,
elicitationId: params.elicitationId,
},
@ -118,7 +193,7 @@ export function appServerMcpElicitationRequest(request: ServerRequest): PendingM
serverName: params.serverName,
mode: "form",
message: params.message,
meta: params._meta,
meta: params.meta,
fields: mcpElicitationFieldsFromRequestedSchema(params.requestedSchema),
},
};
@ -140,7 +215,7 @@ function commandApprovalRequest(requestId: PendingApproval["requestId"], params:
return {
requestId,
kind: "command",
turnId: params.turnId,
turnId: nullableString(params.turnId),
title: "Command approval",
summary: approvalSummary(params.reason, params.command, "Command execution requested."),
resultSummary: approvalResultSummary(params.reason, params.command, "Command execution requested."),
@ -152,11 +227,11 @@ function commandApprovalRequest(requestId: PendingApproval["requestId"], params:
function fileChangeApprovalRequest(requestId: PendingApproval["requestId"], params: FileChangeApprovalParams): PendingApproval {
const reason = params.reason ?? null;
const grantRoot = params.grantRoot ?? null;
const grantRoot = nonEmptyString(params.grantRoot);
return {
requestId,
kind: "fileChange",
turnId: params.turnId,
turnId: nullableString(params.turnId),
title: "File change approval",
summary: approvalSummary(reason, grantRoot ? `grant root: ${grantRoot}` : null, "Allow file changes?"),
resultSummary: approvalResultSummary(reason, grantRoot ? `grant root: ${grantRoot}` : null, "Allow file changes?"),
@ -168,6 +243,8 @@ function fileChangeApprovalRequest(requestId: PendingApproval["requestId"], para
function permissionsApprovalRequest(requestId: PendingApproval["requestId"], params: PermissionsApprovalParams): PendingApproval {
const details: ApprovalDetailRow[] = [];
const cwd = stringValue(params.cwd);
const cwdSummary = cwd ? `cwd: ${cwd}` : null;
addOptional(details, "reason", params.reason);
addOptional(details, "cwd", params.cwd);
addOptional(details, "environment", params.environmentId);
@ -175,10 +252,10 @@ function permissionsApprovalRequest(requestId: PendingApproval["requestId"], par
return {
requestId,
kind: "permission",
turnId: params.turnId,
turnId: nullableString(params.turnId),
title: "Permission approval",
summary: approvalSummary(params.reason, `cwd: ${params.cwd}`, "Permission change requested."),
resultSummary: approvalResultSummary(params.reason, `cwd: ${params.cwd}`, "Permission change requested."),
summary: approvalSummary(params.reason, cwdSummary, "Permission change requested."),
resultSummary: approvalResultSummary(params.reason, cwdSummary, "Permission change requested."),
details,
responses: {
accept: { scope: "turn", permissions: grantedPermissions(params.permissions) },
@ -224,10 +301,11 @@ function fileChangeDecision(intent: ApprovalActionIntent): SimpleApprovalDecisio
return "decline";
}
function grantedPermissions(requested: { network?: unknown; fileSystem?: unknown }): AppServerGrantedPermissionProfile {
function grantedPermissions(requested: unknown): AppServerGrantedPermissionProfile {
const profile = asRecordOrNull(requested);
const granted: AppServerGrantedPermissionProfile = {};
if (requested.network) granted.network = requested.network;
if (requested.fileSystem) granted.fileSystem = requested.fileSystem;
if (profile?.["network"]) granted.network = profile["network"];
if (profile?.["fileSystem"]) granted.fileSystem = profile["fileSystem"];
return granted;
}
@ -246,11 +324,12 @@ interface NetworkApprovalContext {
function commandApprovalDetails(params: CommandApprovalParams): ApprovalDetailRow[] {
const rows: ApprovalDetailRow[] = [];
const cwd = nullableString(params.cwd);
addOptional(rows, "reason", params.reason);
addOptional(rows, "command", params.command);
addOptional(rows, "cwd", params.cwd);
addOptional(rows, "network", networkApprovalContextLabel(params.networkApprovalContext));
rows.push(...commandActionRows(params.commandActions, params.cwd));
rows.push(...commandActionRows(params.commandActions, cwd));
rows.push(...prefixedPermissionRows("additional", params.additionalPermissions));
addOptional(rows, "future command rule", params.proposedExecpolicyAmendment);
addOptional(rows, "future network rules", networkPolicyAmendmentsLabel(params.proposedNetworkPolicyAmendments));
@ -258,8 +337,9 @@ function commandApprovalDetails(params: CommandApprovalParams): ApprovalDetailRo
}
function commandApprovalActionOptions(decisions: CommandApprovalParams["availableDecisions"]): PendingApprovalOption[] | null {
if (!decisions || decisions.length === 0) return null;
return decisions.map((decision, index) => {
const availableDecisions = commandApprovalDecisions(decisions);
if (availableDecisions.length === 0) return null;
return availableDecisions.map((decision, index) => {
const intent = commandDecisionIntent(decision);
return {
id: `approval-option:${String(index)}:${commandDecisionKey(decision)}`,
@ -274,6 +354,20 @@ function commandApprovalActionOptions(decisions: CommandApprovalParams["availabl
});
}
function commandApprovalDecisions(value: unknown): CommandApprovalDecision[] {
if (!Array.isArray(value)) return [];
return value.filter(isCommandApprovalDecision);
}
function isCommandApprovalDecision(value: unknown): value is CommandApprovalDecision {
if (typeof value === "string") return true;
const decision = asRecordOrNull(value);
if (!decision) return false;
if ("acceptWithExecpolicyAmendment" in decision) return true;
const networkDecision = asRecordOrNull(decision["applyNetworkPolicyAmendment"]);
return Boolean(asRecordOrNull(networkDecision?.["network_policy_amendment"]));
}
function commandDecisionIntent(decision: CommandApprovalDecision): ApprovalActionIntent {
if (typeof decision === "string") return simpleCommandDecisionIntent(decision);
if ("acceptWithExecpolicyAmendment" in decision) return "accept-session";
@ -313,7 +407,7 @@ function commandDecisionKey(decision: CommandApprovalDecision): string {
if ("applyNetworkPolicyAmendment" in decision) {
const amendment = decision.applyNetworkPolicyAmendment.network_policy_amendment;
const host = amendment.host;
return `applyNetworkPolicyAmendment:${amendment.action}:${typeof host === "string" ? host : ""}`;
return `applyNetworkPolicyAmendment:${stringValue(amendment.action)}:${typeof host === "string" ? host : ""}`;
}
return "unknown";
}
@ -462,29 +556,91 @@ function nonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function nullableString(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
function asRecordOrNull(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
}
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;
return {
threadId: stringValue(params["threadId"]),
turnId: stringValue(params["turnId"]),
itemId: stringValue(params["itemId"]),
questions: questions.map(pendingUserInputQuestion),
autoResolutionMs: numberOrNull(params["autoResolutionMs"]),
};
}
function pendingUserInputQuestion(value: unknown): PendingUserInputQuestion {
const question = asRecordOrEmpty(value);
const options = question["options"];
return {
id: stringValue(question["id"]),
header: stringValue(question["header"]),
question: stringValue(question["question"]),
isOther: question["isOther"] === true,
isSecret: question["isSecret"] === true,
options: Array.isArray(options)
? options.map((option) => {
const record = asRecordOrEmpty(option);
return { label: stringValue(record["label"]), description: stringValue(record["description"]) };
})
: null,
};
}
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 numberOrNull(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function mcpElicitationFieldsFromRequestedSchema(schema: unknown): PendingMcpElicitationField[] {
if (!isMcpElicitationObjectSchema(schema)) return [];
return mcpElicitationFieldsFromSchema(schema.properties, new Set(schema.required ?? []));
}
function isMcpElicitationObjectSchema(schema: unknown): schema is Extract<AppServerMcpElicitationSchema, { properties: unknown }> {
return Boolean(schema && typeof schema === "object" && "properties" in schema && typeof schema.properties === "object");
}
function mcpElicitationFieldsFromSchema(
properties: Record<string, AppServerMcpElicitationPrimitiveSchema | undefined>,
required: ReadonlySet<string>,
): PendingMcpElicitationField[] {
return Object.entries(properties).flatMap(([id, schema]) => {
if (!schema) return [];
return [mcpElicitationFieldFromSchema(id, schema, required.has(id))];
const record = asRecordOrNull(schema);
const properties = asRecordOrNull(record?.["properties"]);
if (!properties) return [];
const required = new Set(
Array.isArray(record?.["required"]) ? record["required"].filter((item): item is string => typeof item === "string") : [],
);
return Object.entries(properties).flatMap(([id, fieldSchema]) => {
if (!isMcpElicitationPrimitiveSchema(fieldSchema)) return [];
return [mcpElicitationFieldFromSchema(id, fieldSchema, required.has(id))];
});
}
function isMcpElicitationPrimitiveSchema(schema: unknown): schema is AppServerMcpElicitationPrimitiveSchema {
const record = asRecordOrNull(schema);
if (!record) return false;
const type = record["type"];
return type === "boolean" || type === "number" || type === "integer" || type === "array" || type === "string";
}
function mcpElicitationFieldFromSchema(
id: string,
schema: AppServerMcpElicitationPrimitiveSchema,
@ -492,68 +648,100 @@ function mcpElicitationFieldFromSchema(
): PendingMcpElicitationField {
const base = {
id,
title: schema.title ?? id,
description: schema.description ?? null,
title: nonEmptyString(schema.title) ?? id,
description: nonEmptyString(schema.description),
required,
};
if (schema.type === "boolean") {
return { ...base, type: "boolean", defaultValue: schema.default ?? false };
switch (schema.type) {
case "boolean":
return { ...base, type: "boolean", defaultValue: schema.default === true };
case "number":
case "integer":
return {
...base,
type: schema.type,
minimum: numberOrNull(schema.minimum),
maximum: numberOrNull(schema.maximum),
defaultValue: numberOrNull(schema.default),
};
case "array":
return {
...base,
type: "multi-select",
options: multiSelectOptions(schema),
minItems: bigintToNumberOrNull(schema.minItems),
maxItems: bigintToNumberOrNull(schema.maxItems),
defaultValue: stringArrayOrEmpty(schema.default),
};
case "string": {
const options = singleSelectOptions(schema);
if (options.length > 0) {
return {
...base,
type: "single-select",
options,
defaultValue: typeof schema.default === "string" ? schema.default : (options[0]?.value ?? ""),
};
}
return {
...base,
type: "string",
format: nullableString(schema.format),
minLength: numberOrNull(schema.minLength),
maxLength: numberOrNull(schema.maxLength),
defaultValue: typeof schema.default === "string" ? schema.default : "",
};
}
}
if (schema.type === "number" || schema.type === "integer") {
return {
...base,
type: schema.type,
minimum: schema.minimum ?? null,
maximum: schema.maximum ?? null,
defaultValue: schema.default ?? null,
};
}
if (schema.type === "array") {
return {
...base,
type: "multi-select",
options: multiSelectOptions(schema),
minItems: bigintToNumberOrNull(schema.minItems),
maxItems: bigintToNumberOrNull(schema.maxItems),
defaultValue: schema.default ?? [],
};
}
if ("oneOf" in schema || "enum" in schema) {
const options = singleSelectOptions(schema);
return {
...base,
type: "single-select",
options,
defaultValue: schema.default ?? options[0]?.value ?? "",
};
}
const stringSchema = schema as Extract<AppServerMcpElicitationPrimitiveSchema, { type: "string" }>;
return {
...base,
type: "string",
format: "format" in stringSchema ? (stringSchema.format ?? null) : null,
minLength: "minLength" in stringSchema ? (stringSchema.minLength ?? null) : null,
maxLength: "maxLength" in stringSchema ? (stringSchema.maxLength ?? null) : null,
defaultValue: typeof stringSchema.default === "string" ? stringSchema.default : "",
};
}
function singleSelectOptions(schema: Extract<AppServerMcpElicitationPrimitiveSchema, { type: "string" }>): PendingMcpElicitationOption[] {
if ("oneOf" in schema) return schema.oneOf.map((option) => ({ value: option.const, label: option.title }));
if ("enum" in schema) {
const enumNames = "enumNames" in schema ? schema.enumNames : undefined;
if (enumNames) return schema.enum.map((value, index) => ({ value, label: enumNames[index] ?? value }));
return schema.enum.map((value) => ({ value, label: value }));
if (Array.isArray(schema.oneOf)) {
const options = schema.oneOf.flatMap((option) => {
const selected = selectOption(option);
return selected ? [selected] : [];
});
if (options.length > 0) return options;
}
return [];
return enumOptions(schema.enum, schema.enumNames);
}
function multiSelectOptions(schema: Extract<AppServerMcpElicitationPrimitiveSchema, { type: "array" }>): PendingMcpElicitationOption[] {
if ("anyOf" in schema.items) return schema.items.anyOf.map((option) => ({ value: option.const, label: option.title }));
return schema.items.enum.map((value) => ({ value, label: value }));
const items = asRecordOrNull(schema.items);
const anyOf = items?.["anyOf"];
if (Array.isArray(anyOf)) {
const options = anyOf.flatMap((option) => {
const selected = selectOption(option);
return selected ? [selected] : [];
});
if (options.length > 0) return options;
}
return enumOptions(items?.["enum"], null);
}
function bigintToNumberOrNull(value: bigint | number | undefined): number | null {
function stringArrayOrEmpty(value: unknown): readonly string[] {
if (!Array.isArray(value)) return [];
if (!value.every((item) => typeof item === "string")) return [];
return value;
}
function enumOptions(values: unknown, labels: unknown): PendingMcpElicitationOption[] {
if (!Array.isArray(values)) return [];
const labelValues = Array.isArray(labels) ? labels : [];
return values.flatMap((value, index) => {
if (typeof value !== "string") return [];
return [{ value, label: nonEmptyString(labelValues[index]) ?? value }];
});
}
function selectOption(value: unknown): PendingMcpElicitationOption | null {
const record = asRecordOrNull(value);
const optionValue = nullableString(record?.["const"]);
if (optionValue === null) return null;
return { value: optionValue, label: nonEmptyString(record?.["title"]) ?? optionValue };
}
function bigintToNumberOrNull(value: unknown): number | null {
if (typeof value === "bigint") return Number(value);
return typeof value === "number" && Number.isFinite(value) ? value : null;
}

View file

@ -1,12 +1,3 @@
import type { ServerRequest } from "../../../../../app-server/connection/rpc-messages";
import {
appServerApprovalRequest,
appServerApprovalResponse,
appServerMcpElicitationRequest,
appServerMcpElicitationResponse,
appServerUserInputRequest,
appServerUserInputResponse,
} from "../../../../../app-server/protocol/server-requests";
import type {
ApprovalAction,
McpElicitationAction,
@ -14,14 +5,23 @@ import type {
PendingApproval,
PendingMcpElicitation,
PendingUserInput,
} from "../../../../../domain/pending-requests/model";
} from "../domain/pending-requests/model";
import type { ServerRequest } from "./connection/rpc-messages";
import {
appServerApprovalRequest,
appServerApprovalResponse,
appServerMcpElicitationRequest,
appServerMcpElicitationResponse,
appServerUserInputRequest,
appServerUserInputResponse,
} from "./protocol/server-requests";
import {
type ActiveRouteScope,
fallbackMessageScope,
isMessageScopeInActiveRouteScope,
isTurnScopedMessageForIdleActiveThread,
type MessageScope,
} from "../route-scope";
} from "./route-scope";
export type ServerRequestRoute =
| { kind: "approval"; request: ServerRequest; approval: PendingApproval }

View file

@ -1,4 +1,11 @@
import type { RequestId, ServerNotification, ServerRequest } from "../../../../app-server/connection/rpc-messages";
import {
routeServerRequest,
serverRequestApprovalResponse,
serverRequestCurrentTimeResponse,
serverRequestMcpElicitationResponse,
serverRequestUserInputResponse,
} from "../../../../app-server/server-requests";
import {
type ApprovalAction,
contentForPendingMcpElicitation,
@ -24,13 +31,6 @@ import {
import type { AppServerResourceEvent } from "../actions/metadata";
import { classifyAppServerLog } from "./app-server-logs";
import { type ChatNotificationEffect, planChatNotification } from "./notification-plan";
import {
routeServerRequest,
serverRequestApprovalResponse,
serverRequestCurrentTimeResponse,
serverRequestMcpElicitationResponse,
serverRequestUserInputResponse,
} from "./server-requests/adapter";
function cannotSendApprovalResponseMessage(): string {
return "Could not send approval response because Codex app-server is not connected.";

View file

@ -5,7 +5,7 @@ import {
isMessageScopeInActiveRouteScope,
isTurnScopedMessageForIdleActiveThread,
type MessageScope,
} from "./route-scope";
} from "../../../../app-server/route-scope";
type ServerNotificationMethod = ServerNotification["method"];
type RoutedNotification<M extends ServerNotificationMethod> = Extract<ServerNotification, { method: M }>;

View file

@ -63,5 +63,5 @@ function slashPath(value: string): string {
}
function allowsGeneratedAppServerImport(relativePath: string): boolean {
return relativePath.startsWith("src/app-server/connection/") || relativePath === "src/app-server/protocol/server-requests.ts";
return relativePath.startsWith("src/app-server/connection/");
}

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import type { ServerNotification, ServerRequest } from "../../../../../src/app-server/connection/rpc-messages";
import { routeServerRequest } from "../../../../../src/app-server/server-requests";
import {
PLANNED_SERVER_NOTIFICATION_METHODS_BY_ROUTE_KIND,
planChatNotification,
@ -8,7 +9,6 @@ import {
ROUTED_SERVER_NOTIFICATION_METHODS_BY_ROUTE_KIND,
routeServerNotification,
} from "../../../../../src/features/chat/app-server/inbound/notification-routing";
import { routeServerRequest } from "../../../../../src/features/chat/app-server/inbound/server-requests/adapter";
import { chatStateFixture, chatStateWith } from "../../support/state";
const activeScope = { activeThreadId: "thread-active", activeTurnId: "turn-active" };

View file

@ -90,6 +90,37 @@ describe("MCP elicitation request model", () => {
});
expect(contentForPendingMcpElicitation(input, new Map())).toBeNull();
});
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");
expect(input.params.fields.map((field) => field.id)).toEqual(["badDefault", "brokenSelect", "enumSelect", "labels"]);
expect(input.params.fields.find((field) => field.id === "badDefault")).toMatchObject({
type: "boolean",
defaultValue: false,
});
expect(input.params.fields.find((field) => field.id === "brokenSelect")).toMatchObject({
type: "string",
format: null,
defaultValue: "",
});
expect(input.params.fields.find((field) => field.id === "enumSelect")).toMatchObject({
type: "single-select",
options: [
{ value: "low", label: "Low" },
{ value: "high", label: "High" },
],
});
expect(input.params.fields.find((field) => field.id === "labels")).toMatchObject({
type: "multi-select",
defaultValue: [],
options: [
{ value: "bug", label: "Bug" },
{ value: "docs", label: "docs" },
],
});
});
});
function formRequest(): ServerRequest {
@ -155,3 +186,35 @@ function urlRequest(): ServerRequest {
},
};
}
function malformedSchemaRequest(): ServerRequest {
return {
id: 44,
method: "mcpServer/elicitation/request",
params: {
threadId: "thread",
turnId: null,
serverName: "github",
mode: "form",
_meta: null,
message: "Provide issue details",
requestedSchema: {
type: "object",
properties: {
unsupported: { type: "object", title: "Unsupported" },
badDefault: { type: "boolean", title: "Bad default", default: "yes" },
brokenSelect: { type: "string", title: "Broken select", oneOf: { const: "low", title: "Low" }, format: 1 },
enumSelect: { type: "string", title: "Enum select", enum: ["low", 1, "high"], enumNames: ["Low", "One", "High"] },
labels: {
type: "array",
title: "Labels",
items: {
anyOf: [{ const: "bug", title: "Bug" }, { const: 1, title: "One" }, { const: "docs" }],
},
default: ["bug", 1],
},
},
},
},
} as unknown as ServerRequest;
}

View file

@ -15,7 +15,7 @@ const projectPluginByName = new Map(
}),
);
const APP_SERVER_PROTOCOL_BOUNDARY_MESSAGE =
"Source modules outside root src/app-server must use domain models and app-server services instead of app-server protocol modules. Chat turn-item conversion may consume turn protocol, and chat request handling may consume server request protocol at their app-server boundaries; feature state and UI must use Panel-owned models.";
"Source modules outside root src/app-server must use domain models and app-server services instead of app-server protocol modules. Chat turn-item conversion may consume turn protocol at its app-server boundary; feature state and UI must use Panel-owned models.";
let appServerBoundaryPolicyReportPromise;
let renderingAndCssPolicyReportPromise;
@ -314,7 +314,10 @@ export function timestamp(): number {
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(pluginDiagnostics(report, "src/app-server/protocol/server-requests.ts")).toEqual([]);
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.",
]);
});
it("keeps TSX files in rendering-owned source folders", async () => {
@ -351,7 +354,8 @@ export function timestamp(): number {
APP_SERVER_PROTOCOL_BOUNDARY_MESSAGE,
APP_SERVER_PROTOCOL_BOUNDARY_MESSAGE,
]);
expect(pluginMessages(report, "src/features/chat/app-server/inbound/server-requests/adapter.ts")).toEqual([
expect(pluginMessages(report, "src/features/chat/app-server/inbound/server-request-protocol-leak.ts")).toEqual([
APP_SERVER_PROTOCOL_BOUNDARY_MESSAGE,
APP_SERVER_PROTOCOL_BOUNDARY_MESSAGE,
]);
});
@ -398,10 +402,14 @@ export function timestamp(): number {
"Do not import app-server connection internals from this module. Keep connection usage at app-server adapters.",
]);
expect(pluginDiagnostics(report, "src/app-server/services/catalog.ts")).toEqual([]);
expect(pluginMessages(report, "src/domain/connection-client.ts")).toEqual([
"Domain modules must stay pure; outer layers may depend on domain, not the reverse.",
"Do not import app-server connection internals from this module. Keep connection usage at app-server adapters.",
]);
const domainConnectionMessages = pluginMessages(report, "src/domain/connection-client.ts");
expect(domainConnectionMessages).toHaveLength(2);
expect(domainConnectionMessages).toEqual(
expect.arrayContaining([
"Domain modules must stay pure; outer layers may depend on domain, not the reverse.",
"Do not import app-server connection internals from this module. Keep connection usage at app-server adapters.",
]),
);
expect(pluginMessages(report, "src/shared/connection-client.ts")).toEqual([
"Do not import app-server connection internals from this module. Keep connection usage at app-server adapters.",
]);
@ -661,7 +669,7 @@ export type Item = TurnItem;
`.trimStart(),
);
await writeFile(
path.join(cwd, "src/features/chat/app-server/inbound/server-requests/adapter.ts"),
path.join(cwd, "src/features/chat/app-server/inbound/server-request-protocol-leak.ts"),
`
import { appServerUserInputResponse } from "../../../../../app-server/protocol/server-requests";
@ -825,7 +833,7 @@ export type AppServerThreadResumeClient = Pick<AppServerClient, "resumeThread">;
"src/features/chat/ui/protocol-leak.tsx",
"src/features/chat/app-server/inbound/app-server-logs.ts",
"src/features/chat/app-server/mappers/message-stream/turn-items.ts",
"src/features/chat/app-server/inbound/server-requests/adapter.ts",
"src/features/chat/app-server/inbound/server-request-protocol-leak.ts",
"src/domain/threads/model.ts",
"src/domain/threads/format.ts",
"src/features/chat/domain/message-stream/selectors.ts",
@ -858,7 +866,6 @@ async function tempBiomeWorkspace(plugins) {
await mkdir(path.join(cwd, "src/features/chat/application/threads"), { recursive: true });
await mkdir(path.join(cwd, "src/features/chat/application/pending-requests"), { recursive: true });
await mkdir(path.join(cwd, "src/features/chat/app-server/inbound"), { recursive: true });
await mkdir(path.join(cwd, "src/features/chat/app-server/inbound/server-requests"), { recursive: true });
await mkdir(path.join(cwd, "src/features/chat/app-server/mappers/message-stream"), { recursive: true });
await mkdir(path.join(cwd, "src/features/chat/host"), { recursive: true });
await mkdir(path.join(cwd, "src/features/chat/panel"), { recursive: true });