diff --git a/biome.jsonc b/biome.jsonc index ca81e800..94bd0f79 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -11,10 +11,6 @@ "path": "./scripts/lint/no-app-server-projection-rpcs.grit", "includes": ["**/src/features/chat/application/**/*.ts"] }, - { - "path": "./scripts/lint/no-chat-application-root-app-server-imports.grit", - "includes": ["**/src/features/chat/application/**/*.ts"] - }, { "path": "./scripts/lint/no-app-server-protocol-boundary-imports.grit", "includes": ["**/src/**/*.ts", "**/src/**/*.tsx", "!**/src/app-server/**"] diff --git a/docs/development.md b/docs/development.md index cd57d084..12177ef9 100644 --- a/docs/development.md +++ b/docs/development.md @@ -65,7 +65,7 @@ Chat application workflows should not import root `src/app-server/` modules or r 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. -Chat feature folders should keep dependencies flowing toward owned adapters and render surfaces: `application/` must not import host, panel, presentation, or UI layers; `app-server/` must not import host, panel, presentation, or UI layers; `panel/` must not import app-server adapters or host internals; `presentation/` must not import application, app-server, host, panel, or UI layers; and `ui/` must not import application, app-server, host, or panel layers. Biome enforces these folder-scoped import boundaries with per-folder plugin includes. +Chat feature folders should keep dependencies flowing toward owned adapters and render surfaces: `application/` must not import app-server, host, panel, presentation, or UI layers; `app-server/` must not import host, panel, presentation, or UI layers; `panel/` must not import app-server adapters or host internals; `presentation/` must not import application, app-server, host, panel, or UI layers; and `ui/` must not import application, app-server, host, or panel layers. Biome enforces these folder-scoped import boundaries with per-folder plugin includes. Use DOM reads, writes, measurements, hit-tests, focus/selection operations, and DOM event listener wiring only from explicit bridge modules, Obsidian-owned API boundaries, or rendering and measurement code that cannot be expressed cleanly as Preact components. Normal `.ts` and `.tsx` modules may keep refs and call named adapters, but they should not interpret DOM structure or layout directly. Name bridge files with a `.dom`, `.obsidian`, or `.measure` suffix so Biome can enforce the boundary without file-specific allowlists. diff --git a/scripts/lint/no-chat-application-outer-layer-imports.grit b/scripts/lint/no-chat-application-outer-layer-imports.grit index a09818f9..420e452c 100644 --- a/scripts/lint/no-chat-application-outer-layer-imports.grit +++ b/scripts/lint/no-chat-application-outer-layer-imports.grit @@ -12,7 +12,7 @@ private pattern js_module_reference() { js_module_reference() as $stmt where { $stmt <: contains `$source` where { - $source <: r"^[\"'](?:(?:\.\./)+(?:host|panel|presentation|ui)|src/features/chat/(?:host|panel|presentation|ui))(?:/.*)?[\"']$" + $source <: r"^[\"'](?:(?:\.\./)+(?:app-server|host|panel|presentation|ui)|src/(?:app-server|features/chat/(?:app-server|host|panel|presentation|ui)))(?:/.*)?[\"']$" }, - register_diagnostic(span=$stmt, message="Chat application modules must not import host, panel, presentation, or UI layers; expose state and workflow contracts instead.", severity="error") + register_diagnostic(span=$stmt, message="Chat application modules must not import app-server, host, panel, presentation, or UI layers; expose state and workflow contracts instead.", severity="error") } diff --git a/scripts/lint/no-chat-application-root-app-server-imports.grit b/scripts/lint/no-chat-application-root-app-server-imports.grit deleted file mode 100644 index 9ab9efd1..00000000 --- a/scripts/lint/no-chat-application-root-app-server-imports.grit +++ /dev/null @@ -1,16 +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"^[\"'](?:(?:\.\./){3,}app-server|src/app-server)(?:/.*)?[\"']$" }, - register_diagnostic(span=$stmt, message="Chat application modules must not import root app-server modules; use chat app-server transports or application ports instead.", severity="error") -} diff --git a/src/features/chat/app-server/goals/transport.ts b/src/features/chat/app-server/goals/transport.ts index 9cd99c03..6b9bcaa8 100644 --- a/src/features/chat/app-server/goals/transport.ts +++ b/src/features/chat/app-server/goals/transport.ts @@ -1,17 +1,18 @@ import { readThreadGoal, recordThreadGoalUserMessage, setThreadGoal } from "../../../../app-server/threads"; -import type { ThreadGoalTransport } from "../../application/threads/goal-transport"; -import type { ConnectedChatAppServerClientHost } from "../client-scope"; +import type { ThreadGoalReader, ThreadGoalTransport } from "../../application/threads/goal-transport"; +import type { ConnectedChatAppServerClientHost, CurrentChatAppServerClientHost } from "../client-scope"; import { chatAppServerClientIsStale, withConnectedChatAppServerClient, withCurrentChatAppServerClient } from "../client-scope"; +export function createChatThreadGoalReadTransport(host: CurrentChatAppServerClientHost): ThreadGoalReader { + return { + readThreadGoal: (threadId) => readThreadGoalFromCurrentClient(host, threadId), + }; +} + export function createChatThreadGoalTransport(host: ConnectedChatAppServerClientHost): ThreadGoalTransport { return { ensureConnected: async () => (await host.connectedClient()) !== null, - readThreadGoal: async (threadId) => { - const client = host.currentClient(); - if (!client) return undefined; - const goal = await readThreadGoal(client, threadId); - return chatAppServerClientIsStale(host, client) ? undefined : goal; - }, + readThreadGoal: (threadId) => readThreadGoalFromCurrentClient(host, threadId), setThreadGoal: async (threadId, params) => { const client = await host.connectedClient(); if (!client) return undefined; @@ -34,3 +35,13 @@ export function createChatThreadGoalTransport(host: ConnectedChatAppServerClient }, }; } + +async function readThreadGoalFromCurrentClient( + host: CurrentChatAppServerClientHost, + threadId: string, +): ReturnType { + const client = host.currentClient(); + if (!client) return undefined; + const goal = await readThreadGoal(client, threadId); + return chatAppServerClientIsStale(host, client) ? undefined : goal; +} diff --git a/src/features/chat/app-server/threads/loading-transport.ts b/src/features/chat/app-server/threads/loading-transport.ts new file mode 100644 index 00000000..c9929fb4 --- /dev/null +++ b/src/features/chat/app-server/threads/loading-transport.ts @@ -0,0 +1,27 @@ +import type { + ThreadHistoryPage, + ThreadHistoryTransport, + ThreadResumeSnapshot, + ThreadResumeTransport, +} from "../../application/threads/thread-loading-transport"; +import type { ConnectedChatAppServerClientHost, CurrentChatAppServerClientHost } from "../client-scope"; +import { withConnectedChatAppServerClient, withCurrentChatAppServerClient } from "../client-scope"; +import { readChatThreadHistoryPage, resumeChatThread } from "./projection"; + +interface ChatThreadResumeTransportHost extends ConnectedChatAppServerClientHost { + vaultPath: string; +} + +export function createChatThreadHistoryTransport(host: CurrentChatAppServerClientHost): ThreadHistoryTransport { + return { + readHistoryPage: (threadId, cursor, limit): Promise => + withCurrentChatAppServerClient(host, (client) => readChatThreadHistoryPage(client, threadId, cursor, limit)), + }; +} + +export function createChatThreadResumeTransport(host: ChatThreadResumeTransportHost): ThreadResumeTransport { + return { + resumeThread: (threadId): Promise => + withConnectedChatAppServerClient(host, (client) => resumeChatThread(client, threadId, host.vaultPath)), + }; +} diff --git a/src/features/chat/app-server/threads/projection.ts b/src/features/chat/app-server/threads/projection.ts index 808a76da..9721f4bb 100644 --- a/src/features/chat/app-server/threads/projection.ts +++ b/src/features/chat/app-server/threads/projection.ts @@ -1,40 +1,27 @@ import type { AppServerClient } from "../../../../app-server/connection/client"; import { threadActivationSnapshotFromAppServerResponse } from "../../../../app-server/threads"; -import type { ThreadActivationSnapshot } from "../../../../domain/threads/activation"; import type { ThreadTurnsPage } from "../../../../domain/threads/history"; -import type { MessageStreamItem } from "../../domain/message-stream/items"; +import type { ThreadHistoryPage, ThreadResumeSnapshot } from "../../application/threads/thread-loading-transport"; import { messageStreamItemsFromTurns } from "../mappers/message-stream/turn-items"; -export type ChatThreadHistoryClient = Pick; -export type ChatThreadResumeClient = Pick; +type ChatThreadHistoryClient = Pick; +type ChatThreadResumeClient = Pick; interface AppServerThreadTurnsPage { readonly data: ThreadTurnsPage["turns"]; readonly nextCursor: string | null; } -export interface ChatThreadHistoryPage { - items: MessageStreamItem[]; - nextCursor: string | null; - hadTurns: boolean; -} - -export interface ChatThreadResumeSnapshot { - activation: ThreadActivationSnapshot; - rolloutPath: string | null; - initialHistoryPage: ChatThreadHistoryPage | null; -} - export async function readChatThreadHistoryPage( client: ChatThreadHistoryClient, threadId: string, cursor: string | null, limit = 20, -): Promise { +): Promise { return chatThreadHistoryPageFromTurnsPage(threadTurnsPageFromAppServerPage(await client.threadTurnsList(threadId, cursor, limit))); } -function chatThreadHistoryPageFromTurnsPage(page: ThreadTurnsPage): ChatThreadHistoryPage { +function chatThreadHistoryPageFromTurnsPage(page: ThreadTurnsPage): ThreadHistoryPage { return { items: messageStreamItemsFromTurns(page.turns), nextCursor: page.nextCursor, @@ -42,7 +29,7 @@ function chatThreadHistoryPageFromTurnsPage(page: ThreadTurnsPage): ChatThreadHi }; } -export async function resumeChatThread(client: ChatThreadResumeClient, 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/app-server/turns/transport.ts b/src/features/chat/app-server/turns/transport.ts index 35d68bce..2dcf38d0 100644 --- a/src/features/chat/app-server/turns/transport.ts +++ b/src/features/chat/app-server/turns/transport.ts @@ -1,6 +1,6 @@ import type { ChatTurnTransport } from "../../application/conversation/turn-transport"; import type { ConnectedChatAppServerClientHost } from "../client-scope"; -import { chatAppServerClientIsStale } from "../client-scope"; +import { withCurrentChatAppServerClient } from "../client-scope"; interface ChatTurnTransportHost extends ConnectedChatAppServerClientHost { vaultPath: string; @@ -9,28 +9,29 @@ interface ChatTurnTransportHost extends ConnectedChatAppServerClientHost { export function createChatTurnTransport(host: ChatTurnTransportHost): ChatTurnTransport { return { ensureConnected: async () => (await host.connectedClient()) !== null, - startTurn: async (request) => { - const client = host.currentClient(); - if (!client) return null; - const response = await client.startTurn({ - threadId: request.threadId, - cwd: host.vaultPath, - input: request.input, - clientUserMessageId: request.clientUserMessageId, - }); - return chatAppServerClientIsStale(host, client) ? null : { turnId: response.turn.id }; - }, + startTurn: (request) => + withCurrentChatAppServerClient(host, async (client) => { + const response = await client.startTurn({ + threadId: request.threadId, + cwd: host.vaultPath, + input: request.input, + clientUserMessageId: request.clientUserMessageId, + }); + return { turnId: response.turn.id }; + }), steerTurn: async (request) => { - const client = host.currentClient(); - if (!client) return false; - await client.steerTurn(request.threadId, request.turnId, request.input, request.clientUserMessageId); - return !chatAppServerClientIsStale(host, client); + const steered = await withCurrentChatAppServerClient(host, async (client) => { + await client.steerTurn(request.threadId, request.turnId, request.input, request.clientUserMessageId); + return true; + }); + return steered ?? false; }, interruptTurn: async (threadId, turnId) => { - const client = host.currentClient(); - if (!client) return false; - await client.interruptTurn(threadId, turnId); - return !chatAppServerClientIsStale(host, client); + const interrupted = await withCurrentChatAppServerClient(host, async (client) => { + await client.interruptTurn(threadId, turnId); + return true; + }); + return interrupted ?? false; }, }; } diff --git a/src/features/chat/application/threads/auto-title-coordinator.ts b/src/features/chat/application/threads/auto-title-coordinator.ts index fa8a023e..99e0d7c5 100644 --- a/src/features/chat/application/threads/auto-title-coordinator.ts +++ b/src/features/chat/application/threads/auto-title-coordinator.ts @@ -1,13 +1,12 @@ import type { Thread } from "../../../../domain/threads/model"; import type { ThreadTitleContext } from "../../../../domain/threads/title-generation-model"; import type { ThreadConversationSummary } from "../../../../domain/threads/transcript"; -import type { ThreadTitleService } from "../../../threads/thread-title-service"; import type { ChatStateStore } from "../state/store"; export interface AutoTitleCoordinatorHost { stateStore: ChatStateStore; - completedTurnTitleContext: ThreadTitleService["completedTurnContext"]; - generateTitleFromContext: ThreadTitleService["generate"]; + completedTurnTitleContext(turnId: string, completedSummary: ThreadConversationSummary | null): ThreadTitleContext | null; + generateTitleFromContext(context: ThreadTitleContext): Promise; renameGeneratedTitle(threadId: string, title: string, options: { shouldPublish: () => boolean }): Promise; } diff --git a/src/features/chat/application/threads/goal-actions.ts b/src/features/chat/application/threads/goal-actions.ts index 1da175ae..d137f624 100644 --- a/src/features/chat/application/threads/goal-actions.ts +++ b/src/features/chat/application/threads/goal-actions.ts @@ -3,11 +3,11 @@ import type { LocalIdSource } from "../../../../shared/id/local-id"; import { goalChangeItem } from "../../domain/message-stream/factories/goal-items"; import type { GoalMessageStreamItem } from "../../domain/message-stream/items"; import type { ChatStateStore } from "../state/store"; -import type { ThreadGoalTransport } from "./goal-transport"; +import type { ThreadGoalReader, ThreadGoalTransport } from "./goal-transport"; export interface ThreadGoalSyncHost { stateStore: ChatStateStore; - goalTransport: ThreadGoalTransport; + goalTransport: ThreadGoalReader; localItemIds: LocalIdSource; addSystemMessage: (text: string) => void; addGoalEvent: (item: GoalMessageStreamItem) => void; @@ -15,6 +15,7 @@ export interface ThreadGoalSyncHost { } export interface GoalActionsHost extends ThreadGoalSyncHost { + goalTransport: ThreadGoalTransport; startThread: (preview?: string, options?: { syncGoal?: boolean }) => Promise<{ threadId: string } | null>; } diff --git a/src/features/chat/application/threads/goal-transport.ts b/src/features/chat/application/threads/goal-transport.ts index d196d531..7148d5e3 100644 --- a/src/features/chat/application/threads/goal-transport.ts +++ b/src/features/chat/application/threads/goal-transport.ts @@ -1,7 +1,10 @@ import type { ThreadGoal, ThreadGoalUpdate } from "../../../../domain/threads/goal"; -export interface ThreadGoalTransport { +export interface ThreadGoalReader { readThreadGoal(threadId: string): Promise; +} + +export interface ThreadGoalTransport extends ThreadGoalReader { setThreadGoal(threadId: string, params: ThreadGoalUpdate): Promise; clearThreadGoal(threadId: string): Promise; recordThreadGoalUserMessage(threadId: string, objective: string): Promise; diff --git a/src/features/chat/application/threads/history-controller.ts b/src/features/chat/application/threads/history-controller.ts index d7038d18..c199e9d6 100644 --- a/src/features/chat/application/threads/history-controller.ts +++ b/src/features/chat/application/threads/history-controller.ts @@ -1,20 +1,14 @@ -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"; +import type { ThreadHistoryPage, ThreadHistoryTransport } from "./thread-loading-transport"; export interface HistoryControllerHost { stateStore: ChatStateStore; - currentClient: () => ChatThreadHistoryClient | null; + historyTransport: ThreadHistoryTransport; addSystemMessage: (text: string) => void; showLatestPageAtBottom: () => void; setThreadTurnPresence: (hadTurns: boolean) => void; - readHistoryPage?: ( - client: ChatThreadHistoryClient, - threadId: string, - cursor: string | null, - limit: number, - ) => Promise; } type ThreadHistoryLoadLifecycleState = { kind: "idle" } | { kind: "loading"; threadId: string; mode: "latest" | "older" }; @@ -43,11 +37,11 @@ export class HistoryController { } async loadLatest(threadId = this.state.activeThread.id): Promise { - const client = this.host.currentClient(); - if (!client || !threadId) return; + if (!threadId) return; const load = this.startLoading(threadId, "latest"); try { - const response = await this.readHistoryPage(client, threadId, null, 20); + const response = await this.host.historyTransport.readHistoryPage(threadId, null, 20); + if (!response) return; if (this.isStale(load)) return; this.applyLatestPage(threadId, response); } catch (error) { @@ -58,7 +52,7 @@ export class HistoryController { } } - applyLatestPage(threadId: string, response: ChatThreadHistoryPage): boolean { + applyLatestPage(threadId: string, response: ThreadHistoryPage): boolean { if (this.state.activeThread.id !== threadId) return false; this.host.setThreadTurnPresence(response.hadTurns); this.host.showLatestPageAtBottom(); @@ -71,14 +65,14 @@ export class HistoryController { } async loadOlder(): Promise { - const client = this.host.currentClient(); const state = this.state; - if (!client || !state.activeThread.id || !state.messageStream.historyCursor || state.messageStream.loadingHistory) return; + if (!state.activeThread.id || !state.messageStream.historyCursor || state.messageStream.loadingHistory) return; const threadId = state.activeThread.id; const cursor = state.messageStream.historyCursor; const load = this.startLoading(threadId, "older"); try { - const response = await this.readHistoryPage(client, threadId, cursor, 20); + const response = await this.host.historyTransport.readHistoryPage(threadId, cursor, 20); + if (!response) return; if (this.isStale(load)) return; const current = this.state; const currentItems = messageStreamItems(current.messageStream); @@ -113,15 +107,6 @@ export class HistoryController { private isStale(load: ActiveThreadHistoryLoad): boolean { return this.lifecycle !== load || this.state.activeThread.id !== load.threadId; } - - private readHistoryPage( - client: ChatThreadHistoryClient, - threadId: string, - cursor: string | null, - limit: number, - ): Promise { - return (this.host.readHistoryPage ?? readChatThreadHistoryPage)(client, threadId, cursor, limit); - } } function transitionThreadHistoryLoadLifecycle( diff --git a/src/features/chat/application/threads/lifecycle-parts.ts b/src/features/chat/application/threads/lifecycle-parts.ts index dd4e47b4..dd691c13 100644 --- a/src/features/chat/application/threads/lifecycle-parts.ts +++ b/src/features/chat/application/threads/lifecycle-parts.ts @@ -1,5 +1,4 @@ import type { ThreadTokenUsage } from "../../../../domain/runtime/metrics"; -import type { ChatThreadResumeClient } from "../../app-server/threads/projection"; import type { ChatResumeWorkTracker } from "../lifecycle"; import type { ChatStateStore } from "../state/store"; import { createActiveThreadIdentitySync } from "./active-thread-identity-sync"; @@ -7,14 +6,11 @@ import type { GoalActions } from "./goal-actions"; import type { HistoryController } from "./history-controller"; import { RestorationController } from "./restoration-controller"; import { createResumeActions, type ResumeActions } from "./resume-actions"; +import type { ThreadResumeTransport } from "./thread-loading-transport"; export interface ThreadLifecyclePartsContext { - settingsRef: { readonly vaultPath: string }; stateStore: ChatStateStore; - client: { - currentClient: () => ChatThreadResumeClient | null; - ensureConnected: () => Promise; - }; + resumeTransport: ThreadResumeTransport; lifecycle: { resumeWork: ChatResumeWorkTracker; history: HistoryController; @@ -45,7 +41,7 @@ export interface ThreadLifecycleParts { } export function createThreadLifecycleParts(context: ThreadLifecyclePartsContext): ThreadLifecycleParts { - const { settingsRef, stateStore, client, lifecycle, thread, status, liveState, goals, resetThreadTurnPresence } = context; + const { stateStore, resumeTransport, lifecycle, thread, status, liveState, goals, resetThreadTurnPresence } = context; const { resumeWork, history, invalidateThreadWork } = lifecycle; const restoration = new RestorationController({ invalidateThreadWork, @@ -54,12 +50,10 @@ export function createThreadLifecycleParts(context: ThreadLifecyclePartsContext) }); const resume = createResumeActions({ stateStore, - vaultPath: settingsRef.vaultPath, resumeWork, history, restoration, - currentClient: client.currentClient, - ensureConnected: client.ensureConnected, + resumeTransport, closing: lifecycle.getClosing, resetThreadTurnPresence, notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged, diff --git a/src/features/chat/application/threads/rename-editor-actions.ts b/src/features/chat/application/threads/rename-editor-actions.ts index 14c1530e..37173776 100644 --- a/src/features/chat/application/threads/rename-editor-actions.ts +++ b/src/features/chat/application/threads/rename-editor-actions.ts @@ -1,7 +1,5 @@ import { threadRenameDraftTitle } from "../../../../domain/threads/title"; import type { ThreadTitleContext } from "../../../../domain/threads/title-generation-model"; -import type { ThreadOperations } from "../../../threads/thread-operations"; -import type { ThreadTitleService } from "../../../threads/thread-title-service"; import { messageStreamItems } from "../state/message-stream"; import type { ChatAction, ChatState } from "../state/root-reducer"; import type { ChatStateStore } from "../state/store"; @@ -17,8 +15,8 @@ export interface ThreadRenameEditorActionsHost { stateStore: ChatStateStore; ensureConnected: () => Promise; addSystemMessage: (text: string) => void; - renameThread: ThreadOperations["renameThread"]; - generateThreadTitle: ThreadTitleService["generateTitle"]; + renameThread(threadId: string, value: string): Promise; + generateThreadTitle(threadId: string): Promise; } export interface ThreadRenameEditorActions { diff --git a/src/features/chat/application/threads/resume-actions.ts b/src/features/chat/application/threads/resume-actions.ts index 410a0213..da8d915f 100644 --- a/src/features/chat/application/threads/resume-actions.ts +++ b/src/features/chat/application/threads/resume-actions.ts @@ -1,21 +1,19 @@ import type { ThreadTokenUsage } from "../../../../domain/runtime/metrics"; -import { type ChatThreadResumeClient, type ChatThreadResumeSnapshot, resumeChatThread } from "../../app-server/threads/projection"; import type { ActiveChatResume, ChatResumeWorkTracker } from "../lifecycle"; import { resumedThreadAction } from "../state/actions"; import { messageStreamIsEmpty } from "../state/message-stream"; import type { ChatStateStore } from "../state/store"; import type { HistoryController } from "./history-controller"; import type { RestorationController } from "./restoration-controller"; +import type { ThreadResumeSnapshot, ThreadResumeTransport } from "./thread-loading-transport"; import { canSwitchToThread } from "./thread-switching"; export interface ResumeActionsHost { stateStore: ChatStateStore; - vaultPath: string; resumeWork: ChatResumeWorkTracker; history: HistoryController; restoration: RestorationController; - currentClient: () => ChatThreadResumeClient | null; - ensureConnected: () => Promise; + resumeTransport: ThreadResumeTransport; closing: () => boolean; resetThreadTurnPresence: (hadTurns: boolean) => void; notifyActiveThreadIdentityChanged: () => void; @@ -23,7 +21,6 @@ export interface ResumeActionsHost { refreshLiveState: () => void; syncThreadGoal: (threadId: string) => Promise; recoverTokenUsageFromRollout?: (path: string) => Promise; - resumeFromAppServer?: (client: ChatThreadResumeClient, threadId: string, cwd: string) => Promise; } export interface ResumeActions { @@ -43,12 +40,10 @@ async function resumeThread(host: ResumeActionsHost, threadId: string): Promise< } const resume = host.resumeWork.begin(threadId); host.history.invalidate(); - await host.ensureConnected(); - const client = host.currentClient(); - if (!client || isStaleResume(host, resume)) return; try { - const response = await (host.resumeFromAppServer ?? resumeChatThread)(client, threadId, host.vaultPath); + const response = await host.resumeTransport.resumeThread(threadId); + if (!response) return; if (isStaleResume(host, resume)) return; applyResumedThread(host, response); recoverResumedThreadTokenUsage(host, response.activation.thread.id, response.rolloutPath, resume); @@ -71,7 +66,7 @@ async function resumeThread(host: ResumeActionsHost, threadId: string): Promise< } } -function applyResumedThread(host: ResumeActionsHost, response: ChatThreadResumeSnapshot): void { +function applyResumedThread(host: ResumeActionsHost, response: ThreadResumeSnapshot): void { host.stateStore.dispatch( resumedThreadAction({ response: response.activation, diff --git a/src/features/chat/application/threads/thread-loading-transport.ts b/src/features/chat/application/threads/thread-loading-transport.ts new file mode 100644 index 00000000..cbb2a174 --- /dev/null +++ b/src/features/chat/application/threads/thread-loading-transport.ts @@ -0,0 +1,22 @@ +import type { ThreadActivationSnapshot } from "../../../../domain/threads/activation"; +import type { MessageStreamItem } from "../../domain/message-stream/items"; + +export interface ThreadHistoryPage { + items: MessageStreamItem[]; + nextCursor: string | null; + hadTurns: boolean; +} + +export interface ThreadHistoryTransport { + readHistoryPage(threadId: string, cursor: string | null, limit: number): Promise; +} + +export interface ThreadResumeSnapshot { + activation: ThreadActivationSnapshot; + rolloutPath: string | null; + initialHistoryPage: ThreadHistoryPage | null; +} + +export interface ThreadResumeTransport { + resumeThread(threadId: string): Promise; +} diff --git a/src/features/chat/application/threads/thread-management-actions.ts b/src/features/chat/application/threads/thread-management-actions.ts index 142d080c..dbd64317 100644 --- a/src/features/chat/application/threads/thread-management-actions.ts +++ b/src/features/chat/application/threads/thread-management-actions.ts @@ -1,6 +1,5 @@ import { inheritedForkThreadName } from "../../../../domain/threads/model"; import type { ThreadCatalogEvent } from "../../../../workspace/thread-catalog"; -import type { ThreadOperations } from "../../../threads/thread-operations"; import { activeThreadRuntimeState } from "../../domain/runtime/state"; import { chatTurnBusy } from "../conversation/turn-state"; import { resumedThreadActionFromActiveRuntime } from "../state/actions"; @@ -16,7 +15,7 @@ const STATUS_ROLLBACK_FAILED = "Rollback failed."; export interface ThreadManagementActionsHost { stateStore: ChatStateStore; - operations: ThreadOperations; + operations: ThreadManagementOperations; threadTransport: ThreadMutationTransport; addSystemMessage: (text: string) => void; setStatus: (status: string) => void; @@ -28,6 +27,11 @@ export interface ThreadManagementActionsHost { applyThreadCatalogEvent: (event: ThreadCatalogEvent) => void; } +interface ThreadManagementOperations { + renameThread(threadId: string, value: string): Promise; + archiveThread(threadId: string, options?: { saveMarkdown?: boolean; closeOpenPanels?: boolean }): Promise; +} + export interface ThreadManagementActions { compactActiveThread: () => Promise; compactThread: (threadId: string) => Promise; diff --git a/src/features/chat/host/thread-bundle.ts b/src/features/chat/host/thread-bundle.ts index 94ecef92..ad22e47c 100644 --- a/src/features/chat/host/thread-bundle.ts +++ b/src/features/chat/host/thread-bundle.ts @@ -7,7 +7,8 @@ import type { LocalIdSource } from "../../../shared/id/local-id"; import { createThreadOperations, type ThreadOperations } from "../../threads/thread-operations"; import { createThreadTitleService, type ThreadTitleService } from "../../threads/thread-title-service"; import type { ChatServerThreadActions } from "../app-server/actions/threads"; -import { createChatThreadGoalTransport } from "../app-server/goals/transport"; +import { createChatThreadGoalReadTransport, createChatThreadGoalTransport } from "../app-server/goals/transport"; +import { createChatThreadHistoryTransport, createChatThreadResumeTransport } from "../app-server/threads/loading-transport"; import { createChatThreadMutationTransport } from "../app-server/threads/transport"; import type { ChatResumeWorkTracker } from "../application/lifecycle"; import { messageStreamItems } from "../application/state/message-stream"; @@ -156,7 +157,7 @@ export function createThreadLifecycleBundle( ); const lifecycle = createSessionThreadLifecycle(host, { currentClient, - ensureConnected, + connectedClient, status, goals, autoTitleCoordinator: foundation.autoTitleCoordinator, @@ -283,7 +284,9 @@ function createSessionHistoryController( ): HistoryController { return new HistoryController({ stateStore: host.stateStore, - currentClient, + historyTransport: createChatThreadHistoryTransport({ + currentClient, + }), addSystemMessage: status.addSystemMessage, showLatestPageAtBottom: () => { host.messageScrollController.showLatest(); @@ -303,9 +306,8 @@ function createSessionGoalSyncActions( ): ChatPanelGoalSyncActions { return createThreadGoalSyncActions({ stateStore: host.stateStore, - goalTransport: createChatThreadGoalTransport({ + goalTransport: createChatThreadGoalReadTransport({ currentClient, - connectedClient: async () => currentClient(), }), localItemIds, addSystemMessage: (text) => { @@ -391,7 +393,7 @@ function createSessionThreadRenameEditorActions( stateStore, ensureConnected, addSystemMessage: status.addSystemMessage, - renameThread: (threadId, value, options) => operations.renameThread(threadId, value, options), + renameThread: (threadId, value) => operations.renameThread(threadId, value), generateThreadTitle: (threadId) => titleService.generateTitle(threadId), }); } @@ -400,7 +402,7 @@ function createSessionThreadLifecycle( host: ChatPanelThreadHost, input: { currentClient: CurrentAppServerClient; - ensureConnected: () => Promise; + connectedClient: () => Promise>; status: ChatPanelThreadStatus; goals: ChatPanelGoalActions; autoTitleCoordinator: AutoTitleCoordinator; @@ -413,7 +415,7 @@ function createSessionThreadLifecycle( ): ChatPanelThreadLifecycle { const { currentClient, - ensureConnected, + connectedClient, status, goals, autoTitleCoordinator, @@ -424,12 +426,12 @@ function createSessionThreadLifecycle( notifyActiveThreadIdentityChanged, } = input; return createThreadLifecycleParts({ - settingsRef: host.environment.plugin.settingsRef, stateStore: host.stateStore, - client: { + resumeTransport: createChatThreadResumeTransport({ + vaultPath: host.environment.plugin.settingsRef.vaultPath, currentClient, - ensureConnected, - }, + connectedClient, + }), lifecycle: { resumeWork: host.resumeWork, history, diff --git a/tests/features/chat/app-server/transports.test.ts b/tests/features/chat/app-server/transports.test.ts index 12a724c5..2260e140 100644 --- a/tests/features/chat/app-server/transports.test.ts +++ b/tests/features/chat/app-server/transports.test.ts @@ -4,9 +4,13 @@ import type { AppServerClient } from "../../../../src/app-server/connection/clie import type { ThreadRecord } from "../../../../src/app-server/protocol/thread"; import type { TurnItem, TurnRecord } from "../../../../src/app-server/protocol/turn"; import type { CodexInput } from "../../../../src/domain/chat/input"; -import { createChatThreadGoalTransport } from "../../../../src/features/chat/app-server/goals/transport"; +import { createChatThreadGoalReadTransport, createChatThreadGoalTransport } from "../../../../src/features/chat/app-server/goals/transport"; import { createThreadReferenceResolver } from "../../../../src/features/chat/app-server/references/thread-reference-resolver"; import { createChatRuntimeSettingsTransport } from "../../../../src/features/chat/app-server/runtime/thread-settings-transport"; +import { + createChatThreadHistoryTransport, + createChatThreadResumeTransport, +} from "../../../../src/features/chat/app-server/threads/loading-transport"; import { createChatThreadMutationTransport } from "../../../../src/features/chat/app-server/threads/transport"; import { createChatTurnTransport } from "../../../../src/features/chat/app-server/turns/transport"; import { deferred } from "../../../support/async"; @@ -121,6 +125,104 @@ describe("chat app-server transports", () => { expect(snapshot?.items).toEqual([expect.objectContaining({ kind: "message", role: "user", text: "prompt" })]); }); + it("reads thread history pages as message stream items", async () => { + const threadTurnsList = vi.fn().mockResolvedValue({ + data: [turn([userMessage("u1", "prompt"), agentMessage("a1", "answer")])], + nextCursor: "older", + }); + const client = { threadTurnsList } as unknown as AppServerClient; + const transport = createChatThreadHistoryTransport({ + currentClient: () => client, + }); + + const page = await transport.readHistoryPage("thread", "cursor", 20); + + expect(threadTurnsList).toHaveBeenCalledWith("thread", "cursor", 20); + expect(page?.nextCursor).toBe("older"); + expect(page?.hadTurns).toBe(true); + expect(page?.items).toEqual([ + expect.objectContaining({ kind: "message", role: "user", text: "prompt" }), + expect.objectContaining({ kind: "message", role: "assistant", text: "answer" }), + ]); + }); + + it("drops stale history transport responses after the current client changes", async () => { + const history = deferred<{ data: TurnRecord[]; nextCursor: string | null }>(); + const firstClient = { threadTurnsList: vi.fn().mockReturnValue(history.promise) } as unknown as AppServerClient; + const secondClient = {} as unknown as AppServerClient; + let currentClient = firstClient; + const transport = createChatThreadHistoryTransport({ + currentClient: () => currentClient, + }); + + const loading = transport.readHistoryPage("thread", "cursor", 20); + currentClient = secondClient; + history.resolve({ data: [turn([userMessage("u1", "prompt")])], nextCursor: "older" }); + + await expect(loading).resolves.toBeNull(); + }); + + it("resumes threads with the session vault path and projects initial history", async () => { + const resumeThread = vi.fn().mockResolvedValue({ + thread: { ...threadRecord("thread"), path: "/tmp/rollout.jsonl" }, + cwd: "/vault", + model: "gpt-test", + serviceTier: null, + approvalsReviewer: "user", + reasoningEffort: null, + initialTurnsPage: { + data: [turn([userMessage("u1", "prompt")])], + nextCursor: "older", + }, + }); + const client = { resumeThread } as unknown as AppServerClient; + const transport = createChatThreadResumeTransport({ + vaultPath: "/vault", + currentClient: () => client, + connectedClient: vi.fn().mockResolvedValue(client), + }); + + const snapshot = await transport.resumeThread("thread"); + + expect(resumeThread).toHaveBeenCalledWith("thread", "/vault"); + expect(snapshot?.activation.thread.id).toBe("thread"); + expect(snapshot?.activation.cwd).toBe("/vault"); + expect(snapshot?.rolloutPath).toBe("/tmp/rollout.jsonl"); + expect(snapshot?.initialHistoryPage).toMatchObject({ + nextCursor: "older", + hadTurns: true, + items: [expect.objectContaining({ kind: "message", role: "user", text: "prompt" })], + }); + }); + + it("drops stale resume transport responses after the current client changes", async () => { + const resume = deferred(); + const firstClient = { resumeThread: vi.fn().mockReturnValue(resume.promise) } as unknown as AppServerClient; + const secondClient = {} as unknown as AppServerClient; + let currentClient = firstClient; + const transport = createChatThreadResumeTransport({ + vaultPath: "/vault", + currentClient: () => currentClient, + connectedClient: vi.fn().mockResolvedValue(firstClient), + }); + + const resuming = transport.resumeThread("thread"); + currentClient = secondClient; + resume.resolve(threadResumeResponse("thread")); + + await expect(resuming).resolves.toBeNull(); + }); + + it("returns no resume snapshot when no connected client is available", async () => { + const transport = createChatThreadResumeTransport({ + vaultPath: "/vault", + currentClient: () => null, + connectedClient: vi.fn().mockResolvedValue(null), + }); + + await expect(transport.resumeThread("thread")).resolves.toBeNull(); + }); + it("distinguishes absent goals from unavailable goal clients", async () => { const client = { getThreadGoal: vi.fn().mockResolvedValue({ goal: null }) } as unknown as AppServerClient; const transport = createChatThreadGoalTransport({ @@ -131,9 +233,13 @@ describe("chat app-server transports", () => { currentClient: () => null, connectedClient: vi.fn().mockResolvedValue(null), }); + const readOnlyUnavailable = createChatThreadGoalReadTransport({ + currentClient: () => null, + }); await expect(transport.readThreadGoal("thread")).resolves.toBeNull(); await expect(unavailable.readThreadGoal("thread")).resolves.toBeUndefined(); + await expect(readOnlyUnavailable.readThreadGoal("thread")).resolves.toBeUndefined(); }); it("drops stale runtime settings updates after the current client changes", async () => { @@ -182,7 +288,9 @@ describe("chat app-server transports", () => { }); }); -function threadRecord(id: string, turns: readonly TurnRecord[] = []): ThreadRecord { +type AppServerThreadResumeResponse = Awaited>; + +function threadRecord(id: string, turns: readonly TurnRecord[] = [], overrides: Partial = {}): ThreadRecord { return { id, sessionId: id, @@ -204,6 +312,27 @@ function threadRecord(id: string, turns: readonly TurnRecord[] = []): ThreadReco gitInfo: null, name: null, turns, + ...overrides, + }; +} + +function threadResumeResponse(threadId: string, overrides: Partial = {}): AppServerThreadResumeResponse { + return { + thread: threadRecord(threadId) as AppServerThreadResumeResponse["thread"], + cwd: "/vault", + model: "gpt-test", + modelProvider: "openai", + serviceTier: null, + runtimeWorkspaceRoots: [], + instructionSources: [], + approvalPolicy: "never", + approvalsReviewer: "user", + sandbox: { type: "readOnly", networkAccess: false }, + activePermissionProfile: null, + reasoningEffort: null, + multiAgentMode: "none", + initialTurnsPage: null, + ...overrides, }; } diff --git a/tests/features/chat/application/threads/history-controller.test.ts b/tests/features/chat/application/threads/history-controller.test.ts index 163043e2..a860cbc9 100644 --- a/tests/features/chat/application/threads/history-controller.test.ts +++ b/tests/features/chat/application/threads/history-controller.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it, vi } from "vitest"; -import type { AppServerClient } from "../../../../../src/app-server/connection/client"; -import type { ChatThreadHistoryPage } from "../../../../../src/features/chat/app-server/threads/projection"; import { createChatStateStore } from "../../../../../src/features/chat/application/state/store"; -import { HistoryController, type HistoryControllerHost } from "../../../../../src/features/chat/application/threads/history-controller"; +import { HistoryController } from "../../../../../src/features/chat/application/threads/history-controller"; +import type { + ThreadHistoryPage, + ThreadHistoryTransport, +} from "../../../../../src/features/chat/application/threads/thread-loading-transport"; import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items"; import { deferred } from "../../../../support/async"; import { chatStateMessageStreamItems } from "../../support/message-stream"; @@ -10,8 +12,8 @@ import { chatStateFixture, chatStateWith } from "../../support/state"; describe("HistoryController", () => { it("keeps the latest history load when an older request resolves later", async () => { - const first = deferred(); - const second = deferred(); + const first = deferred(); + const second = deferred(); const { loader, stateStore } = historyFixture({ readHistoryPage: vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise), }); @@ -29,7 +31,7 @@ describe("HistoryController", () => { }); it("ignores a history load that is invalidated while pending", async () => { - const pending = deferred(); + const pending = deferred(); const { loader, stateStore, addSystemMessage } = historyFixture({ readHistoryPage: vi.fn().mockReturnValue(pending.promise), }); @@ -79,15 +81,28 @@ describe("HistoryController", () => { await loader.loadOlder(); - expect(readHistoryPage).toHaveBeenCalledWith(expect.anything(), "thread", "cursor", 20); + expect(readHistoryPage).toHaveBeenCalledWith("thread", "cursor", 20); expect(chatStateMessageStreamItems(stateStore.getState()).map((item) => item.id)).toEqual(["older", "current"]); expect(stateStore.getState().messageStream.historyCursor).toBe("next"); expect(showLatestPageAtBottom).not.toHaveBeenCalled(); expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ type: "message-stream/items-replaced" })); }); + + it("clears loading state when the history transport has no page", async () => { + const readHistoryPage = vi.fn().mockResolvedValue(null); + const { loader, stateStore, addSystemMessage, setThreadTurnPresence } = historyFixture({ readHistoryPage }); + + await loader.loadLatest(); + + expect(readHistoryPage).toHaveBeenCalledWith("thread", null, 20); + expect(chatStateMessageStreamItems(stateStore.getState())).toEqual([]); + expect(stateStore.getState().messageStream.loadingHistory).toBe(false); + expect(setThreadTurnPresence).not.toHaveBeenCalled(); + expect(addSystemMessage).not.toHaveBeenCalled(); + }); }); -type HistoryPageReader = NonNullable; +type HistoryPageReader = ThreadHistoryTransport["readHistoryPage"]; function historyFixture(options: { readHistoryPage: ReturnType> }) { let state = chatStateFixture(); @@ -96,18 +111,20 @@ function historyFixture(options: { readHistoryPage: ReturnType ({}) as AppServerClient, + historyTransport: { + readHistoryPage: options.readHistoryPage, + }, addSystemMessage, showLatestPageAtBottom, - setThreadTurnPresence: vi.fn(), - readHistoryPage: options.readHistoryPage, + setThreadTurnPresence, }); - return { loader, stateStore, addSystemMessage, dispatch, showLatestPageAtBottom }; + return { loader, stateStore, addSystemMessage, dispatch, setThreadTurnPresence, showLatestPageAtBottom }; } -function historyPage(items: MessageStreamItem[], nextCursor: string | null): ChatThreadHistoryPage { +function historyPage(items: MessageStreamItem[], nextCursor: string | null): ThreadHistoryPage { return { items, nextCursor, hadTurns: items.length > 0 }; } diff --git a/tests/features/chat/application/threads/resume-actions.test.ts b/tests/features/chat/application/threads/resume-actions.test.ts index 9bac0d31..84178d4f 100644 --- a/tests/features/chat/application/threads/resume-actions.test.ts +++ b/tests/features/chat/application/threads/resume-actions.test.ts @@ -1,18 +1,21 @@ import { describe, expect, it, vi } from "vitest"; -import type { AppServerClient } from "../../../../../src/app-server/connection/client"; import type { ThreadTokenUsage } from "../../../../../src/domain/runtime/metrics"; import type { Thread as PanelThread } from "../../../../../src/domain/threads/model"; -import type { ChatThreadHistoryPage, ChatThreadResumeSnapshot } from "../../../../../src/features/chat/app-server/threads/projection"; import { ChatResumeWorkTracker } from "../../../../../src/features/chat/application/lifecycle"; import { createChatState } from "../../../../../src/features/chat/application/state/root-reducer"; import { createChatStateStore } from "../../../../../src/features/chat/application/state/store"; import type { HistoryController } from "../../../../../src/features/chat/application/threads/history-controller"; import type { RestorationController } from "../../../../../src/features/chat/application/threads/restoration-controller"; import { createResumeActions, type ResumeActionsHost } from "../../../../../src/features/chat/application/threads/resume-actions"; +import type { + ThreadHistoryPage, + ThreadResumeSnapshot, + ThreadResumeTransport, +} from "../../../../../src/features/chat/application/threads/thread-loading-transport"; import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items"; -function activation(threadId: string, overrides: Partial = {}): ChatThreadResumeSnapshot { +function activation(threadId: string, overrides: Partial = {}): ThreadResumeSnapshot { return { activation: { thread: panelThread(threadId), @@ -28,22 +31,19 @@ function activation(threadId: string, overrides: Partial = {}) { +function createActions(response: ThreadResumeSnapshot | null = activation("thread"), overrides: Partial = {}) { const stateStore = createChatStateStore(createChatState()); - const resumeFromAppServer = vi.fn().mockResolvedValue(response); - const client = {} as AppServerClient; + const resumeThread = vi.fn().mockResolvedValue(response); const loadLatest = vi.fn().mockResolvedValue(undefined); const applyLatestPage = vi.fn(); const invalidateHistory = vi.fn(); const restoredClear = vi.fn(); const host = { stateStore, - vaultPath: "/vault", resumeWork: new ChatResumeWorkTracker(), history: { loadLatest, applyLatestPage, invalidate: invalidateHistory } as unknown as HistoryController, restoration: { clear: restoredClear } as unknown as RestorationController, - currentClient: () => client, - ensureConnected: vi.fn().mockResolvedValue(undefined), + resumeTransport: { resumeThread }, closing: () => false, systemItem: (text: string) => ({ id: "system", kind: "system" as const, role: "system" as const, text }), resetThreadTurnPresence: vi.fn(), @@ -51,7 +51,6 @@ function createActions(response: ChatThreadResumeSnapshot = activation("thread") addSystemMessage: vi.fn(), refreshLiveState: vi.fn(), syncThreadGoal: vi.fn().mockResolvedValue(undefined), - resumeFromAppServer, ...overrides, }; return { @@ -61,7 +60,7 @@ function createActions(response: ChatThreadResumeSnapshot = activation("thread") invalidateHistory, loadLatest, restoredClear, - resumeThread: resumeFromAppServer, + resumeThread, stateStore, }; } @@ -72,7 +71,7 @@ describe("ResumeActions", () => { await actions.resumeThread("thread"); - expect(resumeThread).toHaveBeenCalledWith(expect.anything(), "thread", "/vault"); + expect(resumeThread).toHaveBeenCalledWith("thread"); expect(host.syncThreadGoal).toHaveBeenCalledWith("thread"); expect(stateStore.getState().activeThread.id).toBe("thread"); expect(loadLatest).toHaveBeenCalledWith("thread"); @@ -92,6 +91,18 @@ describe("ResumeActions", () => { expect(loadLatest).not.toHaveBeenCalled(); }); + it("does not change active thread when the resume transport has no snapshot", async () => { + const { actions, host, loadLatest, restoredClear, resumeThread, stateStore } = createActions(null); + + await actions.resumeThread("thread"); + + expect(resumeThread).toHaveBeenCalledWith("thread"); + expect(stateStore.getState().activeThread.id).toBeNull(); + expect(loadLatest).not.toHaveBeenCalled(); + expect(restoredClear).not.toHaveBeenCalled(); + expect(host.syncThreadGoal).not.toHaveBeenCalled(); + }); + it("refreshes live state after resumed history and goal sync finish", async () => { const { actions, host } = createActions(); @@ -200,7 +211,7 @@ function panelThread(id: string): PanelThread { }; } -function historyPage(items: MessageStreamItem[], nextCursor: string | null): ChatThreadHistoryPage { +function historyPage(items: MessageStreamItem[], nextCursor: string | null): ThreadHistoryPage { return { items, nextCursor, hadTurns: items.length > 0 }; } diff --git a/tests/scripts/grit-policy.test.mjs b/tests/scripts/grit-policy.test.mjs index 7c82fbeb..7da11de7 100644 --- a/tests/scripts/grit-policy.test.mjs +++ b/tests/scripts/grit-policy.test.mjs @@ -16,10 +16,8 @@ const projectPluginByName = new Map( ); const APP_SERVER_PROTOCOL_BOUNDARY_MESSAGE = "Source modules outside root src/app-server must use domain models and app-server services instead of app-server protocol modules. Chat turn-item conversion may consume turn protocol at its app-server boundary; feature state and UI must use Panel-owned models."; -const CHAT_APPLICATION_ROOT_APP_SERVER_MESSAGE = - "Chat application modules must not import root app-server modules; use chat app-server transports or application ports instead."; const CHAT_APPLICATION_OUTER_LAYER_MESSAGE = - "Chat application modules must not import host, panel, presentation, or UI layers; expose state and workflow contracts instead."; + "Chat application modules must not import app-server, host, panel, presentation, or UI layers; expose state and workflow contracts instead."; const CHAT_APP_SERVER_OUTER_LAYER_MESSAGE = "Chat app-server adapters must not import chat host, panel, presentation, or UI layers."; const CHAT_PANEL_RUNTIME_BOUNDARY_MESSAGE = "Chat panel modules must not import app-server adapters or chat host internals."; const CHAT_PRESENTATION_OUTER_LAYER_MESSAGE = @@ -394,7 +392,6 @@ export function timestamp(): number { it("keeps chat folder ownership boundaries explicit without filename-scoped Grit checks", async () => { const cwd = await tempBiomeWorkspace([ "no-chat-application-outer-layer-imports.grit", - "no-chat-application-root-app-server-imports.grit", "no-chat-app-server-outer-layer-imports.grit", "no-chat-panel-runtime-boundary-imports.grit", "no-chat-presentation-outer-layer-imports.grit", @@ -404,11 +401,12 @@ export function timestamp(): number { path.join(cwd, "src/features/chat/application/outer.ts"), ` import type { Host } from "../host/contracts"; +import type { ChatThreadHistoryPage } from "../app-server/threads/projection"; import type { PanelSnapshot } from "../panel/snapshot"; import { statusText } from "../presentation/runtime/status"; import { Toolbar } from "../ui/toolbar"; -export type Escape = Host | PanelSnapshot; +export type Escape = ChatThreadHistoryPage | Host | PanelSnapshot; export const values = [statusText, Toolbar] satisfies unknown[]; `.trimStart(), ); @@ -416,9 +414,8 @@ export const values = [statusText, Toolbar] satisfies unknown[]; path.join(cwd, "src/features/chat/application/allowed.ts"), ` import type { MessageStreamItem } from "../domain/message-stream/items"; -import type { ChatThreadHistoryPage } from "../app-server/threads/projection"; -export type Item = MessageStreamItem | ChatThreadHistoryPage; +export type Item = MessageStreamItem; `.trimStart(), ); await writeFile( @@ -536,10 +533,10 @@ export const value = statusText; ); expect(pluginMessages(report, "src/features/chat/application/outer.ts")).toEqual( - Array.from({ length: 4 }, () => CHAT_APPLICATION_OUTER_LAYER_MESSAGE), + Array.from({ length: 5 }, () => CHAT_APPLICATION_OUTER_LAYER_MESSAGE), ); expect(pluginDiagnostics(report, "src/features/chat/application/allowed.ts")).toEqual([]); - expect(pluginMessages(report, "src/features/chat/application/root-app-server.ts")).toEqual([CHAT_APPLICATION_ROOT_APP_SERVER_MESSAGE]); + expect(pluginMessages(report, "src/features/chat/application/root-app-server.ts")).toEqual([CHAT_APPLICATION_OUTER_LAYER_MESSAGE]); expect(pluginMessages(report, "src/features/chat/app-server/outer.ts")).toEqual( Array.from({ length: 4 }, () => CHAT_APP_SERVER_OUTER_LAYER_MESSAGE), ); diff --git a/tests/support/async.ts b/tests/support/async.ts index 743a8ee2..97ba21b2 100644 --- a/tests/support/async.ts +++ b/tests/support/async.ts @@ -15,17 +15,13 @@ export function deferred(): Deferred { } export async function waitForAsyncWork(assertion: () => void): Promise { - let lastError: unknown; for (let attempt = 0; attempt < 20; attempt += 1) { try { assertion(); return; - } catch (error) { - lastError = error; + } catch { await Promise.resolve(); } } assertion(); - if (lastError instanceof Error) throw lastError; - throw new Error("Timed out waiting for async test work."); }