mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Refine chat app-server protocol lint boundaries
This commit is contained in:
parent
4e107dadd3
commit
d7bb49efbe
13 changed files with 270 additions and 305 deletions
22
biome.jsonc
22
biome.jsonc
|
|
@ -13,27 +13,7 @@
|
|||
},
|
||||
{
|
||||
"path": "./scripts/lint/no-app-server-protocol-boundary-imports.grit",
|
||||
"includes": [
|
||||
"**/src/**/*.ts",
|
||||
"**/src/**/*.tsx",
|
||||
"!**/src/app-server/**/*.ts",
|
||||
"!**/src/app-server/**/*.tsx",
|
||||
"!**/src/features/chat/app-server/inbound/notification-plan.ts",
|
||||
"!**/src/features/chat/app-server/mappers/message-stream/turn-items.ts",
|
||||
"!**/src/features/chat/app-server/inbound/handler.ts",
|
||||
"!**/src/features/chat/app-server/inbound/routing.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "./scripts/lint/no-chat-app-server-turn-protocol-imports.grit",
|
||||
"includes": [
|
||||
"**/src/features/chat/app-server/inbound/notification-plan.ts",
|
||||
"**/src/features/chat/app-server/mappers/message-stream/turn-items.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "./scripts/lint/no-chat-app-server-request-protocol-imports.grit",
|
||||
"includes": ["**/src/features/chat/app-server/inbound/handler.ts", "**/src/features/chat/app-server/inbound/routing.ts"]
|
||||
"includes": ["**/src/**/*.ts", "**/src/**/*.tsx", "!**/src/app-server/**/*.ts", "!**/src/app-server/**/*.tsx"]
|
||||
},
|
||||
{
|
||||
"path": "./scripts/lint/no-generated-app-server-boundary-imports.grit",
|
||||
|
|
|
|||
|
|
@ -12,7 +12,15 @@ private pattern js_module_reference() {
|
|||
|
||||
js_module_reference() as $stmt where {
|
||||
$stmt <: contains `$source` where {
|
||||
$source <: r"^[\"'](?:(?:\.\./)+app-server/protocol|src/app-server/protocol)/[^/\"']+(?:/.*)?[\"']$"
|
||||
$source <: r"^[\"'](?:(?:\.\./)+app-server/protocol|src/app-server/protocol)/[^/\"']+(?:/.*)?[\"']$",
|
||||
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 app-server must use domain models and app-server services instead of app-server protocol modules. Chat ingestion and message-stream conversion may consume app-server turn protocol at the boundary; feature state and UI must use Panel-owned models.", severity="error")
|
||||
register_diagnostic(span=$stmt, message="Source modules outside 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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
language js
|
||||
|
||||
private pattern js_module_reference() {
|
||||
or {
|
||||
JsImport(),
|
||||
JsExportNamedFromClause(),
|
||||
JsExportFromClause(),
|
||||
TsImportType(),
|
||||
JsImportCallExpression()
|
||||
}
|
||||
}
|
||||
|
||||
js_module_reference() as $stmt where {
|
||||
$stmt <: contains `$source` where {
|
||||
$source <: r"^[\"'](?:(?:\.\./)+app-server/protocol|src/app-server/protocol)/[^/\"']+(?:/.*)?[\"']$",
|
||||
not { $source <: r"^[\"'](?:(?:\.\./)+app-server/protocol/server-requests|src/app-server/protocol/server-requests)(?:/.*)?[\"']$" }
|
||||
},
|
||||
register_diagnostic(span=$stmt, message="Chat app-server request handling may consume server request protocol projections only. Convert app-server payloads to chat pending request domain models at this boundary.", severity="error")
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
language js
|
||||
|
||||
private pattern js_module_reference() {
|
||||
or {
|
||||
JsImport(),
|
||||
JsExportNamedFromClause(),
|
||||
JsExportFromClause(),
|
||||
TsImportType(),
|
||||
JsImportCallExpression()
|
||||
}
|
||||
}
|
||||
|
||||
js_module_reference() as $stmt where {
|
||||
$stmt <: contains `$source` where {
|
||||
$source <: r"^[\"'](?:(?:\.\./)+app-server/protocol|src/app-server/protocol)/[^/\"']+(?:/.*)?[\"']$",
|
||||
not { $source <: r"^[\"'](?:(?:\.\./)+app-server/protocol/turn|src/app-server/protocol/turn)(?:/.*)?[\"']$" }
|
||||
},
|
||||
register_diagnostic(span=$stmt, 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.", severity="error")
|
||||
}
|
||||
|
|
@ -1,9 +1,4 @@
|
|||
import type { RequestId, ServerNotification, ServerRequest } from "../../../../app-server/connection/rpc-messages";
|
||||
import {
|
||||
appServerApprovalResponse,
|
||||
appServerMcpElicitationResponse,
|
||||
appServerUserInputResponse,
|
||||
} from "../../../../app-server/protocol/server-requests";
|
||||
import {
|
||||
type ApprovalAction,
|
||||
contentForPendingMcpElicitation,
|
||||
|
|
@ -29,7 +24,12 @@ import {
|
|||
import type { AppServerResourceEvent } from "../actions/metadata";
|
||||
import { classifyAppServerLog } from "./app-server-logs";
|
||||
import { type ChatNotificationEffect, planChatNotification } from "./notification-plan";
|
||||
import { routeServerRequest } from "./routing";
|
||||
import {
|
||||
serverRequestApprovalResponse,
|
||||
serverRequestMcpElicitationResponse,
|
||||
serverRequestUserInputResponse,
|
||||
} from "./server-requests/responses";
|
||||
import { routeServerRequest } from "./server-requests/routing";
|
||||
|
||||
function cannotSendApprovalResponseMessage(): string {
|
||||
return "Could not send approval response because Codex app-server is not connected.";
|
||||
|
|
@ -181,7 +181,7 @@ function handleAppServerLog(context: ChatInboundHandlerContext, message: string)
|
|||
function resolveApproval(context: ChatInboundHandlerContext, requestId: PendingRequestId, action: ApprovalAction): void {
|
||||
const approval = pendingApproval(context, requestId);
|
||||
if (!approval) return;
|
||||
if (!context.actions.respondToServerRequest(approval.requestId, appServerApprovalResponse(approval, action))) {
|
||||
if (!context.actions.respondToServerRequest(approval.requestId, serverRequestApprovalResponse(approval, action))) {
|
||||
addSystemMessage(context, cannotSendApprovalResponseMessage());
|
||||
return;
|
||||
}
|
||||
|
|
@ -191,7 +191,7 @@ function resolveApproval(context: ChatInboundHandlerContext, requestId: PendingR
|
|||
function resolveUserInput(context: ChatInboundHandlerContext, requestId: PendingRequestId, answers: Record<string, string>): void {
|
||||
const input = pendingUserInput(context, requestId);
|
||||
if (!input) return;
|
||||
if (!context.actions.respondToServerRequest(input.requestId, appServerUserInputResponse(input.params.questions, answers))) {
|
||||
if (!context.actions.respondToServerRequest(input.requestId, serverRequestUserInputResponse(input.params.questions, answers))) {
|
||||
addSystemMessage(context, cannotSendUserInputMessage());
|
||||
return;
|
||||
}
|
||||
|
|
@ -220,7 +220,7 @@ function resolveMcpElicitation(context: ChatInboundHandlerContext, requestId: Pe
|
|||
const elicitation = pendingMcpElicitation(context, requestId);
|
||||
if (!elicitation) return;
|
||||
const content = action === "accept" ? contentForPendingMcpElicitation(elicitation, state(context).requests.mcpElicitationDrafts) : null;
|
||||
if (!context.actions.respondToServerRequest(elicitation.requestId, appServerMcpElicitationResponse(action, content))) {
|
||||
if (!context.actions.respondToServerRequest(elicitation.requestId, serverRequestMcpElicitationResponse(action, content))) {
|
||||
addSystemMessage(context, cannotSendMcpElicitationMessage());
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type { ServerNotification } from "../../../../app-server/connection/rpc-messages";
|
||||
import { completedConversationSummaryFromTurnRecord, type TurnItem } from "../../../../app-server/protocol/turn";
|
||||
import { threadFromAppServerRecord } from "../../../../app-server/threads";
|
||||
import { threadTokenUsageFromRuntimeUsage } from "../../../../domain/runtime/metrics";
|
||||
import { normalizeExplicitThreadName } from "../../../../domain/threads/model";
|
||||
|
|
@ -30,6 +29,8 @@ import { hookRunMessageStreamItem } from "../mappers/message-stream/hook-run-ite
|
|||
import { createAutoReviewResultItem, createReviewResultItem } from "../mappers/message-stream/review-result-items";
|
||||
import { taskProgressMessageStreamItem } from "../mappers/message-stream/task-progress";
|
||||
import {
|
||||
type AppServerTurnItem,
|
||||
completedConversationSummaryFromAppServerTurn,
|
||||
messageStreamItemFromTurnItem,
|
||||
messageStreamItemsFromTurns,
|
||||
shouldSuppressLifecycleItem,
|
||||
|
|
@ -46,7 +47,7 @@ import {
|
|||
type TurnLifecycleNotificationMethod,
|
||||
type UserVisibleNoticeNotification,
|
||||
type UserVisibleNoticeNotificationMethod,
|
||||
} from "./routing";
|
||||
} from "./notification-routing";
|
||||
|
||||
export type ChatNotificationEffect =
|
||||
| { type: "refresh-threads" }
|
||||
|
|
@ -243,7 +244,7 @@ const TURN_LIFECYCLE_PLANNERS = {
|
|||
type: "maybe-name-thread",
|
||||
threadId: notification.params.threadId,
|
||||
turnId: notification.params.turn.id,
|
||||
completedSummary: completedConversationSummaryFromTurnRecord(notification.params.turn),
|
||||
completedSummary: completedConversationSummaryFromAppServerTurn(notification.params.turn),
|
||||
},
|
||||
{ type: "refresh-threads" },
|
||||
],
|
||||
|
|
@ -427,13 +428,13 @@ function autoApprovalReviewPlan(
|
|||
});
|
||||
}
|
||||
|
||||
function startedItemPlan(item: TurnItem, turnId: string): ChatNotificationPlan {
|
||||
function startedItemPlan(item: AppServerTurnItem, turnId: string): ChatNotificationPlan {
|
||||
if (shouldSuppressLifecycleItem(item)) return EMPTY_PLAN;
|
||||
const streamItem = messageStreamItemFromTurnItem(item, turnId);
|
||||
return streamItem ? actionPlan({ type: "message-stream/item-upserted", item: streamItem }) : EMPTY_PLAN;
|
||||
}
|
||||
|
||||
function completedItemPlan(item: TurnItem, turnId: string): ChatNotificationPlan {
|
||||
function completedItemPlan(item: AppServerTurnItem, turnId: string): ChatNotificationPlan {
|
||||
if (item.type === "userMessage") return EMPTY_PLAN;
|
||||
const streamItem = messageStreamItemFromTurnItem(item, turnId);
|
||||
if (!streamItem) return EMPTY_PLAN;
|
||||
|
|
|
|||
|
|
@ -1,27 +1,13 @@
|
|||
import type { ServerNotification, ServerRequest } from "../../../../app-server/connection/rpc-messages";
|
||||
import type { ServerNotification } from "../../../../app-server/connection/rpc-messages";
|
||||
import {
|
||||
appServerApprovalRequest,
|
||||
appServerMcpElicitationRequest,
|
||||
appServerUserInputRequest,
|
||||
} from "../../../../app-server/protocol/server-requests";
|
||||
import type { PendingApproval, PendingMcpElicitation, PendingUserInput } from "../../../../domain/pending-requests/model";
|
||||
|
||||
export interface ActiveRouteScope {
|
||||
activeThreadId: string | null;
|
||||
activeTurnId: string | null;
|
||||
}
|
||||
|
||||
export type ServerRequestRoute =
|
||||
| { kind: "approval"; request: ServerRequest; approval: PendingApproval }
|
||||
| { kind: "userInput"; request: ServerRequest; input: PendingUserInput }
|
||||
| { kind: "mcpElicitation"; request: ServerRequest; elicitation: PendingMcpElicitation }
|
||||
| { kind: "currentTime"; request: Extract<ServerRequest, { method: "currentTime/read" }> }
|
||||
| { kind: "unsupported"; request: ServerRequest }
|
||||
| { kind: "unknown"; request: ServerRequest }
|
||||
| { kind: "inactive"; request: ServerRequest };
|
||||
type ActiveRouteScope,
|
||||
fallbackMessageScope,
|
||||
isMessageScopeInActiveRouteScope,
|
||||
isTurnScopedMessageForIdleActiveThread,
|
||||
type MessageScope,
|
||||
} from "./route-scope";
|
||||
|
||||
type ServerNotificationMethod = ServerNotification["method"];
|
||||
type ServerRequestMethod = ServerRequest["method"];
|
||||
type RoutedNotification<M extends ServerNotificationMethod> = Extract<ServerNotification, { method: M }>;
|
||||
export type StreamUpdateNotification = RoutedNotification<StreamUpdateNotificationMethod>;
|
||||
export type TurnLifecycleNotification = RoutedNotification<TurnLifecycleNotificationMethod>;
|
||||
|
|
@ -40,18 +26,9 @@ export type ServerNotificationRoute =
|
|||
| { kind: "unhandled"; notification: ServerNotification }
|
||||
| { kind: "inactive"; notification: ServerNotification };
|
||||
|
||||
interface MessageScope {
|
||||
threadId: string | null;
|
||||
turnId: string | null;
|
||||
}
|
||||
|
||||
type ServerRequestRouteKindByMethod = Record<ServerRequestMethod, Exclude<ServerRequestRoute["kind"], "inactive" | "unknown">>;
|
||||
type ServerNotificationScopeExtractors = {
|
||||
[Method in ServerNotificationMethod]: (notification: Extract<ServerNotification, { method: Method }>) => MessageScope;
|
||||
};
|
||||
type ServerRequestScopeExtractors = {
|
||||
[Method in ServerRequestMethod]: (request: Extract<ServerRequest, { method: Method }>) => MessageScope;
|
||||
};
|
||||
|
||||
const GLOBALLY_ROUTED_THREAD_CATALOG_NOTIFICATION_METHODS = [
|
||||
"thread/started",
|
||||
|
|
@ -202,70 +179,11 @@ const SERVER_NOTIFICATION_SCOPE_EXTRACTORS: ServerNotificationScopeExtractors =
|
|||
"account/login/completed": unscopedNotificationScope,
|
||||
};
|
||||
|
||||
const SERVER_REQUEST_SCOPE_EXTRACTORS: ServerRequestScopeExtractors = {
|
||||
"item/commandExecution/requestApproval": threadTurnRequestScope,
|
||||
"item/fileChange/requestApproval": threadTurnRequestScope,
|
||||
"item/tool/requestUserInput": threadTurnRequestScope,
|
||||
"mcpServer/elicitation/request": threadTurnRequestScope,
|
||||
"item/permissions/requestApproval": threadTurnRequestScope,
|
||||
"item/tool/call": threadTurnRequestScope,
|
||||
"account/chatgptAuthTokens/refresh": unscopedRequestScope,
|
||||
"attestation/generate": unscopedRequestScope,
|
||||
"currentTime/read": threadOnlyRequestScope,
|
||||
applyPatchApproval: unscopedRequestScope,
|
||||
execCommandApproval: unscopedRequestScope,
|
||||
};
|
||||
|
||||
const SERVER_REQUEST_ROUTE_KIND_BY_METHOD: ServerRequestRouteKindByMethod = {
|
||||
"item/commandExecution/requestApproval": "approval",
|
||||
"item/fileChange/requestApproval": "approval",
|
||||
"item/permissions/requestApproval": "approval",
|
||||
"item/tool/requestUserInput": "userInput",
|
||||
"mcpServer/elicitation/request": "mcpElicitation",
|
||||
"item/tool/call": "unsupported",
|
||||
"account/chatgptAuthTokens/refresh": "unsupported",
|
||||
"attestation/generate": "unsupported",
|
||||
"currentTime/read": "currentTime",
|
||||
applyPatchApproval: "unsupported",
|
||||
execCommandApproval: "unsupported",
|
||||
};
|
||||
|
||||
export function routeServerRequest(request: ServerRequest, scope: ActiveRouteScope): ServerRequestRoute {
|
||||
if (!isServerRequest(request)) {
|
||||
if (!isMessageInActiveScope(request, scope)) return { kind: "inactive", request };
|
||||
if (isTurnScopedMessageForIdleActiveThread(request, scope)) return { kind: "inactive", request };
|
||||
return { kind: "unknown", request };
|
||||
}
|
||||
if (!isMessageInActiveScope(request, scope)) return { kind: "inactive", request };
|
||||
if (isTurnScopedMessageForIdleActiveThread(request, scope)) return { kind: "inactive", request };
|
||||
|
||||
switch (SERVER_REQUEST_ROUTE_KIND_BY_METHOD[request.method]) {
|
||||
case "approval": {
|
||||
const approval = appServerApprovalRequest(request);
|
||||
if (approval) return { kind: "approval", request, approval };
|
||||
return { kind: "unsupported", request };
|
||||
}
|
||||
case "userInput": {
|
||||
const input = appServerUserInputRequest(request);
|
||||
if (input) return { kind: "userInput", request, input };
|
||||
return { kind: "unsupported", request };
|
||||
}
|
||||
case "mcpElicitation": {
|
||||
const elicitation = appServerMcpElicitationRequest(request);
|
||||
if (elicitation) return { kind: "mcpElicitation", request, elicitation };
|
||||
return { kind: "unsupported", request };
|
||||
}
|
||||
case "currentTime":
|
||||
return { kind: "currentTime", request: request as Extract<ServerRequest, { method: "currentTime/read" }> };
|
||||
case "unsupported":
|
||||
return { kind: "unsupported", request };
|
||||
}
|
||||
}
|
||||
|
||||
export function routeServerNotification(notification: ServerNotification, scope: ActiveRouteScope): ServerNotificationRoute {
|
||||
if (isThreadCatalogNotification(notification)) return { kind: "threadLifecycle", notification };
|
||||
if (!isMessageInActiveScope(notification, scope)) return { kind: "inactive", notification };
|
||||
if (isIdleThreadStreamUpdate(notification, scope)) return { kind: "inactive", notification };
|
||||
const messageScope = serverNotificationScope(notification);
|
||||
if (!isMessageScopeInActiveRouteScope(messageScope, scope)) return { kind: "inactive", notification };
|
||||
if (isIdleThreadStreamUpdate(notification, messageScope, scope)) return { kind: "inactive", notification };
|
||||
|
||||
if (isStreamUpdateNotification(notification)) return { kind: "streamUpdate", notification };
|
||||
if (isTurnLifecycleNotification(notification)) return { kind: "turnLifecycle", notification };
|
||||
|
|
@ -276,59 +194,14 @@ export function routeServerNotification(notification: ServerNotification, scope:
|
|||
return { kind: "unhandled", notification };
|
||||
}
|
||||
|
||||
function isMessageInActiveScope(message: ServerNotification | ServerRequest, scope: ActiveRouteScope): boolean {
|
||||
const threadId = messageThreadId(message);
|
||||
// Scope identifiers are filters only when both the notification and the
|
||||
// active panel have one. Thread catalog and idle-thread notifications often
|
||||
// omit active turn scope, so missing ids stay eligible for the active route.
|
||||
if (threadId && scope.activeThreadId && threadId !== scope.activeThreadId) return false;
|
||||
|
||||
const turnId = messageTurnId(message);
|
||||
if (turnId && scope.activeTurnId && turnId !== scope.activeTurnId) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function messageThreadId(message: ServerNotification | ServerRequest): string | null {
|
||||
if (isServerRequest(message)) return serverRequestScope(message).threadId;
|
||||
if (isServerNotification(message)) return serverNotificationScope(message).threadId;
|
||||
return fallbackMessageScope(message).threadId;
|
||||
}
|
||||
|
||||
function messageTurnId(message: ServerNotification | ServerRequest): string | null {
|
||||
if (isServerRequest(message)) return serverRequestScope(message).turnId;
|
||||
if (isServerNotification(message)) return serverNotificationScope(message).turnId;
|
||||
return fallbackMessageScope(message).turnId;
|
||||
}
|
||||
|
||||
function serverNotificationScope(notification: ServerNotification): MessageScope {
|
||||
if (!isServerNotification(notification)) return fallbackMessageScope(notification);
|
||||
const extractor = SERVER_NOTIFICATION_SCOPE_EXTRACTORS[notification.method] as (notification: ServerNotification) => MessageScope;
|
||||
return extractor(notification);
|
||||
}
|
||||
|
||||
function serverRequestScope(request: ServerRequest): MessageScope {
|
||||
const extractor = SERVER_REQUEST_SCOPE_EXTRACTORS[request.method] as (request: ServerRequest) => MessageScope;
|
||||
return extractor(request);
|
||||
}
|
||||
|
||||
function isServerRequest(message: ServerNotification | ServerRequest): message is ServerRequest {
|
||||
return Object.hasOwn(SERVER_REQUEST_SCOPE_EXTRACTORS, message.method);
|
||||
}
|
||||
|
||||
function isServerNotification(message: ServerNotification | ServerRequest): message is ServerNotification {
|
||||
return Object.hasOwn(SERVER_NOTIFICATION_SCOPE_EXTRACTORS, message.method);
|
||||
}
|
||||
|
||||
function threadTurnRequestScope(request: { params: { threadId: string; turnId: string | null } }): MessageScope {
|
||||
return { threadId: request.params.threadId, turnId: request.params.turnId };
|
||||
}
|
||||
|
||||
function threadOnlyRequestScope(request: { params: { threadId: string | null } }): MessageScope {
|
||||
return { threadId: request.params.threadId, turnId: null };
|
||||
}
|
||||
|
||||
function unscopedRequestScope(): MessageScope {
|
||||
return { threadId: null, turnId: null };
|
||||
function isServerNotification(notification: ServerNotification): boolean {
|
||||
return Object.hasOwn(SERVER_NOTIFICATION_SCOPE_EXTRACTORS, notification.method);
|
||||
}
|
||||
|
||||
function threadStartedNotificationScope(notification: { params: { thread: { id: string } } }): MessageScope {
|
||||
|
|
@ -351,24 +224,6 @@ function unscopedNotificationScope(): MessageScope {
|
|||
return { threadId: null, turnId: null };
|
||||
}
|
||||
|
||||
function fallbackMessageScope(message: ServerNotification | ServerRequest): MessageScope {
|
||||
const params = messageParams(message);
|
||||
return {
|
||||
threadId: stringParam(params, "threadId"),
|
||||
turnId: stringParam(params, "turnId"),
|
||||
};
|
||||
}
|
||||
|
||||
function messageParams(message: { params?: unknown }): Record<string, unknown> | null {
|
||||
const params = message.params;
|
||||
return params !== null && typeof params === "object" && !Array.isArray(params) ? (params as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function stringParam(params: Record<string, unknown> | null, key: string): string | null {
|
||||
const value = params?.[key];
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function isThreadCatalogNotification(notification: ServerNotification): notification is ThreadLifecycleNotification {
|
||||
return notificationMethodIn(notification.method, GLOBALLY_ROUTED_THREAD_CATALOG_NOTIFICATION_METHODS);
|
||||
}
|
||||
|
|
@ -377,12 +232,8 @@ function isStreamUpdateNotification(notification: ServerNotification): notificat
|
|||
return notificationMethodIn(notification.method, STREAM_UPDATE_NOTIFICATION_METHODS);
|
||||
}
|
||||
|
||||
function isIdleThreadStreamUpdate(notification: ServerNotification, scope: ActiveRouteScope): boolean {
|
||||
return isTurnScopedMessageForIdleActiveThread(notification, scope) && isStreamUpdateNotification(notification);
|
||||
}
|
||||
|
||||
function isTurnScopedMessageForIdleActiveThread(message: ServerNotification | ServerRequest, scope: ActiveRouteScope): boolean {
|
||||
return scope.activeThreadId !== null && scope.activeTurnId === null && messageTurnId(message) !== null;
|
||||
function isIdleThreadStreamUpdate(notification: ServerNotification, messageScope: MessageScope, scope: ActiveRouteScope): boolean {
|
||||
return isTurnScopedMessageForIdleActiveThread(messageScope, scope) && isStreamUpdateNotification(notification);
|
||||
}
|
||||
|
||||
function isTurnLifecycleNotification(notification: ServerNotification): notification is TurnLifecycleNotification {
|
||||
40
src/features/chat/app-server/inbound/route-scope.ts
Normal file
40
src/features/chat/app-server/inbound/route-scope.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
export interface ActiveRouteScope {
|
||||
activeThreadId: string | null;
|
||||
activeTurnId: string | null;
|
||||
}
|
||||
|
||||
export interface MessageScope {
|
||||
threadId: string | null;
|
||||
turnId: string | null;
|
||||
}
|
||||
|
||||
export function isMessageScopeInActiveRouteScope(messageScope: MessageScope, activeScope: ActiveRouteScope): boolean {
|
||||
// Scope identifiers are filters only when both the message and the active
|
||||
// panel have one. Thread catalog and idle-thread notifications often omit
|
||||
// active turn scope, so missing ids stay eligible for the active route.
|
||||
if (messageScope.threadId && activeScope.activeThreadId && messageScope.threadId !== activeScope.activeThreadId) return false;
|
||||
if (messageScope.turnId && activeScope.activeTurnId && messageScope.turnId !== activeScope.activeTurnId) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isTurnScopedMessageForIdleActiveThread(messageScope: MessageScope, activeScope: ActiveRouteScope): boolean {
|
||||
return activeScope.activeThreadId !== null && activeScope.activeTurnId === null && messageScope.turnId !== null;
|
||||
}
|
||||
|
||||
export function fallbackMessageScope(message: { params?: unknown }): MessageScope {
|
||||
const params = messageParams(message);
|
||||
return {
|
||||
threadId: stringParam(params, "threadId"),
|
||||
turnId: stringParam(params, "turnId"),
|
||||
};
|
||||
}
|
||||
|
||||
function messageParams(message: { params?: unknown }): Record<string, unknown> | null {
|
||||
const params = message.params;
|
||||
return params !== null && typeof params === "object" && !Array.isArray(params) ? (params as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function stringParam(params: Record<string, unknown> | null, key: string): string | null {
|
||||
const value = params?.[key];
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import {
|
||||
appServerApprovalResponse,
|
||||
appServerMcpElicitationResponse,
|
||||
appServerUserInputResponse,
|
||||
} from "../../../../../app-server/protocol/server-requests";
|
||||
import type {
|
||||
ApprovalAction,
|
||||
McpElicitationAction,
|
||||
McpElicitationContentValue,
|
||||
PendingApproval,
|
||||
} from "../../../../../domain/pending-requests/model";
|
||||
|
||||
export function serverRequestApprovalResponse(approval: PendingApproval, action: ApprovalAction): unknown {
|
||||
return appServerApprovalResponse(approval, action);
|
||||
}
|
||||
|
||||
export function serverRequestUserInputResponse(questions: readonly { id: string }[], answers: Record<string, string>): unknown {
|
||||
return appServerUserInputResponse(questions, answers);
|
||||
}
|
||||
|
||||
export function serverRequestMcpElicitationResponse(
|
||||
action: McpElicitationAction,
|
||||
content: Record<string, McpElicitationContentValue> | null,
|
||||
): unknown {
|
||||
return appServerMcpElicitationResponse(action, content);
|
||||
}
|
||||
112
src/features/chat/app-server/inbound/server-requests/routing.ts
Normal file
112
src/features/chat/app-server/inbound/server-requests/routing.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import type { ServerRequest } from "../../../../../app-server/connection/rpc-messages";
|
||||
import {
|
||||
appServerApprovalRequest,
|
||||
appServerMcpElicitationRequest,
|
||||
appServerUserInputRequest,
|
||||
} from "../../../../../app-server/protocol/server-requests";
|
||||
import type { PendingApproval, PendingMcpElicitation, PendingUserInput } from "../../../../../domain/pending-requests/model";
|
||||
import {
|
||||
type ActiveRouteScope,
|
||||
fallbackMessageScope,
|
||||
isMessageScopeInActiveRouteScope,
|
||||
isTurnScopedMessageForIdleActiveThread,
|
||||
type MessageScope,
|
||||
} from "../route-scope";
|
||||
|
||||
export type ServerRequestRoute =
|
||||
| { kind: "approval"; request: ServerRequest; approval: PendingApproval }
|
||||
| { kind: "userInput"; request: ServerRequest; input: PendingUserInput }
|
||||
| { kind: "mcpElicitation"; request: ServerRequest; elicitation: PendingMcpElicitation }
|
||||
| { kind: "currentTime"; request: Extract<ServerRequest, { method: "currentTime/read" }> }
|
||||
| { kind: "unsupported"; request: ServerRequest }
|
||||
| { kind: "unknown"; request: ServerRequest }
|
||||
| { kind: "inactive"; request: ServerRequest };
|
||||
|
||||
type ServerRequestMethod = ServerRequest["method"];
|
||||
type ServerRequestRouteKindByMethod = Record<ServerRequestMethod, Exclude<ServerRequestRoute["kind"], "inactive" | "unknown">>;
|
||||
type ServerRequestScopeExtractors = {
|
||||
[Method in ServerRequestMethod]: (request: Extract<ServerRequest, { method: Method }>) => MessageScope;
|
||||
};
|
||||
|
||||
const SERVER_REQUEST_SCOPE_EXTRACTORS: ServerRequestScopeExtractors = {
|
||||
"item/commandExecution/requestApproval": threadTurnRequestScope,
|
||||
"item/fileChange/requestApproval": threadTurnRequestScope,
|
||||
"item/tool/requestUserInput": threadTurnRequestScope,
|
||||
"mcpServer/elicitation/request": threadTurnRequestScope,
|
||||
"item/permissions/requestApproval": threadTurnRequestScope,
|
||||
"item/tool/call": threadTurnRequestScope,
|
||||
"account/chatgptAuthTokens/refresh": unscopedRequestScope,
|
||||
"attestation/generate": unscopedRequestScope,
|
||||
"currentTime/read": threadOnlyRequestScope,
|
||||
applyPatchApproval: unscopedRequestScope,
|
||||
execCommandApproval: unscopedRequestScope,
|
||||
};
|
||||
|
||||
const SERVER_REQUEST_ROUTE_KIND_BY_METHOD: ServerRequestRouteKindByMethod = {
|
||||
"item/commandExecution/requestApproval": "approval",
|
||||
"item/fileChange/requestApproval": "approval",
|
||||
"item/permissions/requestApproval": "approval",
|
||||
"item/tool/requestUserInput": "userInput",
|
||||
"mcpServer/elicitation/request": "mcpElicitation",
|
||||
"item/tool/call": "unsupported",
|
||||
"account/chatgptAuthTokens/refresh": "unsupported",
|
||||
"attestation/generate": "unsupported",
|
||||
"currentTime/read": "currentTime",
|
||||
applyPatchApproval: "unsupported",
|
||||
execCommandApproval: "unsupported",
|
||||
};
|
||||
|
||||
export function routeServerRequest(request: ServerRequest, scope: ActiveRouteScope): ServerRequestRoute {
|
||||
const messageScope = serverRequestScope(request);
|
||||
if (!isServerRequest(request)) {
|
||||
if (!isMessageScopeInActiveRouteScope(messageScope, scope)) return { kind: "inactive", request };
|
||||
if (isTurnScopedMessageForIdleActiveThread(messageScope, scope)) return { kind: "inactive", request };
|
||||
return { kind: "unknown", request };
|
||||
}
|
||||
if (!isMessageScopeInActiveRouteScope(messageScope, scope)) return { kind: "inactive", request };
|
||||
if (isTurnScopedMessageForIdleActiveThread(messageScope, scope)) return { kind: "inactive", request };
|
||||
|
||||
switch (SERVER_REQUEST_ROUTE_KIND_BY_METHOD[request.method]) {
|
||||
case "approval": {
|
||||
const approval = appServerApprovalRequest(request);
|
||||
if (approval) return { kind: "approval", request, approval };
|
||||
return { kind: "unsupported", request };
|
||||
}
|
||||
case "userInput": {
|
||||
const input = appServerUserInputRequest(request);
|
||||
if (input) return { kind: "userInput", request, input };
|
||||
return { kind: "unsupported", request };
|
||||
}
|
||||
case "mcpElicitation": {
|
||||
const elicitation = appServerMcpElicitationRequest(request);
|
||||
if (elicitation) return { kind: "mcpElicitation", request, elicitation };
|
||||
return { kind: "unsupported", request };
|
||||
}
|
||||
case "currentTime":
|
||||
return { kind: "currentTime", request: request as Extract<ServerRequest, { method: "currentTime/read" }> };
|
||||
case "unsupported":
|
||||
return { kind: "unsupported", request };
|
||||
}
|
||||
}
|
||||
|
||||
function serverRequestScope(request: ServerRequest): MessageScope {
|
||||
if (!isServerRequest(request)) return fallbackMessageScope(request);
|
||||
const extractor = SERVER_REQUEST_SCOPE_EXTRACTORS[request.method] as (request: ServerRequest) => MessageScope;
|
||||
return extractor(request);
|
||||
}
|
||||
|
||||
function isServerRequest(request: ServerRequest): boolean {
|
||||
return Object.hasOwn(SERVER_REQUEST_SCOPE_EXTRACTORS, request.method);
|
||||
}
|
||||
|
||||
function threadTurnRequestScope(request: { params: { threadId: string; turnId: string | null } }): MessageScope {
|
||||
return { threadId: request.params.threadId, turnId: request.params.turnId };
|
||||
}
|
||||
|
||||
function threadOnlyRequestScope(request: { params: { threadId: string | null } }): MessageScope {
|
||||
return { threadId: request.params.threadId, turnId: null };
|
||||
}
|
||||
|
||||
function unscopedRequestScope(): MessageScope {
|
||||
return { threadId: null, turnId: null };
|
||||
}
|
||||
|
|
@ -1,7 +1,12 @@
|
|||
import type { TurnItem } from "../../../../../app-server/protocol/turn";
|
||||
import { turnUserItemText } from "../../../../../app-server/protocol/turn";
|
||||
import {
|
||||
completedConversationSummaryFromTurnRecord,
|
||||
type TurnItem,
|
||||
type TurnRecord,
|
||||
turnUserItemText,
|
||||
} from "../../../../../app-server/protocol/turn";
|
||||
import type { HistoricalTurn } from "../../../../../domain/threads/history";
|
||||
import { referencedThreadMetadataFromPrompt } from "../../../../../domain/threads/reference";
|
||||
import type { ThreadConversationSummary } from "../../../../../domain/threads/transcript";
|
||||
import { jsonPreview } from "../../../../../shared/text/preview";
|
||||
import {
|
||||
commandExecutionState,
|
||||
|
|
@ -41,6 +46,12 @@ interface TurnItemSourceFields {
|
|||
sourceItemId: string;
|
||||
}
|
||||
|
||||
export type AppServerTurnItem = TurnItem;
|
||||
|
||||
export function completedConversationSummaryFromAppServerTurn(turn: TurnRecord): ThreadConversationSummary | null {
|
||||
return completedConversationSummaryFromTurnRecord(turn);
|
||||
}
|
||||
|
||||
export function messageStreamItemsFromTurns(turns: readonly HistoricalTurn[]): MessageStreamItem[] {
|
||||
const sortedTurns = [...turns].sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0));
|
||||
const items: MessageStreamItem[] = [];
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import {
|
|||
import {
|
||||
ROUTED_SERVER_NOTIFICATION_METHODS_BY_ROUTE_KIND,
|
||||
routeServerNotification,
|
||||
routeServerRequest,
|
||||
} from "../../../../../src/features/chat/app-server/inbound/routing";
|
||||
} from "../../../../../src/features/chat/app-server/inbound/notification-routing";
|
||||
import { routeServerRequest } from "../../../../../src/features/chat/app-server/inbound/server-requests/routing";
|
||||
import { chatStateFixture, chatStateWith } from "../../support/state";
|
||||
|
||||
const activeScope = { activeThreadId: "thread-active", activeTurnId: "turn-active" };
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ const projectPluginByName = new Map(
|
|||
return [path.basename(pluginPath), plugin];
|
||||
}),
|
||||
);
|
||||
const APP_SERVER_PROTOCOL_BOUNDARY_MESSAGE =
|
||||
"Source modules outside 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.";
|
||||
|
||||
describe("GritQL source policy", () => {
|
||||
it("keeps project-wide source-shape policies enforceable as Biome plugin diagnostics", async () => {
|
||||
|
|
@ -466,6 +468,28 @@ export const response = appServerUserInputResponse;
|
|||
import { appServerUserInputResponse } from "../../../../app-server/protocol/server-requests";
|
||||
|
||||
export const response = appServerUserInputResponse;
|
||||
`.trimStart(),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/features/chat/app-server/mappers/message-stream/turn-items.ts"),
|
||||
`
|
||||
import type { TurnItem } from "../../../../../app-server/protocol/turn";
|
||||
import { toolInventoryAppsFromAppInfos } from "../../../../../app-server/protocol/tool-inventory";
|
||||
|
||||
const toolInventory = await import("../../../../../app-server/protocol/tool-inventory");
|
||||
|
||||
export const convert = [toolInventoryAppsFromAppInfos, toolInventory] satisfies unknown[];
|
||||
export type Item = TurnItem;
|
||||
`.trimStart(),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/features/chat/app-server/inbound/server-requests/responses.ts"),
|
||||
`
|
||||
import { appServerUserInputResponse } from "../../../../../app-server/protocol/server-requests";
|
||||
|
||||
const runtimeMetrics = await import("../../../../../app-server/protocol/runtime-metrics");
|
||||
|
||||
export const response = [appServerUserInputResponse, runtimeMetrics] satisfies unknown[];
|
||||
`.trimStart(),
|
||||
);
|
||||
|
||||
|
|
@ -475,82 +499,31 @@ export const response = appServerUserInputResponse;
|
|||
"src/features/chat/application/threads/history-controller.ts",
|
||||
"src/features/chat/panel/surface/message-stream-presenter.ts",
|
||||
"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/responses.ts",
|
||||
],
|
||||
cwd,
|
||||
);
|
||||
|
||||
expect(pluginMessages(report, "src/features/chat/application/pending-requests/pending-request-actions.ts")).toEqual([
|
||||
"Source modules outside app-server must use domain models and app-server services instead of app-server protocol modules. Chat ingestion and message-stream conversion may consume app-server turn protocol at the boundary; feature state and UI must use Panel-owned models.",
|
||||
APP_SERVER_PROTOCOL_BOUNDARY_MESSAGE,
|
||||
]);
|
||||
expect(pluginMessages(report, "src/features/chat/application/threads/history-controller.ts")).toEqual([
|
||||
"Source modules outside app-server must use domain models and app-server services instead of app-server protocol modules. Chat ingestion and message-stream conversion may consume app-server turn protocol at the boundary; feature state and UI must use Panel-owned models.",
|
||||
APP_SERVER_PROTOCOL_BOUNDARY_MESSAGE,
|
||||
]);
|
||||
expect(pluginMessages(report, "src/features/chat/panel/surface/message-stream-presenter.ts")).toEqual([
|
||||
"Source modules outside app-server must use domain models and app-server services instead of app-server protocol modules. Chat ingestion and message-stream conversion may consume app-server turn protocol at the boundary; feature state and UI must use Panel-owned models.",
|
||||
APP_SERVER_PROTOCOL_BOUNDARY_MESSAGE,
|
||||
]);
|
||||
expect(pluginMessages(report, "src/features/chat/app-server/inbound/app-server-logs.ts")).toEqual([
|
||||
"Source modules outside app-server must use domain models and app-server services instead of app-server protocol modules. Chat ingestion and message-stream conversion may consume app-server turn protocol at the boundary; feature state and UI must use Panel-owned models.",
|
||||
APP_SERVER_PROTOCOL_BOUNDARY_MESSAGE,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps chat app-server ingestion on app-server turn protocol only", async () => {
|
||||
const cwd = await tempBiomeWorkspace(["no-chat-app-server-turn-protocol-imports.grit"]);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/features/chat/app-server/inbound/notification-plan.ts"),
|
||||
`
|
||||
import { toolInventoryAppsFromAppInfos } from "../../../../app-server/protocol/tool-inventory";
|
||||
|
||||
const toolInventory = await import("../../../../app-server/protocol/tool-inventory");
|
||||
|
||||
export const convert = [toolInventoryAppsFromAppInfos, toolInventory];
|
||||
`.trimStart(),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/features/chat/app-server/mappers/message-stream/turn-items.ts"),
|
||||
`
|
||||
import type { TurnItem } from "../../../../../app-server/protocol/turn";
|
||||
|
||||
export type Item = TurnItem;
|
||||
`.trimStart(),
|
||||
);
|
||||
|
||||
const report = biomeLint(
|
||||
["src/features/chat/app-server/inbound/notification-plan.ts", "src/features/chat/app-server/mappers/message-stream/turn-items.ts"],
|
||||
cwd,
|
||||
);
|
||||
|
||||
expect(pluginMessages(report, "src/features/chat/app-server/inbound/notification-plan.ts")).toEqual([
|
||||
"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.",
|
||||
"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.",
|
||||
expect(pluginMessages(report, "src/features/chat/app-server/mappers/message-stream/turn-items.ts")).toEqual([
|
||||
APP_SERVER_PROTOCOL_BOUNDARY_MESSAGE,
|
||||
APP_SERVER_PROTOCOL_BOUNDARY_MESSAGE,
|
||||
]);
|
||||
expect(pluginDiagnostics(report, "src/features/chat/app-server/mappers/message-stream/turn-items.ts")).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps chat app-server request handling on server request protocol only", async () => {
|
||||
const cwd = await tempBiomeWorkspace(["no-chat-app-server-request-protocol-imports.grit"]);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/features/chat/app-server/inbound/handler.ts"),
|
||||
`
|
||||
const runtimeMetrics = await import("../../../../app-server/protocol/runtime-metrics");
|
||||
|
||||
export const response = runtimeMetrics;
|
||||
`.trimStart(),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/features/chat/app-server/inbound/routing.ts"),
|
||||
`
|
||||
import { appServerUserInputResponse } from "../../../../app-server/protocol/server-requests";
|
||||
|
||||
export const response = appServerUserInputResponse;
|
||||
`.trimStart(),
|
||||
);
|
||||
|
||||
const report = biomeLint(["src/features/chat/app-server/inbound/handler.ts", "src/features/chat/app-server/inbound/routing.ts"], cwd);
|
||||
|
||||
expect(pluginMessages(report, "src/features/chat/app-server/inbound/handler.ts")).toEqual([
|
||||
"Chat app-server request handling may consume server request protocol projections only. Convert app-server payloads to chat pending request domain models at this boundary.",
|
||||
expect(pluginMessages(report, "src/features/chat/app-server/inbound/server-requests/responses.ts")).toEqual([
|
||||
APP_SERVER_PROTOCOL_BOUNDARY_MESSAGE,
|
||||
]);
|
||||
expect(pluginDiagnostics(report, "src/features/chat/app-server/inbound/routing.ts")).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps lower-level source independent from connection and feature layers", async () => {
|
||||
|
|
@ -862,6 +835,7 @@ 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 });
|
||||
|
|
|
|||
Loading…
Reference in a new issue