Isolate app-server protocol boundaries

This commit is contained in:
murashit 2026-06-25 18:06:39 +09:00
parent 2888c0cff0
commit dd280f74e7
16 changed files with 203 additions and 81 deletions

View file

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

View file

@ -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")
}

View file

@ -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")
}
}

View file

@ -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 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[];

View file

@ -20,6 +20,10 @@ import {
const THREAD_LIST_PAGE_LIMIT = 100;
export type ThreadTurnSortDirection = "asc" | "desc";
export type ThreadConversationSummaryClient = Pick<AppServerClient, "threadTurnsList">;
export type ThreadForkClient = Pick<AppServerClient, "forkThread">;
export type ThreadRollbackClient = Pick<AppServerClient, "rollbackThread">;
export type ThreadCompactionClient = Pick<AppServerClient, "compactThread">;
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<ThreadConversationSummary[]> {
@ -116,16 +120,20 @@ function threadRollbackSnapshotFromAppServerResponse(response: ThreadRollbackRes
};
}
export async function rollbackThread(client: AppServerClient, threadId: string, numTurns?: number): Promise<ThreadRollbackSnapshot> {
export async function rollbackThread(client: ThreadRollbackClient, threadId: string, numTurns?: number): Promise<ThreadRollbackSnapshot> {
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<Thread> {
export async function forkThread(client: ThreadForkClient, threadId: string, cwd: string): Promise<Thread> {
const response = await client.forkThread(threadId, cwd);
return threadFromThreadRecord(response.thread);
}
export async function compactThread(client: ThreadCompactionClient, threadId: string): Promise<void> {
await client.compactThread(threadId);
}
export async function restoreArchivedThread(client: AppServerClient, threadId: string): Promise<Thread> {
const response = await client.unarchiveThread(threadId);
return threadFromThreadRecord(response.thread);

View file

@ -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<AppServerClient, "threadTurnsList">;
export type ChatThreadResumeClient = Pick<AppServerClient, "resumeThread">;
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<ChatThreadResumeSnapshot> {
export async function resumeChatThread(client: ChatThreadResumeClient, threadId: string, cwd: string): Promise<ChatThreadResumeSnapshot> {
const response = await client.resumeThread(threadId, cwd);
return {
activation: threadActivationSnapshotFromAppServerResponse(response),

View file

@ -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<ChatStateStore["getState"]>
async function referencedThreadInput(
host: SlashCommandExecutorHost,
client: AppServerClient,
client: ThreadConversationSummaryClient,
thread: Thread,
message: string,
): Promise<ThreadReferenceInput | null> {

View file

@ -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<ChatThreadHistoryPage>;
readHistoryPage?: (
client: ChatThreadHistoryClient,
threadId: string,
cursor: string | null,
limit: number,
) => Promise<ChatThreadHistoryPage>;
}
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<ChatThreadHistoryPage> {
private readHistoryPage(
client: ChatThreadHistoryClient,
threadId: string,
cursor: string | null,
limit: number,
): Promise<ChatThreadHistoryPage> {
return (this.host.readHistoryPage ?? readChatThreadHistoryPage)(client, threadId, cursor, limit);
}
}

View file

@ -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<void>;
closing: () => boolean;
resetThreadTurnPresence: (hadTurns: boolean) => void;
@ -23,7 +22,7 @@ export interface ResumeActionsHost {
refreshLiveState: () => void;
syncThreadGoal: (threadId: string) => Promise<void>;
recoverTokenUsageFromRollout?: (path: string) => Promise<ThreadTokenUsage | null>;
resumeFromAppServer?: (client: AppServerClient, threadId: string, cwd: string) => Promise<ChatThreadResumeSnapshot>;
resumeFromAppServer?: (client: ChatThreadResumeClient, threadId: string, cwd: string) => Promise<ChatThreadResumeSnapshot>;
}
export interface ResumeActions {

View file

@ -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<AppServerClient | null>;
currentClient: () => AppServerClient | null;
connectedClient: () => Promise<ThreadManagementClient | null>;
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);

View file

@ -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<ReturnType<EphemeralStructuredTurnClient["startStructuredTurn"]>>;
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<ServerNotification, { method: "item/completed" }>;
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<ServerNotification, { method: "turn/completed" }>;
return {
method: "turn/completed",
params: { threadId, turn: completedTurn },
params: { threadId, turn: completedTurn } as unknown as TurnCompletedNotification["params"],
};
}

View file

@ -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<string[]> {
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";
}

View file

@ -23,7 +23,7 @@ type InitializeResponse = ServerInitialization;
type ModelListResponse = Awaited<ReturnType<AppServerClient["listModels"]>>;
type ThreadStartResponse = Awaited<ReturnType<AppServerClient["startEphemeralThread"]>>;
type Turn = TurnRecord;
type TurnStartResponse = Awaited<ReturnType<AppServerClient["startStructuredTurn"]>>;
type TurnStartResponse = Awaited<ReturnType<EphemeralStructuredTurnClient["startStructuredTurn"]>>;
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<ServerNotification, { method: "item/completed" }>;
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<ServerNotification, { method: "turn/completed" }>;
return {
method: "turn/completed",
params: { threadId, turn: completedTurn },
params: { threadId, turn: completedTurn } as unknown as TurnCompletedNotification["params"],
};
}

View file

@ -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<ServerNotification, { method: "turn/completed" }>["params"]["turn"],
},
} satisfies Extract<ServerNotification, { method: "turn/completed" }>);
expect(maybeNameThread).toHaveBeenCalledWith("thread-active", "turn-active", {

View file

@ -31,9 +31,9 @@ type InitializeResponse = ServerInitialization;
type ModelListResponse = Awaited<ReturnType<AppServerClient["listModels"]>>;
type ThreadStartResponse = Awaited<ReturnType<AppServerClient["startEphemeralThread"]>>;
type Turn = TurnRecord;
type TurnStartResponse = Awaited<ReturnType<AppServerClient["startStructuredTurn"]>>;
type SelectionRewriteClientFactory = NonNullable<Parameters<typeof runSelectionRewrite>[0]["clientFactory"]>;
type SelectionRewriteClient = ReturnType<SelectionRewriteClientFactory>;
type TurnStartResponse = Awaited<ReturnType<SelectionRewriteClient["startStructuredTurn"]>>;
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<ServerNotification, { method: "item/completed" }>;
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<ServerNotification, { method: "turn/completed" }>;
return {
method: "turn/completed",
params: { threadId, turn: completedTurn },
params: { threadId, turn: completedTurn } as unknown as TurnCompletedNotification["params"],
};
}

View file

@ -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<void> {
`
import type { AppServerClient } from "../../../app-server/connection/client";
type Resume = AppServerClient["resumeThread"];
export type AppServerThreadResumeClient = Pick<AppServerClient, "resumeThread">;
`.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 () => {