From dd280f74e720788098416267b29999700c6d73d0 Mon Sep 17 00:00:00 2001 From: murashit Date: Thu, 25 Jun 2026 18:06:39 +0900 Subject: [PATCH] Isolate app-server protocol boundaries --- docs/development.md | 2 +- .../lint/no-app-server-projection-rpcs.grit | 27 ++---- ...generated-app-server-boundary-imports.grit | 4 +- src/app-server/protocol/turn.ts | 85 +++++++++++++++++-- src/app-server/threads.ts | 16 +++- .../chat/app-server/threads/projection.ts | 7 +- .../conversation/slash-command-executor.ts | 7 +- .../application/threads/history-controller.ts | 19 +++-- .../application/threads/resume-actions.ts | 7 +- .../threads/thread-management-actions.ts | 20 +++-- .../ephemeral-structured-turn.test.ts | 9 +- .../generated-import-boundary.test.ts | 8 +- .../thread-title-generation.test.ts | 8 +- .../chat/protocol/inbound/handler.test.ts | 5 +- .../selection-rewrite.test.ts | 8 +- tests/scripts/grit-policy.test.mjs | 52 ++++++++---- 16 files changed, 203 insertions(+), 81 deletions(-) diff --git a/docs/development.md b/docs/development.md index a10c46a5..484ccea0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -57,7 +57,7 @@ Within `src/features/chat/`: Keep new code near the state or API it owns. A feature may import another feature only for a capability that feature owns. Feature-neutral behavior belongs in `src/shared/`, `src/domain/`, or `src/app-server/`. -Generated app-server types should stay behind `src/app-server/` or chat-local app-server integration modules. If a domain, shared, settings, workspace, or UI module needs app-server data, add or reuse a panel-owned projection at the boundary instead of importing generated payload types directly. +Generated app-server types should stay behind app-server connection and protocol adapter modules. Chat-local app-server integration modules may consume app-server protocol projections, but not raw generated bindings. If a domain, shared, settings, workspace, or UI module needs app-server data, add or reuse a panel-owned projection at the boundary instead of importing generated payload types directly. Chat panel-visible state belongs in `ChatStateStore` and should flow through named reducer actions and the shell-state adapter. Use Preact Signals only in `src/features/chat/panel/shell-state.tsx`; lint enforces this boundary. When a surface needs fewer dependencies, add or reuse a named shell-state projection instead of importing `@preact/signals` elsewhere. diff --git a/scripts/lint/no-app-server-projection-rpcs.grit b/scripts/lint/no-app-server-projection-rpcs.grit index b5f7d042..bf10b599 100644 --- a/scripts/lint/no-app-server-projection-rpcs.grit +++ b/scripts/lint/no-app-server-projection-rpcs.grit @@ -1,23 +1,12 @@ language js or { - or { - `$client.resumeThread($...)` as $stmt, - `$client.threadTurnsList($...)` as $stmt, - `$client.forkThread($...)` as $stmt, - `$client.rollbackThread($...)` as $stmt - } where { - $client <: r"^(?:client|[A-Za-z_$][A-Za-z0-9_$]*Client)$", - $filename <: r".*/src/features/chat/application/.*", - register_diagnostic(span=$stmt, message="Keep app-server projection RPCs behind app-server facades; chat application code should consume Panel-owned snapshots or view models.", severity="error") - }, - or { - `AppServerClient["resumeThread"]` as $stmt, - `AppServerClient["threadTurnsList"]` as $stmt, - `AppServerClient["forkThread"]` as $stmt, - `AppServerClient["rollbackThread"]` as $stmt - } where { - $filename <: r".*/src/.*", - register_diagnostic(span=$stmt, message="Do not expose app-server projection RPC signatures through AppServerClient indexed access types; define a Panel-owned projection type instead.", severity="error") - } + `$client.resumeThread($...)` as $stmt, + `$client.threadTurnsList($...)` as $stmt, + `$client.forkThread($...)` as $stmt, + `$client.rollbackThread($...)` as $stmt +} where { + $client <: r"^(?:client|[A-Za-z_$][A-Za-z0-9_$]*Client)$", + $filename <: r".*/src/features/chat/application/.*", + register_diagnostic(span=$stmt, message="Keep app-server projection RPCs behind app-server facades; chat application code should consume Panel-owned snapshots or view models.", severity="error") } diff --git a/scripts/lint/no-generated-app-server-boundary-imports.grit b/scripts/lint/no-generated-app-server-boundary-imports.grit index 543bb7d7..c04d06ec 100644 --- a/scripts/lint/no-generated-app-server-boundary-imports.grit +++ b/scripts/lint/no-generated-app-server-boundary-imports.grit @@ -14,13 +14,13 @@ or { js_module_reference() as $stmt where { $stmt <: contains `$source` where { $source <: r"^[\"'].*(?:src/)?generated/app-server(?:/.*)?[\"']$" }, not { $filename <: r".*/(?:src/generated|src/app-server/connection|tests/app-server)/.*" }, - not { $filename <: r".*/src/app-server/protocol/(?:turn|server-requests)\.ts$" }, + not { $filename <: r".*/src/app-server/protocol/server-requests\.ts$" }, register_diagnostic(span=$stmt, message="Keep generated app-server types behind src/app-server and tests/app-server; expose Panel-owned models outside raw app-server adapters.", severity="error") }, `require($source)` as $stmt where { $source <: r"^[\"'].*(?:src/)?generated/app-server(?:/.*)?[\"']$", not { $filename <: r".*/(?:src/generated|src/app-server/connection|tests/app-server)/.*" }, - not { $filename <: r".*/src/app-server/protocol/(?:turn|server-requests)\.ts$" }, + not { $filename <: r".*/src/app-server/protocol/server-requests\.ts$" }, register_diagnostic(span=$stmt, message="Keep generated app-server types behind src/app-server and tests/app-server; expose Panel-owned models outside raw app-server adapters.", severity="error") } } diff --git a/src/app-server/protocol/turn.ts b/src/app-server/protocol/turn.ts index 8bbca11b..4c328f53 100644 --- a/src/app-server/protocol/turn.ts +++ b/src/app-server/protocol/turn.ts @@ -4,18 +4,29 @@ import { type ThreadConversationSummary, type ThreadTranscriptEntry, } from "../../domain/threads/transcript"; -import type { ThreadItem as GeneratedTurnItem } from "../../generated/app-server/v2/ThreadItem"; - -export type TurnItem = GeneratedTurnItem; type AppServerUserInput = - | { type: "text"; text: string } - | { type: "image"; url: string } - | { type: "localImage"; path: string } + | { 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 } } @@ -41,6 +52,68 @@ interface TurnError { additionalDetails: string | null; } +interface BaseTurnItem { + type: Type; + id: string; +} + +export type TurnItem = + | (BaseTurnItem<"userMessage"> & { clientId: string | null; content: AppServerUserInput[] }) + | (BaseTurnItem<"hookPrompt"> & { fragments: { text: string; [key: string]: unknown }[] }) + | (BaseTurnItem<"agentMessage"> & { text: string; phase: string | null; memoryCitation: unknown }) + | (BaseTurnItem<"plan"> & { text: string }) + | (BaseTurnItem<"reasoning"> & { summary: string[]; content: string[] }) + | (BaseTurnItem<"commandExecution"> & { + command: string; + cwd: string; + processId: string | null; + source: string; + status: string; + commandActions: CommandAction[]; + aggregatedOutput: string | null; + exitCode: number | null; + durationMs: number | null; + }) + | (BaseTurnItem<"fileChange"> & { changes: { path: string; kind: { type: string }; diff: string }[]; status: string }) + | (BaseTurnItem<"mcpToolCall"> & { + server: string; + tool: string; + status: string; + arguments: unknown; + appContext: unknown; + pluginId: string | null; + result: unknown; + error: { message?: string; [key: string]: unknown } | null; + durationMs: number | null; + }) + | (BaseTurnItem<"dynamicToolCall"> & { + namespace: string | null; + tool: string; + arguments: unknown; + status: string; + contentItems: unknown[] | null; + success: boolean | null; + durationMs: number | null; + }) + | (BaseTurnItem<"collabAgentToolCall"> & { + tool: string; + status: string; + senderThreadId: string; + receiverThreadIds: string[]; + prompt: string | null; + model: string | null; + reasoningEffort: string | null; + agentsStates: Record; + }) + | (BaseTurnItem<"subAgentActivity"> & { kind: string; agentThreadId: string; agentPath: string }) + | (BaseTurnItem<"webSearch"> & { query: string; action: WebSearchAction | null }) + | (BaseTurnItem<"imageView"> & { path: string }) + | (BaseTurnItem<"sleep"> & { durationMs: number }) + | (BaseTurnItem<"imageGeneration"> & { status: string; revisedPrompt: string | null; result: string; savedPath?: string }) + | (BaseTurnItem<"enteredReviewMode"> & { review: string }) + | (BaseTurnItem<"exitedReviewMode"> & { review: string }) + | BaseTurnItem<"contextCompaction">; + export interface TurnRecord { id: string; items: TurnItem[]; diff --git a/src/app-server/threads.ts b/src/app-server/threads.ts index 081d00fd..c4d9b6b2 100644 --- a/src/app-server/threads.ts +++ b/src/app-server/threads.ts @@ -20,6 +20,10 @@ import { const THREAD_LIST_PAGE_LIMIT = 100; export type ThreadTurnSortDirection = "asc" | "desc"; +export type ThreadConversationSummaryClient = Pick; +export type ThreadForkClient = Pick; +export type ThreadRollbackClient = Pick; +export type ThreadCompactionClient = Pick; interface ThreadConversationSummaryPage { data: ThreadConversationSummary[]; @@ -73,7 +77,7 @@ export async function readThreadForArchiveExport(client: AppServerClient, thread } export async function readCompletedConversationSummariesPage( - client: AppServerClient, + client: ThreadConversationSummaryClient, threadId: string, cursor: string | null, limit: number, @@ -87,7 +91,7 @@ export async function readCompletedConversationSummariesPage( } export async function readReferencedThreadConversationSummaries( - client: AppServerClient, + client: ThreadConversationSummaryClient, threadId: string, limit = REFERENCED_THREAD_TURN_LIMIT, ): Promise { @@ -116,16 +120,20 @@ function threadRollbackSnapshotFromAppServerResponse(response: ThreadRollbackRes }; } -export async function rollbackThread(client: AppServerClient, threadId: string, numTurns?: number): Promise { +export async function rollbackThread(client: ThreadRollbackClient, threadId: string, numTurns?: number): Promise { const response = numTurns === undefined ? await client.rollbackThread(threadId) : await client.rollbackThread(threadId, numTurns); return threadRollbackSnapshotFromAppServerResponse(response); } -export async function forkThread(client: AppServerClient, threadId: string, cwd: string): Promise { +export async function forkThread(client: ThreadForkClient, threadId: string, cwd: string): Promise { const response = await client.forkThread(threadId, cwd); return threadFromThreadRecord(response.thread); } +export async function compactThread(client: ThreadCompactionClient, threadId: string): Promise { + await client.compactThread(threadId); +} + export async function restoreArchivedThread(client: AppServerClient, threadId: string): Promise { const response = await client.unarchiveThread(threadId); return threadFromThreadRecord(response.thread); diff --git a/src/features/chat/app-server/threads/projection.ts b/src/features/chat/app-server/threads/projection.ts index 14430e45..5bd875c4 100644 --- a/src/features/chat/app-server/threads/projection.ts +++ b/src/features/chat/app-server/threads/projection.ts @@ -5,6 +5,9 @@ import type { ThreadTurnsPage } from "../../../../domain/threads/history"; import type { MessageStreamItem } from "../../domain/message-stream/items"; import { messageStreamItemsFromTurns } from "../mappers/message-stream/turn-items"; +export type ChatThreadHistoryClient = Pick; +export type ChatThreadResumeClient = Pick; + export interface ChatThreadHistoryPage { items: MessageStreamItem[]; nextCursor: string | null; @@ -18,7 +21,7 @@ export interface ChatThreadResumeSnapshot { } export async function readChatThreadHistoryPage( - client: AppServerClient, + client: ChatThreadHistoryClient, threadId: string, cursor: string | null, limit = 20, @@ -34,7 +37,7 @@ function chatThreadHistoryPageFromTurnsPage(page: ThreadTurnsPage): ChatThreadHi }; } -export async function resumeChatThread(client: AppServerClient, threadId: string, cwd: string): Promise { +export async function resumeChatThread(client: ChatThreadResumeClient, threadId: string, cwd: string): Promise { const response = await client.resumeThread(threadId, cwd); return { activation: threadActivationSnapshotFromAppServerResponse(response), diff --git a/src/features/chat/application/conversation/slash-command-executor.ts b/src/features/chat/application/conversation/slash-command-executor.ts index b799ae36..2976208c 100644 --- a/src/features/chat/application/conversation/slash-command-executor.ts +++ b/src/features/chat/application/conversation/slash-command-executor.ts @@ -1,5 +1,4 @@ -import type { AppServerClient } from "../../../../app-server/connection/client"; -import { readReferencedThreadConversationSummaries } from "../../../../app-server/threads"; +import { readReferencedThreadConversationSummaries, type ThreadConversationSummaryClient } from "../../../../app-server/threads"; import type { ReasoningEffort } from "../../../../domain/catalog/metadata"; import { findModelMetadataByIdOrName, supportedEffortsForModelMetadata } from "../../../../domain/catalog/metadata"; import { type CodexInput, codexTextInputWithAttachments } from "../../../../domain/chat/input"; @@ -21,7 +20,7 @@ import { submissionStateSnapshot } from "./submission-state"; export interface SlashCommandExecutorHost extends SlashCommandExecutionPorts { stateStore: ChatStateStore; - currentClient: () => AppServerClient | null; + currentClient: () => ThreadConversationSummaryClient | null; codexInput: (text: string) => CodexInput; setStatus: (status: string) => void; } @@ -65,7 +64,7 @@ function supportedReasoningEfforts(state: ReturnType async function referencedThreadInput( host: SlashCommandExecutorHost, - client: AppServerClient, + client: ThreadConversationSummaryClient, thread: Thread, message: string, ): Promise { diff --git a/src/features/chat/application/threads/history-controller.ts b/src/features/chat/application/threads/history-controller.ts index dd52c2b9..d7038d18 100644 --- a/src/features/chat/application/threads/history-controller.ts +++ b/src/features/chat/application/threads/history-controller.ts @@ -1,16 +1,20 @@ -import type { AppServerClient } from "../../../../app-server/connection/client"; -import { type ChatThreadHistoryPage, readChatThreadHistoryPage } from "../../app-server/threads/projection"; +import { type ChatThreadHistoryClient, type ChatThreadHistoryPage, readChatThreadHistoryPage } from "../../app-server/threads/projection"; import { messageStreamItems } from "../state/message-stream"; import type { ChatAction, ChatState } from "../state/root-reducer"; import type { ChatStateStore } from "../state/store"; export interface HistoryControllerHost { stateStore: ChatStateStore; - currentClient: () => AppServerClient | null; + currentClient: () => ChatThreadHistoryClient | null; addSystemMessage: (text: string) => void; showLatestPageAtBottom: () => void; setThreadTurnPresence: (hadTurns: boolean) => void; - readHistoryPage?: (client: AppServerClient, threadId: string, cursor: string | null, limit: number) => Promise; + readHistoryPage?: ( + client: ChatThreadHistoryClient, + threadId: string, + cursor: string | null, + limit: number, + ) => Promise; } type ThreadHistoryLoadLifecycleState = { kind: "idle" } | { kind: "loading"; threadId: string; mode: "latest" | "older" }; @@ -110,7 +114,12 @@ export class HistoryController { return this.lifecycle !== load || this.state.activeThread.id !== load.threadId; } - private readHistoryPage(client: AppServerClient, threadId: string, cursor: string | null, limit: number): Promise { + private readHistoryPage( + client: ChatThreadHistoryClient, + threadId: string, + cursor: string | null, + limit: number, + ): Promise { return (this.host.readHistoryPage ?? readChatThreadHistoryPage)(client, threadId, cursor, limit); } } diff --git a/src/features/chat/application/threads/resume-actions.ts b/src/features/chat/application/threads/resume-actions.ts index 5b9e0b2e..d89a8d92 100644 --- a/src/features/chat/application/threads/resume-actions.ts +++ b/src/features/chat/application/threads/resume-actions.ts @@ -1,6 +1,5 @@ -import type { AppServerClient } from "../../../../app-server/connection/client"; import type { ThreadTokenUsage } from "../../../../domain/runtime/metrics"; -import { type ChatThreadResumeSnapshot, resumeChatThread } from "../../app-server/threads/projection"; +import { type ChatThreadResumeClient, type ChatThreadResumeSnapshot, resumeChatThread } from "../../app-server/threads/projection"; import type { ActiveChatResume, ChatResumeWorkTracker } from "../lifecycle"; import { resumedThreadAction } from "../state/actions"; import type { ChatStateStore } from "../state/store"; @@ -14,7 +13,7 @@ export interface ResumeActionsHost { resumeWork: ChatResumeWorkTracker; history: HistoryController; restoration: RestorationController; - currentClient: () => AppServerClient | null; + currentClient: () => ChatThreadResumeClient | null; ensureConnected: () => Promise; closing: () => boolean; resetThreadTurnPresence: (hadTurns: boolean) => void; @@ -23,7 +22,7 @@ export interface ResumeActionsHost { refreshLiveState: () => void; syncThreadGoal: (threadId: string) => Promise; recoverTokenUsageFromRollout?: (path: string) => Promise; - resumeFromAppServer?: (client: AppServerClient, threadId: string, cwd: string) => Promise; + resumeFromAppServer?: (client: ChatThreadResumeClient, threadId: string, cwd: string) => Promise; } export interface ResumeActions { diff --git a/src/features/chat/application/threads/thread-management-actions.ts b/src/features/chat/application/threads/thread-management-actions.ts index edeba23e..99c1a58b 100644 --- a/src/features/chat/application/threads/thread-management-actions.ts +++ b/src/features/chat/application/threads/thread-management-actions.ts @@ -1,5 +1,11 @@ -import type { AppServerClient } from "../../../../app-server/connection/client"; -import { forkThread as forkThreadOnAppServer, rollbackThread as rollbackThreadOnAppServer } from "../../../../app-server/threads"; +import { + compactThread as compactThreadOnAppServer, + forkThread as forkThreadOnAppServer, + rollbackThread as rollbackThreadOnAppServer, + type ThreadCompactionClient, + type ThreadForkClient, + type ThreadRollbackClient, +} from "../../../../app-server/threads"; import { inheritedForkThreadName } from "../../../../domain/threads/model"; import type { ThreadCatalogEvent } from "../../../../workspace/thread-catalog"; import type { ThreadOperations } from "../../../threads/thread-operations"; @@ -20,8 +26,8 @@ export interface ThreadManagementActionsHost { stateStore: ChatStateStore; vaultPath: string; operations: ThreadOperations; - connectedClient: () => Promise; - currentClient: () => AppServerClient | null; + connectedClient: () => Promise; + currentClient: () => ThreadManagementClient | null; addSystemMessage: (text: string) => void; setStatus: (status: string) => void; setComposerText: (text: string) => void; @@ -43,11 +49,13 @@ export interface ThreadManagementActions { } interface ThreadManagementActionScope { - client: AppServerClient; + client: ThreadManagementClient; targetThreadId: string; initialActiveThreadId: string | null; } +type ThreadManagementClient = ThreadCompactionClient & ThreadForkClient & ThreadRollbackClient; + export function createThreadManagementActions(host: ThreadManagementActionsHost): ThreadManagementActions { return { compactActiveThread: () => compactActiveThread(host), @@ -113,7 +121,7 @@ async function compactThread(host: ThreadManagementActionsHost, threadId: string const scope = await captureThreadManagementActionScope(host, threadId); if (!scope) return; try { - await scope.client.compactThread(threadId); + await compactThreadOnAppServer(scope.client, threadId); if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return; host.addSystemMessage(STATUS_COMPACTION_REQUESTED); host.setStatus(STATUS_COMPACTION_REQUESTED); diff --git a/tests/app-server/ephemeral-structured-turn.test.ts b/tests/app-server/ephemeral-structured-turn.test.ts index 2510d812..3af6ed84 100644 --- a/tests/app-server/ephemeral-structured-turn.test.ts +++ b/tests/app-server/ephemeral-structured-turn.test.ts @@ -19,7 +19,8 @@ import type { ServerRequest } from "../../src/generated/app-server/ServerRequest import type { ModelListResponse } from "../../src/generated/app-server/v2/ModelListResponse"; import type { Thread as AppServerThread } from "../../src/generated/app-server/v2/Thread"; import type { ThreadStartResponse } from "../../src/generated/app-server/v2/ThreadStartResponse"; -import type { TurnStartResponse } from "../../src/generated/app-server/v2/TurnStartResponse"; + +type TurnStartResponse = Awaited>; describe("runEphemeralStructuredTurn", () => { it("fills completed turn items from item completion notifications", async () => { @@ -376,16 +377,18 @@ function agentMessage(id: string, text: string): TurnItem { } function completedItemNotification(threadId: string, turnId: string, item: TurnItem): ServerNotification { + type ItemCompletedNotification = Extract; return { method: "item/completed", - params: { threadId, turnId, item, completedAtMs: 1 }, + params: { threadId, turnId, item, completedAtMs: 1 } as unknown as ItemCompletedNotification["params"], }; } function turnCompletedNotification(threadId: string, completedTurn: TurnRecord): ServerNotification { + type TurnCompletedNotification = Extract; return { method: "turn/completed", - params: { threadId, turn: completedTurn }, + params: { threadId, turn: completedTurn } as unknown as TurnCompletedNotification["params"], }; } diff --git a/tests/app-server/generated-import-boundary.test.ts b/tests/app-server/generated-import-boundary.test.ts index 40775012..95fd7236 100644 --- a/tests/app-server/generated-import-boundary.test.ts +++ b/tests/app-server/generated-import-boundary.test.ts @@ -17,14 +17,14 @@ describe("generated app-server import boundary", () => { expect(generatedImportPattern.test('export * from "../../src/generated/app-server/v2/Thread";')).toBe(true); }); - it("keeps generated app-server types behind the app-server boundary", async () => { + it("keeps generated app-server types behind explicit app-server adapters", async () => { const files = await sourceFiles(sourceRoot); const offenders: string[] = []; for (const file of files) { const relativePath = slashPath(path.relative(repoRoot, file)); if (relativePath.startsWith("src/generated/")) continue; - if (relativePath.startsWith("src/app-server/")) continue; + if (allowsGeneratedAppServerImport(relativePath)) continue; if (generatedImportPattern.test(await readFile(file, "utf8"))) offenders.push(relativePath); } @@ -61,3 +61,7 @@ async function sourceFiles(directory: string): Promise { function slashPath(value: string): string { return value.split(path.sep).join("/"); } + +function allowsGeneratedAppServerImport(relativePath: string): boolean { + return relativePath.startsWith("src/app-server/connection/") || relativePath === "src/app-server/protocol/server-requests.ts"; +} diff --git a/tests/app-server/thread-title-generation.test.ts b/tests/app-server/thread-title-generation.test.ts index e550f6fb..d3d66b31 100644 --- a/tests/app-server/thread-title-generation.test.ts +++ b/tests/app-server/thread-title-generation.test.ts @@ -23,7 +23,7 @@ type InitializeResponse = ServerInitialization; type ModelListResponse = Awaited>; type ThreadStartResponse = Awaited>; type Turn = TurnRecord; -type TurnStartResponse = Awaited>; +type TurnStartResponse = Awaited>; describe("thread title", () => { it("builds title context from a conversation summary", () => { @@ -325,16 +325,18 @@ function assistantMessage(id: string, text: string): TurnItem { } function completedItemNotification(threadId: string, turnId: string, item: TurnItem): ServerNotification { + type ItemCompletedNotification = Extract; return { method: "item/completed", - params: { threadId, turnId, item, completedAtMs: 1 }, + params: { threadId, turnId, item, completedAtMs: 1 } as unknown as ItemCompletedNotification["params"], }; } function turnCompletedNotification(threadId: string, completedTurn: Turn): ServerNotification { + type TurnCompletedNotification = Extract; return { method: "turn/completed", - params: { threadId, turn: completedTurn }, + params: { threadId, turn: completedTurn } as unknown as TurnCompletedNotification["params"], }; } diff --git a/tests/features/chat/protocol/inbound/handler.test.ts b/tests/features/chat/protocol/inbound/handler.test.ts index 70d79d57..988a7b28 100644 --- a/tests/features/chat/protocol/inbound/handler.test.ts +++ b/tests/features/chat/protocol/inbound/handler.test.ts @@ -1557,7 +1557,10 @@ describe("ChatInboundHandler", () => { handler.handleNotification({ method: "turn/completed", - params: { threadId: "thread-active", turn }, + params: { + threadId: "thread-active", + turn: turn as unknown as Extract["params"]["turn"], + }, } satisfies Extract); expect(maybeNameThread).toHaveBeenCalledWith("thread-active", "turn-active", { diff --git a/tests/features/selection-rewrite/selection-rewrite.test.ts b/tests/features/selection-rewrite/selection-rewrite.test.ts index 064ca3ed..ea77f779 100644 --- a/tests/features/selection-rewrite/selection-rewrite.test.ts +++ b/tests/features/selection-rewrite/selection-rewrite.test.ts @@ -31,9 +31,9 @@ type InitializeResponse = ServerInitialization; type ModelListResponse = Awaited>; type ThreadStartResponse = Awaited>; type Turn = TurnRecord; -type TurnStartResponse = Awaited>; type SelectionRewriteClientFactory = NonNullable[0]["clientFactory"]>; type SelectionRewriteClient = ReturnType; +type TurnStartResponse = Awaited>; installObsidianDomShims(); (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -900,16 +900,18 @@ function agentDeltaNotification(threadId: string, turnId: string, delta: string) } function completedItemNotification(threadId: string, turnId: string, item: TurnItem): ServerNotification { + type ItemCompletedNotification = Extract; return { method: "item/completed", - params: { threadId, turnId, item, completedAtMs: 1 }, + params: { threadId, turnId, item, completedAtMs: 1 } as unknown as ItemCompletedNotification["params"], }; } function turnCompletedNotification(threadId: string, completedTurn: Turn): ServerNotification { + type TurnCompletedNotification = Extract; return { method: "turn/completed", - params: { threadId, turn: completedTurn }, + params: { threadId, turn: completedTurn } as unknown as TurnCompletedNotification["params"], }; } diff --git a/tests/scripts/grit-policy.test.mjs b/tests/scripts/grit-policy.test.mjs index 68df34ca..43aac7e8 100644 --- a/tests/scripts/grit-policy.test.mjs +++ b/tests/scripts/grit-policy.test.mjs @@ -329,11 +329,24 @@ export interface RuntimeOverrideModelClient { import type { ThreadItem as GeneratedTurnItem } from "../../generated/app-server/v2/ThreadItem"; export type TurnItem = GeneratedTurnItem; +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/app-server/protocol/server-requests.ts"), + ` +import type { ServerRequest } from "../../generated/app-server/ServerRequest"; + +export type Request = ServerRequest; `.trimStart(), ); const report = biomeLint( - ["src/app-server/protocol/request-input.ts", "src/app-server/services/runtime-overrides.ts", "src/app-server/protocol/turn.ts"], + [ + "src/app-server/protocol/request-input.ts", + "src/app-server/services/runtime-overrides.ts", + "src/app-server/protocol/turn.ts", + "src/app-server/protocol/server-requests.ts", + ], cwd, ); @@ -343,7 +356,10 @@ export type TurnItem = GeneratedTurnItem; expect(pluginMessages(report, "src/app-server/services/runtime-overrides.ts")).toEqual([ "Keep generated app-server types behind src/app-server and tests/app-server; expose Panel-owned models outside raw app-server adapters.", ]); - expect(pluginDiagnostics(report, "src/app-server/protocol/turn.ts")).toEqual([]); + expect(pluginMessages(report, "src/app-server/protocol/turn.ts")).toEqual([ + "Keep generated app-server types behind src/app-server and tests/app-server; expose Panel-owned models outside raw app-server adapters.", + ]); + expect(pluginDiagnostics(report, "src/app-server/protocol/server-requests.ts")).toEqual([]); }); it("keeps lower-level source independent from connection and feature layers", async () => { @@ -419,7 +435,7 @@ export const format = formatDate; expect(pluginDiagnostics(report, "src/domain/threads/format.ts")).toEqual([]); }); - it("keeps generated app-server import shapes behind narrow aliases and protocol exceptions", async () => { + it("keeps generated app-server import shapes behind narrow aliases and server request exceptions", async () => { const cwd = await tempBiomeWorkspace(["no-generated-app-server-import-shapes.grit"]); await writeFile( path.join(cwd, "src/app-server/connection/thread.ts"), @@ -446,13 +462,15 @@ export type ConnectionThread = ThreadRecord; `.trimStart(), ); await writeFile( - path.join(cwd, "src/app-server/protocol/turn.ts"), + path.join(cwd, "src/app-server/connection/import-type-thread.ts"), ` -import type { ThreadItem as GeneratedTurnItem } from "../../generated/app-server/v2/ThreadItem"; -import type { UserInput } from "../../generated/app-server/v2/UserInput"; - -export type TurnItem = GeneratedTurnItem; -export type Input = UserInput; +export type ConnectionThread = import("../../generated/app-server/v2/Thread").Thread; +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/app-server/connection/export-thread.ts"), + ` +export type { Thread } from "../../generated/app-server/v2/Thread"; `.trimStart(), ); await writeFile( @@ -479,7 +497,8 @@ export const loadedParams = params; "src/app-server/connection/thread.ts", "src/app-server/connection/aliased-thread.ts", "src/app-server/connection/record-thread.ts", - "src/app-server/protocol/turn.ts", + "src/app-server/connection/import-type-thread.ts", + "src/app-server/connection/export-thread.ts", "src/app-server/protocol/server-requests.ts", ], cwd, @@ -492,8 +511,11 @@ export const loadedParams = params; expect(pluginMessages(report, "src/app-server/connection/record-thread.ts")).toEqual([ "Import generated app-server Thread as AppServerThread, or use the Panel-owned domain Thread model.", ]); - expect(pluginMessages(report, "src/app-server/protocol/turn.ts")).toEqual([ - "Only generated ThreadItem is allowed in the turn protocol exception. Model other turn payload shapes locally.", + expect(pluginMessages(report, "src/app-server/connection/import-type-thread.ts")).toEqual([ + "Import generated app-server Thread as AppServerThread, or use the Panel-owned domain Thread model.", + ]); + expect(pluginMessages(report, "src/app-server/connection/export-thread.ts")).toEqual([ + "Import generated app-server Thread as AppServerThread, or use the Panel-owned domain Thread model.", ]); expect(pluginMessages(report, "src/app-server/protocol/server-requests.ts")).toEqual([ "Only generated RequestId and ServerRequest are allowed in the server request protocol exception.", @@ -530,7 +552,7 @@ export async function read(client: AppServerClient): Promise { ` import type { AppServerClient } from "../../../app-server/connection/client"; -type Resume = AppServerClient["resumeThread"]; +export type AppServerThreadResumeClient = Pick; `.trimStart(), ); @@ -543,9 +565,7 @@ type Resume = AppServerClient["resumeThread"]; "Keep app-server projection RPCs behind app-server facades; chat application code should consume Panel-owned snapshots or view models.", ]); expect(pluginDiagnostics(report, "src/app-server/threads.ts")).toEqual([]); - expect(pluginMessages(report, "src/features/chat/host/connection-bundle.ts")).toEqual([ - "Do not expose app-server projection RPC signatures through AppServerClient indexed access types; define a Panel-owned projection type instead.", - ]); + expect(pluginDiagnostics(report, "src/features/chat/host/connection-bundle.ts")).toEqual([]); }); it("keeps imperative DOM bridges behind filename suffixes", async () => {