Move chat thread loading app-server access out of application

This commit is contained in:
murashit 2026-06-27 18:16:01 +09:00
parent 2f187d7e94
commit e515eb904e
23 changed files with 339 additions and 180 deletions

View file

@ -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/**"]

View file

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

View file

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

View file

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

View file

@ -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<ThreadGoalReader["readThreadGoal"]> {
const client = host.currentClient();
if (!client) return undefined;
const goal = await readThreadGoal(client, threadId);
return chatAppServerClientIsStale(host, client) ? undefined : goal;
}

View file

@ -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<ThreadHistoryPage | null> =>
withCurrentChatAppServerClient(host, (client) => readChatThreadHistoryPage(client, threadId, cursor, limit)),
};
}
export function createChatThreadResumeTransport(host: ChatThreadResumeTransportHost): ThreadResumeTransport {
return {
resumeThread: (threadId): Promise<ThreadResumeSnapshot | null> =>
withConnectedChatAppServerClient(host, (client) => resumeChatThread(client, threadId, host.vaultPath)),
};
}

View file

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

View file

@ -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;
},
};
}

View file

@ -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<string | null>;
renameGeneratedTitle(threadId: string, title: string, options: { shouldPublish: () => boolean }): Promise<boolean>;
}

View file

@ -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>;
}

View file

@ -1,7 +1,10 @@
import type { ThreadGoal, ThreadGoalUpdate } from "../../../../domain/threads/goal";
export interface ThreadGoalTransport {
export interface ThreadGoalReader {
readThreadGoal(threadId: string): Promise<ThreadGoal | null | undefined>;
}
export interface ThreadGoalTransport extends ThreadGoalReader {
setThreadGoal(threadId: string, params: ThreadGoalUpdate): Promise<ThreadGoal | null | undefined>;
clearThreadGoal(threadId: string): Promise<boolean>;
recordThreadGoalUserMessage(threadId: string, objective: string): Promise<boolean>;

View file

@ -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<ChatThreadHistoryPage>;
}
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<void> {
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<void> {
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<ChatThreadHistoryPage> {
return (this.host.readHistoryPage ?? readChatThreadHistoryPage)(client, threadId, cursor, limit);
}
}
function transitionThreadHistoryLoadLifecycle(

View file

@ -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<void>;
};
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,

View file

@ -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<void>;
addSystemMessage: (text: string) => void;
renameThread: ThreadOperations["renameThread"];
generateThreadTitle: ThreadTitleService["generateTitle"];
renameThread(threadId: string, value: string): Promise<boolean>;
generateThreadTitle(threadId: string): Promise<string>;
}
export interface ThreadRenameEditorActions {

View file

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

View file

@ -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<ThreadHistoryPage | null>;
}
export interface ThreadResumeSnapshot {
activation: ThreadActivationSnapshot;
rolloutPath: string | null;
initialHistoryPage: ThreadHistoryPage | null;
}
export interface ThreadResumeTransport {
resumeThread(threadId: string): Promise<ThreadResumeSnapshot | null>;
}

View file

@ -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<boolean>;
archiveThread(threadId: string, options?: { saveMarkdown?: boolean; closeOpenPanels?: boolean }): Promise<unknown>;
}
export interface ThreadManagementActions {
compactActiveThread: () => Promise<void>;
compactThread: (threadId: string) => Promise<void>;

View file

@ -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<void>;
connectedClient: () => Promise<ReturnType<CurrentAppServerClient>>;
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,

View file

@ -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<AppServerThreadResumeResponse>();
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<ReturnType<AppServerClient["resumeThread"]>>;
function threadRecord(id: string, turns: readonly TurnRecord[] = [], overrides: Partial<ThreadRecord> = {}): 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> = {}): 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,
};
}

View file

@ -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<ChatThreadHistoryPage>();
const second = deferred<ChatThreadHistoryPage>();
const first = deferred<ThreadHistoryPage | null>();
const second = deferred<ThreadHistoryPage | null>();
const { loader, stateStore } = historyFixture({
readHistoryPage: vi.fn<HistoryPageReader>().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<ChatThreadHistoryPage>();
const pending = deferred<ThreadHistoryPage | null>();
const { loader, stateStore, addSystemMessage } = historyFixture({
readHistoryPage: vi.fn<HistoryPageReader>().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<HistoryPageReader>().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<HistoryControllerHost["readHistoryPage"]>;
type HistoryPageReader = ThreadHistoryTransport["readHistoryPage"];
function historyFixture(options: { readHistoryPage: ReturnType<typeof vi.fn<HistoryPageReader>> }) {
let state = chatStateFixture();
@ -96,18 +111,20 @@ function historyFixture(options: { readHistoryPage: ReturnType<typeof vi.fn<Hist
const dispatch = vi.spyOn(stateStore, "dispatch");
const addSystemMessage = vi.fn();
const showLatestPageAtBottom = vi.fn();
const setThreadTurnPresence = vi.fn();
const loader = new HistoryController({
stateStore,
currentClient: () => ({}) 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 };
}

View file

@ -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> = {}): ChatThreadResumeSnapshot {
function activation(threadId: string, overrides: Partial<ThreadResumeSnapshot> = {}): ThreadResumeSnapshot {
return {
activation: {
thread: panelThread(threadId),
@ -28,22 +31,19 @@ function activation(threadId: string, overrides: Partial<ChatThreadResumeSnapsho
};
}
function createActions(response: ChatThreadResumeSnapshot = activation("thread"), overrides: Partial<ResumeActionsHost> = {}) {
function createActions(response: ThreadResumeSnapshot | null = activation("thread"), overrides: Partial<ResumeActionsHost> = {}) {
const stateStore = createChatStateStore(createChatState());
const resumeFromAppServer = vi.fn().mockResolvedValue(response);
const client = {} as AppServerClient;
const resumeThread = vi.fn<ThreadResumeTransport["resumeThread"]>().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 };
}

View file

@ -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),
);

View file

@ -15,17 +15,13 @@ export function deferred<T>(): Deferred<T> {
}
export async function waitForAsyncWork(assertion: () => void): Promise<void> {
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.");
}