mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Move chat thread loading app-server access out of application
This commit is contained in:
parent
2f187d7e94
commit
e515eb904e
23 changed files with 339 additions and 180 deletions
|
|
@ -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/**"]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
27
src/features/chat/app-server/threads/loading-transport.ts
Normal file
27
src/features/chat/app-server/threads/loading-transport.ts
Normal 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)),
|
||||
};
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue