mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Make app-server protocol adapters generated-aware
This commit is contained in:
parent
526b7e981e
commit
207aefdf11
5 changed files with 107 additions and 193 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue