Tighten app-server helper and notification boundaries

This commit is contained in:
murashit 2026-06-26 19:50:05 +09:00
parent 6a85d4f6c6
commit 697f7a59d2
23 changed files with 534 additions and 216 deletions

View file

@ -379,8 +379,8 @@ export class AppServerClient {
return this.request("thread/archive", { threadId });
}
deleteThread(threadId: string): Promise<ThreadDeleteResponse> {
return this.request("thread/delete", { threadId });
deleteThread(threadId: string, options: { timeoutMs?: number } = {}): Promise<ThreadDeleteResponse> {
return this.request("thread/delete", { threadId }, options);
}
readThread(threadId: string, includeTurns = true): Promise<ThreadReadResponse> {

View file

@ -16,6 +16,8 @@ type StructuredTurnRuntimeOverride = NonNullable<AppServerStartStructuredTurnOpt
type StructuredTurnProgressEvent = { type: "agent-message-delta"; delta: string } | { type: "reasoning-activity" };
const EPHEMERAL_THREAD_CLEANUP_TIMEOUT_MS = 5_000;
interface EphemeralStructuredTurnTimers {
setTimeout(callback: () => void, delayMs: number): ReturnType<Window["setTimeout"]>;
clearTimeout(timer: ReturnType<Window["setTimeout"]>): void;
@ -33,6 +35,7 @@ export interface EphemeralStructuredTurnClient {
disconnect(): void;
startEphemeralThread(options: AppServerStartEphemeralThreadOptions): Promise<{ thread: { id: string } }>;
startStructuredTurn(options: AppServerStartStructuredTurnOptions): Promise<{ turn: TurnRecord }>;
deleteThread(threadId: string, options?: { timeoutMs?: number }): Promise<unknown>;
}
type EphemeralStructuredTurnRuntimeCapableClient = EphemeralStructuredTurnClient & ModelMetadataClient;
@ -98,6 +101,7 @@ export async function runEphemeralStructuredTurn(options: RunEphemeralStructured
});
const clientFactory = options.clientFactory ?? ((codexPath, cwd, handlers) => new AppServerClient(codexPath, cwd, handlers));
let threadId: string | null = null;
const client = clientFactory(options.codexPath, options.cwd, {
onNotification: (notification) => {
handleNotification(notification);
@ -124,16 +128,17 @@ export async function runEphemeralStructuredTurn(options: RunEphemeralStructured
developerInstructions: options.developerInstructions,
}),
);
threadId = threadResponse.thread.id;
state = {
...state,
lifecycle: transitionEphemeralStructuredTurnLifecycle(state.lifecycle, {
type: "thread-started",
threadId: threadResponse.thread.id,
threadId,
}),
};
const turnResponse = await abortableOperation(
client.startStructuredTurn({
threadId: threadResponse.thread.id,
threadId,
cwd: options.cwd,
text: options.prompt,
outputSchema: options.outputSchema,
@ -144,7 +149,7 @@ export async function runEphemeralStructuredTurn(options: RunEphemeralStructured
...state,
lifecycle: transitionEphemeralStructuredTurnLifecycle(state.lifecycle, {
type: "turn-started",
threadId: threadResponse.thread.id,
threadId,
turnId: turnResponse.turn.id,
}),
};
@ -154,6 +159,7 @@ export async function runEphemeralStructuredTurn(options: RunEphemeralStructured
} finally {
state = completeEphemeralStructuredTurnState(state);
timers.clearTimeout(timeout);
await deleteEphemeralStructuredTurnThread(client, threadId);
client.disconnect();
}
}
@ -256,6 +262,15 @@ function turnWithCollectedItems(turn: TurnRecord, completedItems: readonly TurnI
return { ...turn, items: [...completedItems], itemsView: "full" };
}
async function deleteEphemeralStructuredTurnThread(client: EphemeralStructuredTurnClient, threadId: string | null): Promise<void> {
if (!threadId) return;
try {
await client.deleteThread(threadId, { timeoutMs: EPHEMERAL_THREAD_CLEANUP_TIMEOUT_MS });
} catch {
// Ephemeral helpers must not fail visible workflows because cleanup raced app-server shutdown.
}
}
function throwIfAborted(signal: AbortSignal | undefined, message: string | undefined): void {
throwIfSignalAborted(signal, () => ephemeralStructuredTurnAbortError(message));
}

View file

@ -62,6 +62,7 @@ function cannotRejectServerRequestMessage(): string {
export interface ChatInboundHandlerActions {
refreshActiveThreads: () => void;
refreshServerDiagnostics: (options?: { forceResourceProbes?: boolean }) => void;
applyAppServerResourceEvent: (event: AppServerResourceEvent) => void;
maybeNameThread: (threadId: string, turnId: string, completedSummary: ThreadConversationSummary | null) => void;
applyThreadCatalogEvent: (event: ThreadCatalogEvent) => void;
@ -314,6 +315,9 @@ function runNotificationEffect(context: ChatInboundHandlerContext, effect: ChatN
case "refresh-threads":
context.actions.refreshActiveThreads();
return;
case "refresh-server-diagnostics":
context.actions.refreshServerDiagnostics({ forceResourceProbes: effect.forceResourceProbes === true });
return;
case "apply-app-server-resource-event":
context.actions.applyAppServerResourceEvent(effect.event);
return;

View file

@ -51,6 +51,7 @@ import {
export type ChatNotificationEffect =
| { type: "refresh-threads" }
| { type: "refresh-server-diagnostics"; forceResourceProbes?: boolean }
| { type: "apply-app-server-resource-event"; event: AppServerResourceEvent }
| { type: "maybe-name-thread"; threadId: string; turnId: string; completedSummary: ThreadConversationSummary | null }
| { type: "apply-thread-catalog-event"; event: ThreadCatalogEvent };
@ -99,6 +100,14 @@ const DIAGNOSTIC_STATUS_PLANNERS = {
actions: [],
effects: [{ type: "apply-app-server-resource-event", event: { type: "skills-changed", forceReload: true } }],
}),
"app/list/updated": () => ({
actions: [],
effects: [{ type: "refresh-server-diagnostics" }],
}),
"mcpServer/oauthLogin/completed": () => ({
actions: [],
effects: [{ type: "refresh-server-diagnostics", forceResourceProbes: true }],
}),
"mcpServer/startupStatus/updated": (notification) => ({
actions: [],
effects: [
@ -122,6 +131,9 @@ const USER_VISIBLE_NOTICE_PLANNERS = {
error: jsonNoticePlan,
warning: jsonNoticePlan,
configWarning: jsonNoticePlan,
"windows/worldWritableWarning": jsonNoticePlan,
"windowsSandbox/setupCompleted": (notification, localItemId) =>
notification.params.success ? EMPTY_PLAN : jsonNoticePlan(notification, localItemId),
} satisfies ServerNotificationLocalPlannerMap<UserVisibleNoticeNotificationMethod>;
const STREAM_UPDATE_PLANNERS = {
@ -312,7 +324,7 @@ export const PLANNED_SERVER_NOTIFICATION_METHODS_BY_ROUTE_KIND = {
diagnosticStatus: plannerMethods(DIAGNOSTIC_STATUS_PLANNERS),
userVisibleNotice: plannerMethods(USER_VISIBLE_NOTICE_PLANNERS),
} satisfies Record<
Exclude<ReturnType<typeof routeServerNotification>["kind"], "inactive" | "unhandled">,
Exclude<ReturnType<typeof routeServerNotification>["kind"], "inactive" | "unhandled" | "ignored">,
readonly ServerNotification["method"][]
>;
@ -327,6 +339,7 @@ export function planChatNotification(
});
switch (route.kind) {
case "inactive":
case "ignored":
case "unhandled":
return EMPTY_PLAN;
case "streamUpdate":

View file

@ -23,6 +23,7 @@ export type ServerNotificationRoute =
| { kind: "requestResolved"; notification: RequestResolvedNotification }
| { kind: "diagnosticStatus"; notification: DiagnosticStatusNotification }
| { kind: "userVisibleNotice"; notification: UserVisibleNoticeNotification }
| { kind: "ignored"; notification: ServerNotification }
| { kind: "unhandled"; notification: ServerNotification }
| { kind: "inactive"; notification: ServerNotification };
@ -82,6 +83,8 @@ const DIAGNOSTIC_STATUS_NOTIFICATION_METHODS = [
"thread/tokenUsage/updated",
"account/rateLimits/updated",
"skills/changed",
"app/list/updated",
"mcpServer/oauthLogin/completed",
"mcpServer/startupStatus/updated",
] as const;
@ -94,10 +97,43 @@ const USER_VISIBLE_NOTICE_NOTIFICATION_METHODS = [
"error",
"warning",
"configWarning",
"windows/worldWritableWarning",
"windowsSandbox/setupCompleted",
] as const;
export type UserVisibleNoticeNotificationMethod = (typeof USER_VISIBLE_NOTICE_NOTIFICATION_METHODS)[number];
export const IGNORED_SERVER_NOTIFICATION_METHODS = [
"thread/status/changed",
"thread/closed",
"rawResponseItem/completed",
"command/exec/outputDelta",
"process/outputDelta",
"process/exited",
"item/commandExecution/terminalInteraction",
"account/updated",
"remoteControl/status/changed",
"externalAgentConfig/import/progress",
"externalAgentConfig/import/completed",
"fs/changed",
"model/verification",
"turn/moderationMetadata",
"model/safetyBuffering/updated",
"fuzzyFileSearch/sessionUpdated",
"fuzzyFileSearch/sessionCompleted",
"thread/realtime/started",
"thread/realtime/itemAdded",
"thread/realtime/transcript/delta",
"thread/realtime/transcript/done",
"thread/realtime/outputAudio/delta",
"thread/realtime/sdp",
"thread/realtime/error",
"thread/realtime/closed",
"account/login/completed",
] as const satisfies readonly ServerNotificationMethod[];
type IgnoredServerNotificationMethod = (typeof IGNORED_SERVER_NOTIFICATION_METHODS)[number];
export const ROUTED_SERVER_NOTIFICATION_METHODS_BY_ROUTE_KIND = {
streamUpdate: STREAM_UPDATE_NOTIFICATION_METHODS,
turnLifecycle: TURN_LIFECYCLE_NOTIFICATION_METHODS,
@ -105,7 +141,7 @@ export const ROUTED_SERVER_NOTIFICATION_METHODS_BY_ROUTE_KIND = {
requestResolved: ["serverRequest/resolved"],
diagnosticStatus: DIAGNOSTIC_STATUS_NOTIFICATION_METHODS,
userVisibleNotice: USER_VISIBLE_NOTICE_NOTIFICATION_METHODS,
} satisfies Record<Exclude<ServerNotificationRoute["kind"], "inactive" | "unhandled">, readonly ServerNotificationMethod[]>;
} satisfies Record<Exclude<ServerNotificationRoute["kind"], "inactive" | "unhandled" | "ignored">, readonly ServerNotificationMethod[]>;
const SERVER_NOTIFICATION_SCOPE_EXTRACTORS: ServerNotificationScopeExtractors = {
error: threadTurnNotificationScope,
@ -191,6 +227,7 @@ export function routeServerNotification(notification: ServerNotification, scope:
if (notification.method === "serverRequest/resolved") return { kind: "requestResolved", notification };
if (isDiagnosticStatusNotification(notification)) return { kind: "diagnosticStatus", notification };
if (isUserVisibleNoticeNotification(notification)) return { kind: "userVisibleNotice", notification };
if (isIgnoredServerNotification(notification)) return { kind: "ignored", notification };
return { kind: "unhandled", notification };
}
@ -252,6 +289,12 @@ function isUserVisibleNoticeNotification(notification: ServerNotification): noti
return notificationMethodIn(notification.method, USER_VISIBLE_NOTICE_NOTIFICATION_METHODS);
}
function isIgnoredServerNotification(
notification: ServerNotification,
): notification is RoutedNotification<IgnoredServerNotificationMethod> {
return notificationMethodIn(notification.method, IGNORED_SERVER_NOTIFICATION_METHODS);
}
function notificationMethodIn<M extends ServerNotificationMethod>(method: ServerNotificationMethod, methods: readonly M[]): method is M {
return (methods as readonly ServerNotificationMethod[]).includes(method);
}

View file

@ -20,7 +20,7 @@ import type { ChatConnectionPhase } from "../application/state/root-reducer";
import type { ChatStateStore } from "../application/state/store";
import type { AutoTitleCoordinator } from "../application/threads/auto-title-coordinator";
import type { createThreadGoalSyncActions } from "../application/threads/goal-actions";
import type { ChatPanelEnvironment } from "./runtime";
import type { ChatPanelEnvironment } from "./environment";
export type CurrentAppServerClient = () => AppServerClient | null;
@ -141,6 +141,11 @@ export function createConnectionBundle(
refreshActiveThreads: () => {
refreshSharedThreadsQuietly();
},
refreshServerDiagnostics: (options) => {
void serverDiagnostics.refreshServerDiagnostics(options).catch((error: unknown) => {
status.addSystemMessage(error instanceof Error ? error.message : String(error));
});
},
applyAppServerResourceEvent: (event) => {
void serverMetadata.applyAppServerResourceEvent(event).catch((error: unknown) => {
status.addSystemMessage(error instanceof Error ? error.message : String(error));

View file

@ -0,0 +1,139 @@
import type { ConnectionManager } from "../../../app-server/connection/connection-manager";
import type { ConversationTurnActions } from "../application/conversation/composition";
import type { PendingRequestActions } from "../application/pending-requests/pending-request-actions";
import type { ChatStateStore } from "../application/state/store";
import type { createGoalActions } from "../application/threads/goal-actions";
import type { HistoryController } from "../application/threads/history-controller";
import type { ThreadRenameEditorActions } from "../application/threads/rename-editor-actions";
import type { createSelectionActions } from "../application/threads/selection-actions";
import type { createThreadManagementActions } from "../application/threads/thread-management-actions";
import { type ChatPanelGoalSurface, createChatPanelGoalSurface } from "../panel/surface/goal-projection";
import { MessageStreamPresenter } from "../panel/surface/message-stream-presenter";
import type { ChatMessageScrollController } from "../panel/surface/message-stream-scroll";
import type { ChatPanelToolbarSurface } from "../panel/surface/toolbar-projection";
import { createChatPanelToolbarActions, type ToolbarPanelActions } from "../panel/toolbar-actions";
import type { ToolbarActions } from "../ui/toolbar";
import type { ChatPanelConnectionBundle } from "./connection-bundle";
import type { ChatPanelEnvironment } from "./environment";
type ChatPanelGoalActions = ReturnType<typeof createGoalActions>;
type ChatPanelThreadActions = ReturnType<typeof createThreadManagementActions>;
type ChatPanelSelectionActions = ReturnType<typeof createSelectionActions>;
type ChatPanelConversationTurnActions = ConversationTurnActions;
export interface ChatPanelSurfacesHost {
environment: ChatPanelEnvironment;
stateStore: ChatStateStore;
messageScrollController: ChatMessageScrollController;
}
export interface ChatPanelSurfacesInput {
connection: ConnectionManager;
connectionController: ChatPanelConnectionBundle["connection"]["controller"];
goals: ChatPanelGoalActions;
rename: ThreadRenameEditorActions;
threadActions: ChatPanelThreadActions;
toolbarPanels: ToolbarPanelActions;
selection: ChatPanelSelectionActions;
reconnect: () => Promise<void>;
history: HistoryController;
pendingRequests: PendingRequestActions;
turnActions: ChatPanelConversationTurnActions;
startNewThread: () => Promise<void>;
}
export interface ChatPanelSurfaces {
toolbarActions: ToolbarActions;
toolbarSurface: ChatPanelToolbarSurface;
goalSurface: ChatPanelGoalSurface;
messageStreamPresenter: MessageStreamPresenter;
}
export function createChatPanelSurfaces(host: ChatPanelSurfacesHost, input: ChatPanelSurfacesInput): ChatPanelSurfaces {
const {
connection,
connectionController,
goals,
rename,
threadActions,
toolbarPanels,
selection,
reconnect,
history,
pendingRequests,
turnActions,
startNewThread,
} = input;
const { environment, stateStore } = host;
const toolbarActions = createChatPanelToolbarActions(
{
startNewThread,
},
{
connectionController,
reconnectPanel: reconnect,
threadActions,
goals,
toolbarPanels,
rename,
selection,
},
);
const toolbarSurface: ChatPanelToolbarSurface = {
state: {
connected: () => connection.isConnected(),
nowMs: () => Date.now(),
},
settings: {
vaultPath: () => environment.plugin.settingsRef.vaultPath,
configuredCommand: () => environment.plugin.settingsRef.settings.codexPath,
archiveExportEnabled: () => environment.plugin.settingsRef.settings.archiveExportEnabled,
},
};
const goalSurface = createChatPanelGoalSurface(
{
sendShortcut: () => environment.plugin.settingsRef.settings.sendShortcut,
},
{
goals,
},
);
const messageStreamPresenter = new MessageStreamPresenter({
obsidian: {
app: environment.obsidian.app,
owner: environment.obsidian.owner,
},
state: {
store: stateStore,
},
workspace: {
vaultPath: environment.plugin.settingsRef.vaultPath,
},
scroll: {
controller: host.messageScrollController,
dispose: () => {
host.messageScrollController.dispose();
},
},
history: {
loadOlderTurns: () => void history.loadOlder(),
},
actions: {
rollbackThread: (threadId) => void threadActions.rollbackThread(threadId),
forkThreadFromTurn: (threadId, turnId, archiveSource) => void threadActions.forkThreadFromTurn(threadId, turnId, archiveSource),
implementPlan: (itemId) => void turnActions.planImplementation.implement(itemId),
openTurnDiff: (state) => void environment.plugin.workspace.openTurnDiff(state),
},
requests: {
pendingActions: () => pendingRequests.actions(),
consumePendingAutoFocus: () => pendingRequests.consumeAutoFocus(),
},
});
return {
toolbarActions,
toolbarSurface,
goalSurface,
messageStreamPresenter,
};
}

View file

@ -2,11 +2,7 @@ import { Notice } from "obsidian";
import type { AppServerClientAccess } from "../../../app-server/connection/client-access";
import { ConnectionManager } from "../../../app-server/connection/connection-manager";
import { isStaleAppServerSharedQueryContextError } from "../../../app-server/query/shared-queries";
import type { ModelMetadata } from "../../../domain/catalog/metadata";
import type { ObservedDataResult } from "../../../domain/observed-data";
import { observedData } from "../../../domain/observed-data";
import type { SharedServerMetadata } from "../../../domain/server/metadata";
import { normalizeExplicitThreadName, type Thread } from "../../../domain/threads/model";
import { normalizeExplicitThreadName } from "../../../domain/threads/model";
import { createLocalIdSource, type LocalIdSource } from "../../../shared/id/local-id";
import type { ConnectionWorkTracker } from "../../../shared/lifecycle/connection-work";
import { createThreadOperations, type ThreadOperations } from "../../threads/thread-operations";
@ -53,11 +49,11 @@ import { collaborationModeLabel as formatCollaborationModeLabel } from "../domai
import type { RuntimeSnapshot } from "../domain/runtime/snapshot";
import { ChatComposerController } from "../panel/composer-controller";
import { type ChatPanelComposerSurface, chatPanelComposerProjection } from "../panel/surface/composer-projection";
import { type ChatPanelGoalSurface, createChatPanelGoalSurface } from "../panel/surface/goal-projection";
import { MessageStreamPresenter } from "../panel/surface/message-stream-presenter";
import type { ChatPanelGoalSurface } from "../panel/surface/goal-projection";
import type { MessageStreamPresenter } from "../panel/surface/message-stream-presenter";
import type { ChatMessageScrollController } from "../panel/surface/message-stream-scroll";
import type { ChatPanelToolbarSurface } from "../panel/surface/toolbar-projection";
import { createChatPanelToolbarActions, createToolbarPanelActions, type ToolbarPanelActions } from "../panel/toolbar-actions";
import { createToolbarPanelActions, type ToolbarPanelActions } from "../panel/toolbar-actions";
import { VaultNoteCandidateProvider } from "../panel/vault-note-candidate-provider";
import {
effortStatusLines as buildEffortStatusLines,
@ -66,7 +62,9 @@ import {
} from "../presentation/runtime/status";
import type { ToolbarActions } from "../ui/toolbar";
import { type ChatPanelConnectionBundle, type CurrentAppServerClient, createConnectionBundle } from "./connection-bundle";
import type { ChatPanelEnvironment } from "./runtime";
import type { ChatPanelEnvironment } from "./environment";
import { createChatPanelSurfaces } from "./panel-surfaces";
import { type ChatPanelSharedStateBinding, createChatPanelSharedStateBinding } from "./shared-state-binding";
export interface ChatPanelSessionGraph {
connection: {
@ -114,12 +112,6 @@ export interface ChatPanelSessionGraph {
};
}
interface ChatPanelSharedStateBinding {
applyCached(): void;
subscribe(): void;
unsubscribe(): void;
}
interface ChatPanelSessionStatus {
set: (statusText: string, phase?: ChatConnectionPhase) => void;
addSystemMessage: (text: string) => void;
@ -185,28 +177,6 @@ interface ChatPanelComposerAndTurnInput {
runtimeProjection: ChatPanelRuntimeProjection;
}
interface ChatPanelSurfacePresenterInput {
connection: ConnectionManager;
connectionController: ChatPanelConnectionBundle["connection"]["controller"];
goals: ChatPanelGoalActions;
rename: ThreadRenameEditorActions;
threadActions: ChatPanelThreadActions;
toolbarPanels: ToolbarPanelActions;
selection: ChatPanelSelectionActions;
reconnect: () => Promise<void>;
history: HistoryController;
pendingRequests: PendingRequestActions;
turnActions: ChatPanelConversationTurnActions;
startNewThread: () => Promise<void>;
}
interface ChatPanelSurfacePresenterParts {
toolbarActions: ToolbarActions;
toolbarSurface: ChatPanelToolbarSurface;
goalSurface: ChatPanelGoalSurface;
messageStreamPresenter: MessageStreamPresenter;
}
export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): ChatPanelSessionGraph {
const { environment, stateStore } = host;
const localItemIds = createLocalIdSource();
@ -314,7 +284,7 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch
startNewThread,
runtimeProjection: createSessionRuntimeProjection(host, connection),
});
const surfaceParts = createSurfacesAndPresenter(host, {
const surfaces = createChatPanelSurfaces(host, {
connection,
connectionController,
goals,
@ -337,10 +307,18 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch
}
};
const dispose = (): void => {
surfaceParts.messageStreamPresenter.dispose();
surfaces.messageStreamPresenter.dispose();
composerController.dispose();
};
const sharedState = createChatPanelSharedStateBinding(host, connectionBundle.serverActions);
const sharedState = createChatPanelSharedStateBinding({
stateStore: host.stateStore,
threadCatalog: host.environment.plugin.threadCatalog,
appServerData: host.environment.plugin.appServerData,
serverActions: connectionBundle.serverActions,
refreshTabHeader: () => {
refreshTabHeader(host);
},
});
return {
connection: {
@ -357,18 +335,18 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch
},
toolbar: {
panels: threadActionParts.toolbarPanels,
actions: surfaceParts.toolbarActions,
actions: surfaces.toolbarActions,
},
composer: {
controller: composerController,
submission: composerAndTurn.turnActions.composerSubmit,
},
render: {
messageStreamPresenter: surfaceParts.messageStreamPresenter,
messageStreamPresenter: surfaces.messageStreamPresenter,
},
surface: {
toolbar: surfaceParts.toolbarSurface,
goal: surfaceParts.goalSurface,
toolbar: surfaces.toolbarSurface,
goal: surfaces.goalSurface,
composer: composerSurface,
},
actions: {
@ -393,63 +371,6 @@ function createConnectionManager(environment: ChatPanelEnvironment): ConnectionM
return new ConnectionManager(() => environment.plugin.settingsRef.settings.codexPath, environment.plugin.settingsRef.vaultPath);
}
function createChatPanelSharedStateBinding(
host: ChatPanelSessionGraphHost,
serverActions: ChatPanelConnectionBundle["serverActions"],
): ChatPanelSharedStateBinding {
const unsubscribers: (() => void)[] = [];
const receiveThreads = (threads: readonly Thread[]): void => {
serverActions.threads.applyThreadList(threads);
refreshTabHeader(host);
};
const receiveThreadResult = (result: ObservedDataResult<readonly Thread[]>): void => {
const data = observedData(result);
if (data) receiveThreads(data);
};
const receiveAppServerMetadata = (metadata: SharedServerMetadata): void => {
serverActions.metadata.applyAppServerMetadata(metadata);
};
const receiveAppServerMetadataResult = (result: ObservedDataResult<SharedServerMetadata>): void => {
const data = observedData(result);
if (data) receiveAppServerMetadata(data);
};
const receiveModels = (models: readonly ModelMetadata[]): void => {
dispatch(host.stateStore, { type: "connection/metadata-applied", availableModels: models });
};
const receiveModelsResult = (result: ObservedDataResult<readonly ModelMetadata[]>): void => {
const data = observedData(result);
if (data) receiveModels(data);
};
const unsubscribe = (): void => {
while (unsubscribers.length > 0) {
unsubscribers.pop()?.();
}
};
const applyCached = (): void => {
const threads = host.environment.plugin.threadCatalog.activeSnapshot();
if (threads) serverActions.threads.applyThreadList(threads);
const metadata = host.environment.plugin.appServerData.appServerMetadataSnapshot();
if (metadata) serverActions.metadata.applyAppServerMetadata(metadata);
const models = host.environment.plugin.appServerData.modelsSnapshot();
if (models) receiveModels(models);
};
return {
applyCached,
subscribe: () => {
unsubscribe();
applyCached();
unsubscribers.push(
host.environment.plugin.threadCatalog.observeActive(receiveThreadResult, { emitCurrent: false }),
host.environment.plugin.appServerData.observeAppServerMetadataResult(receiveAppServerMetadataResult, { emitCurrent: false }),
host.environment.plugin.appServerData.observeModelsResult(receiveModelsResult, { emitCurrent: false }),
);
},
unsubscribe,
};
}
function createSessionThreadTitleService(host: ChatPanelSessionGraphHost, currentClient: CurrentAppServerClient): ThreadTitleService {
const { environment, stateStore } = host;
return createThreadTitleService({
@ -887,98 +808,6 @@ function createComposerAndTurnActions(
};
}
function createSurfacesAndPresenter(
host: ChatPanelSessionGraphHost,
input: ChatPanelSurfacePresenterInput,
): ChatPanelSurfacePresenterParts {
const {
connection,
connectionController,
goals,
rename,
threadActions,
toolbarPanels,
selection,
reconnect,
history,
pendingRequests,
turnActions,
startNewThread,
} = input;
const { environment, stateStore } = host;
const toolbarActions = createChatPanelToolbarActions(
{
startNewThread,
},
{
connectionController,
reconnectPanel: reconnect,
threadActions,
goals,
toolbarPanels,
rename,
selection,
},
);
const toolbarSurface: ChatPanelToolbarSurface = {
state: {
connected: () => connection.isConnected(),
nowMs: () => Date.now(),
},
settings: {
vaultPath: () => environment.plugin.settingsRef.vaultPath,
configuredCommand: () => environment.plugin.settingsRef.settings.codexPath,
archiveExportEnabled: () => environment.plugin.settingsRef.settings.archiveExportEnabled,
},
};
const goalSurface = createChatPanelGoalSurface(
{
sendShortcut: () => environment.plugin.settingsRef.settings.sendShortcut,
},
{
goals,
},
);
const messageStreamPresenter = new MessageStreamPresenter({
obsidian: {
app: environment.obsidian.app,
owner: environment.obsidian.owner,
},
state: {
store: stateStore,
},
workspace: {
vaultPath: environment.plugin.settingsRef.vaultPath,
},
scroll: {
controller: host.messageScrollController,
dispose: () => {
host.messageScrollController.dispose();
},
},
history: {
loadOlderTurns: () => void history.loadOlder(),
},
actions: {
rollbackThread: (threadId) => void threadActions.rollbackThread(threadId),
forkThreadFromTurn: (threadId, turnId, archiveSource) => void threadActions.forkThreadFromTurn(threadId, turnId, archiveSource),
implementPlan: (itemId) => void turnActions.planImplementation.implement(itemId),
openTurnDiff: (state) => void environment.plugin.workspace.openTurnDiff(state),
},
requests: {
pendingActions: () => pendingRequests.actions(),
consumePendingAutoFocus: () => pendingRequests.consumeAutoFocus(),
},
});
return {
toolbarActions,
toolbarSurface,
goalSurface,
messageStreamPresenter,
};
}
function collaborationModeLabel(stateStore: ChatStateStore): string {
return formatCollaborationModeLabel(stateStore.getState().runtime.pending.collaborationMode);
}

View file

@ -9,8 +9,8 @@ import { type ChatStateStore, createChatStateStore } from "../application/state/
import { renderChatPanelShell, unmountChatPanelShell } from "../panel/shell.dom";
import { type ChatPanelSnapshot, openPanelTurnLifecycle, parseRestoredThreadState } from "../panel/snapshot";
import { type ChatMessageScrollController, createChatMessageScrollController } from "../panel/surface/message-stream-scroll";
import { createChatViewDeferredTasks } from "./lifecycle";
import type { ChatPanelEnvironment } from "./runtime";
import { createChatViewDeferredTasks } from "./deferred-tasks";
import type { ChatPanelEnvironment } from "./environment";
import { type ChatPanelSessionGraph, createChatPanelSessionGraph } from "./session-graph";
import type { ChatSurfaceHandle } from "./surface-handle";

View file

@ -0,0 +1,92 @@
import type { ModelMetadata } from "../../../domain/catalog/metadata";
import type { ObservedDataResult } from "../../../domain/observed-data";
import { observedData } from "../../../domain/observed-data";
import type { SharedServerMetadata } from "../../../domain/server/metadata";
import type { Thread } from "../../../domain/threads/model";
import type { ChatStateStore } from "../application/state/store";
import type { ChatPanelConnectionBundle } from "./connection-bundle";
type ThreadObserver = (result: ObservedDataResult<readonly Thread[]>) => void;
type MetadataObserver = (result: ObservedDataResult<SharedServerMetadata>) => void;
type ModelsObserver = (result: ObservedDataResult<readonly ModelMetadata[]>) => void;
interface SharedStateThreadCatalog {
activeSnapshot(): readonly Thread[] | null;
observeActive(observer: ThreadObserver, options?: { emitCurrent?: boolean }): () => void;
}
interface SharedStateAppServerData {
appServerMetadataSnapshot(): SharedServerMetadata | null;
modelsSnapshot(): readonly ModelMetadata[] | null;
observeAppServerMetadataResult(observer: MetadataObserver, options?: { emitCurrent?: boolean }): () => void;
observeModelsResult(observer: ModelsObserver, options?: { emitCurrent?: boolean }): () => void;
}
export interface ChatPanelSharedStateBinding {
applyCached(): void;
subscribe(): void;
unsubscribe(): void;
}
export interface ChatPanelSharedStateBindingOptions {
stateStore: ChatStateStore;
threadCatalog: SharedStateThreadCatalog;
appServerData: SharedStateAppServerData;
serverActions: ChatPanelConnectionBundle["serverActions"];
refreshTabHeader: () => void;
}
export function createChatPanelSharedStateBinding(options: ChatPanelSharedStateBindingOptions): ChatPanelSharedStateBinding {
const unsubscribers: (() => void)[] = [];
const { stateStore, threadCatalog, appServerData, serverActions, refreshTabHeader } = options;
const receiveThreads = (threads: readonly Thread[]): void => {
serverActions.threads.applyThreadList(threads);
refreshTabHeader();
};
const receiveThreadResult = (result: ObservedDataResult<readonly Thread[]>): void => {
const data = observedData(result);
if (data) receiveThreads(data);
};
const receiveAppServerMetadata = (metadata: SharedServerMetadata): void => {
serverActions.metadata.applyAppServerMetadata(metadata);
};
const receiveAppServerMetadataResult = (result: ObservedDataResult<SharedServerMetadata>): void => {
const data = observedData(result);
if (data) receiveAppServerMetadata(data);
};
const receiveModels = (models: readonly ModelMetadata[]): void => {
stateStore.dispatch({ type: "connection/metadata-applied", availableModels: models });
};
const receiveModelsResult = (result: ObservedDataResult<readonly ModelMetadata[]>): void => {
const data = observedData(result);
if (data) receiveModels(data);
};
const unsubscribe = (): void => {
while (unsubscribers.length > 0) {
unsubscribers.pop()?.();
}
};
const applyCached = (): void => {
const threads = threadCatalog.activeSnapshot();
if (threads) serverActions.threads.applyThreadList(threads);
const metadata = appServerData.appServerMetadataSnapshot();
if (metadata) serverActions.metadata.applyAppServerMetadata(metadata);
const models = appServerData.modelsSnapshot();
if (models) receiveModels(models);
};
return {
applyCached,
subscribe: () => {
unsubscribe();
applyCached();
unsubscribers.push(
threadCatalog.observeActive(receiveThreadResult, { emitCurrent: false }),
appServerData.observeAppServerMetadataResult(receiveAppServerMetadataResult, { emitCurrent: false }),
appServerData.observeModelsResult(receiveModelsResult, { emitCurrent: false }),
);
},
unsubscribe,
};
}

View file

@ -3,7 +3,7 @@ import { ItemView, type ViewStateResult, type WorkspaceLeaf } from "obsidian";
import { VIEW_TYPE_CODEX_PANEL } from "../../../constants";
import { createLocalIdSource } from "../../../shared/id/local-id";
import { createObsidianArchiveExportDestination } from "../../../shared/obsidian/archive-export-destination";
import type { CodexChatHost } from "./runtime";
import type { CodexChatHost } from "./environment";
import { ChatPanelSession } from "./session";
import type { ChatSurfaceHandle } from "./surface-handle";

View file

@ -8,7 +8,7 @@ import { AppServerSharedQueries } from "./app-server/query/shared-queries";
import { VIEW_TYPE_CODEX_THREADS, VIEW_TYPE_CODEX_TURN_DIFF } from "./constants";
import type { ChatTurnDiffViewState } from "./features/chat/domain/turn-diff";
import { persistedChatTurnDiffViewState } from "./features/chat/domain/turn-diff";
import type { CodexChatHost, PluginSettingsRef } from "./features/chat/host/runtime";
import type { CodexChatHost, PluginSettingsRef } from "./features/chat/host/environment";
import type {
ChatPanelClientSurface,
ChatSharedThreadSurface,

View file

@ -109,6 +109,50 @@ describe("runEphemeralStructuredTurn", () => {
expect(timers.clearTimeout).toHaveBeenCalledWith(123);
});
it("deletes the ephemeral thread before disconnecting after a completed turn", async () => {
const { clientFactory, client } = fakeStructuredTurnClientFactory((fake) => {
fake.startStructuredTurnImpl = async () => ({ turn: turn([agentMessage("answer", '{"ok":true}')]) });
});
await runEphemeralStructuredTurn(runOptions(clientFactory));
const fake = expectPresent(client.current);
expect(fake.deleteThread).toHaveBeenCalledWith("thread", { timeoutMs: 5_000 });
expect(expectPresent(fake.disconnect.mock.invocationCallOrder[0])).toBeGreaterThan(
expectPresent(fake.deleteThread.mock.invocationCallOrder[0]),
);
});
it("does not try to delete when no ephemeral thread was started", async () => {
const timers = timerHarness();
const { clientFactory, client } = fakeStructuredTurnClientFactory((fake) => {
fake.connectImpl = () => new Promise<InitializeResponse>(() => undefined);
});
const running = runEphemeralStructuredTurn({
...runOptions(clientFactory),
timers,
});
await Promise.resolve();
timers.fireTimeout();
await expect(running).rejects.toThrow("Structured test timed out.");
expect(expectPresent(client.current).deleteThread).not.toHaveBeenCalled();
});
it("keeps the completed turn result when deleting the ephemeral thread fails", async () => {
const { clientFactory, client } = fakeStructuredTurnClientFactory((fake) => {
fake.deleteThread.mockRejectedValueOnce(new Error("delete failed"));
fake.startStructuredTurnImpl = async () => ({ turn: turn([agentMessage("answer", '{"ok":true}')]) });
});
await expect(runEphemeralStructuredTurn(runOptions(clientFactory))).resolves.toMatchObject({
items: [agentMessage("answer", '{"ok":true}')],
});
expect(expectPresent(client.current).disconnect).toHaveBeenCalledOnce();
});
it("rejects server requests with the configured message", async () => {
const { clientFactory, client } = fakeStructuredTurnClientFactory((fake) => {
fake.connectImpl = async () => {
@ -215,6 +259,24 @@ describe("runEphemeralStructuredTurn", () => {
expect(expectPresent(client.current).disconnect).toHaveBeenCalledOnce();
expect(timers.clearTimeout).toHaveBeenCalledWith(123);
});
it("deletes a started ephemeral thread when structured turn startup times out", async () => {
const timers = timerHarness();
const { clientFactory, client } = fakeStructuredTurnClientFactory((fake) => {
fake.startStructuredTurnImpl = () => new Promise<TurnStartResponse>(() => undefined);
});
const running = runEphemeralStructuredTurn({
...runOptions(clientFactory),
timers,
});
await expectPresent(client.current).structuredTurnStarted;
timers.fireTimeout();
await expect(running).rejects.toThrow("Structured test timed out.");
expect(expectPresent(client.current).deleteThread).toHaveBeenCalledWith("thread", { timeoutMs: 5_000 });
});
});
function runOptions(clientFactory: EphemeralStructuredTurnClientFactory): Parameters<typeof runEphemeralStructuredTurn>[0] {
@ -256,6 +318,7 @@ class FakeStructuredTurnClient implements EphemeralStructuredTurnClient {
startStructuredTurnOptions: AppServerStartStructuredTurnOptions | null = null;
readonly listModels = vi.fn(async (): Promise<ModelListResponse> => ({ data: [], nextCursor: null }));
readonly rejectServerRequest = vi.fn();
readonly deleteThread = vi.fn(async () => undefined);
readonly disconnect = vi.fn();
readonly structuredTurnStarted: Promise<void>;
private resolveStructuredTurnStarted!: () => void;

View file

@ -242,6 +242,8 @@ class FakeThreadTitleClient implements EphemeralStructuredTurnClient {
disconnect(): void {}
async deleteThread(): Promise<void> {}
async listModels(): Promise<ModelListResponse> {
return { data: this.modelList, nextCursor: null };
}

View file

@ -7,8 +7,8 @@ import type { ModelMetadata } from "../../../../src/domain/catalog/metadata";
import type { Thread } from "../../../../src/domain/threads/model";
import { ChatResumeWorkTracker } from "../../../../src/features/chat/application/lifecycle";
import { type ChatStateStore, createChatStateStore } from "../../../../src/features/chat/application/state/store";
import { createChatViewDeferredTasks } from "../../../../src/features/chat/host/lifecycle";
import type { ChatPanelEnvironment } from "../../../../src/features/chat/host/runtime";
import { createChatViewDeferredTasks } from "../../../../src/features/chat/host/deferred-tasks";
import type { ChatPanelEnvironment } from "../../../../src/features/chat/host/environment";
import { createChatPanelSessionGraph } from "../../../../src/features/chat/host/session-graph";
import { ChatComposerController } from "../../../../src/features/chat/panel/composer-controller";
import { MessageStreamPresenter } from "../../../../src/features/chat/panel/surface/message-stream-presenter";

View file

@ -29,6 +29,7 @@ function handlerForState(
store,
{
refreshActiveThreads: vi.fn(),
refreshServerDiagnostics: vi.fn(),
applyAppServerResourceEvent: vi.fn(),
maybeNameThread: vi.fn(),
applyThreadCatalogEvent: vi.fn(),
@ -717,6 +718,32 @@ describe("ChatInboundHandler", () => {
});
expect(chatStateMessageStreamItems(handler.currentState())).toEqual([]);
});
it("refreshes tool inventory diagnostics when the app list changes", () => {
const refreshServerDiagnostics = vi.fn();
const handler = handlerForState(chatStateFixture(), { refreshServerDiagnostics });
handler.handleNotification({
method: "app/list/updated",
params: { data: [] },
} satisfies Extract<ServerNotification, { method: "app/list/updated" }>);
expect(refreshServerDiagnostics).toHaveBeenCalledWith({ forceResourceProbes: false });
expect(chatStateMessageStreamItems(handler.currentState())).toEqual([]);
});
it("refreshes resource probes after MCP OAuth login completes", () => {
const refreshServerDiagnostics = vi.fn();
const handler = handlerForState(chatStateFixture(), { refreshServerDiagnostics });
handler.handleNotification({
method: "mcpServer/oauthLogin/completed",
params: { name: "github", success: true },
} satisfies Extract<ServerNotification, { method: "mcpServer/oauthLogin/completed" }>);
expect(refreshServerDiagnostics).toHaveBeenCalledWith({ forceResourceProbes: true });
expect(chatStateMessageStreamItems(handler.currentState())).toEqual([]);
});
});
describe("interactive server requests", () => {
@ -1197,6 +1224,59 @@ describe("ChatInboundHandler", () => {
]);
});
it("keeps Windows world-writable warnings in the message stream", () => {
const handler = handlerForState(chatStateFixture());
handler.handleNotification({
method: "windows/worldWritableWarning",
params: { samplePaths: ["C:\\tmp\\open"], extraCount: 2, failedScan: false },
} satisfies Extract<ServerNotification, { method: "windows/worldWritableWarning" }>);
expect(chatStateMessageStreamItems(handler.currentState())).toEqual([
expect.objectContaining({
kind: "system",
text: expect.stringContaining("windows/worldWritableWarning"),
}),
]);
expect(chatStateMessageStreamItems(handler.currentState())[0]).toEqual(
expect.objectContaining({
text: expect.stringContaining("open"),
}),
);
});
it("keeps failed Windows sandbox setup notices in the message stream", () => {
const handler = handlerForState(chatStateFixture());
handler.handleNotification({
method: "windowsSandbox/setupCompleted",
params: { mode: "unelevated", success: false, error: "setup failed" },
} satisfies Extract<ServerNotification, { method: "windowsSandbox/setupCompleted" }>);
expect(chatStateMessageStreamItems(handler.currentState())).toEqual([
expect.objectContaining({
kind: "system",
text: expect.stringContaining("windowsSandbox/setupCompleted"),
}),
]);
expect(chatStateMessageStreamItems(handler.currentState())[0]).toEqual(
expect.objectContaining({
text: expect.stringContaining("setup failed"),
}),
);
});
it("suppresses successful Windows sandbox setup notices", () => {
const handler = handlerForState(chatStateFixture());
handler.handleNotification({
method: "windowsSandbox/setupCompleted",
params: { mode: "unelevated", success: true, error: null },
} satisfies Extract<ServerNotification, { method: "windowsSandbox/setupCompleted" }>);
expect(chatStateMessageStreamItems(handler.currentState())).toEqual([]);
});
it("clears all active-thread scoped state when the active thread is archived", () => {
let state = activeRunningState();
state = chatStateWith(state, { runtime: { active: { model: "gpt-5.5" } } });

View file

@ -1,3 +1,5 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { ServerNotification, ServerRequest } from "../../../../../src/app-server/connection/rpc-messages";
import { routeServerRequest } from "../../../../../src/app-server/server-requests";
@ -6,6 +8,7 @@ import {
planChatNotification,
} from "../../../../../src/features/chat/app-server/inbound/notification-plan";
import {
IGNORED_SERVER_NOTIFICATION_METHODS,
ROUTED_SERVER_NOTIFICATION_METHODS_BY_ROUTE_KIND,
routeServerNotification,
} from "../../../../../src/features/chat/app-server/inbound/notification-routing";
@ -23,6 +26,14 @@ describe("chat inbound routing", () => {
);
});
it("keeps generated app-server notifications explicitly routed or ignored", () => {
const routed = flattenedMethods(ROUTED_SERVER_NOTIFICATION_METHODS_BY_ROUTE_KIND);
const ignored = [...IGNORED_SERVER_NOTIFICATION_METHODS];
expect(intersection(routed, ignored)).toEqual([]);
expect([...new Set([...routed, ...ignored])].sort()).toEqual(generatedServerNotificationMethods());
});
it("routes turn-scoped app-server messages for the active scope", () => {
const notification = {
method: "turn/completed",
@ -187,7 +198,7 @@ describe("chat inbound routing", () => {
expectNotificationRouteKind(notification, kind);
});
it("leaves unhandled app-server notifications explicit", () => {
it("keeps ignored app-server notifications explicit", () => {
const route = routeServerNotification(
{
method: "account/updated",
@ -196,7 +207,7 @@ describe("chat inbound routing", () => {
activeScope,
);
expect(route.kind).toBe("unhandled");
expect(route.kind).toBe("ignored");
});
it("routes unknown runtime notifications to the unhandled fallback", () => {
@ -232,8 +243,8 @@ describe("chat inbound routing", () => {
{ name: "turn moderation metadata", notification: turnModerationMetadataNotification },
{ name: "terminal interaction", notification: terminalInteractionNotification },
{ name: "model verification", notification: modelVerificationNotification },
])("still scopes unhandled turn notification $name", ({ notification }) => {
expectNotificationRouteKind(notification("thread-active", "turn-active"), "unhandled");
])("still scopes ignored turn notification $name", ({ notification }) => {
expectNotificationRouteKind(notification("thread-active", "turn-active"), "ignored");
expectNotificationRouteKind(notification("thread-other", "turn-active"), "inactive");
expectNotificationRouteKind(notification("thread-active", "turn-other"), "inactive");
});
@ -241,8 +252,8 @@ describe("chat inbound routing", () => {
it.each([
{ name: "thread status changed", notification: threadStatusChangedNotification },
{ name: "thread closed", notification: threadClosedNotification },
])("still scopes unhandled thread lifecycle notification $name", ({ notification }) => {
expectNotificationRouteKind(notification("thread-active"), "unhandled");
])("still scopes ignored thread lifecycle notification $name", ({ notification }) => {
expectNotificationRouteKind(notification("thread-active"), "ignored");
expectNotificationRouteKind(notification("thread-other"), "inactive");
});
@ -435,6 +446,26 @@ function sortedMethods(methodsByRouteKind: Record<string, readonly string[]>): R
return Object.fromEntries(Object.entries(methodsByRouteKind).map(([kind, methods]) => [kind, [...methods].sort()]));
}
function flattenedMethods(methodsByRouteKind: Record<string, readonly string[]>): string[] {
return Object.values(methodsByRouteKind).flat().sort();
}
function intersection(left: readonly string[], right: readonly string[]): string[] {
const rightSet = new Set(right);
return left.filter((method) => rightSet.has(method)).sort();
}
function generatedServerNotificationMethods(): string[] {
const generated = readFileSync(path.join(process.cwd(), "src/generated/app-server/ServerNotification.ts"), "utf8");
return [...generated.matchAll(/"method": "([^"]+)"/g)]
.map((match) => {
const method = match[1];
if (!method) throw new Error("Expected generated notification method match.");
return method;
})
.sort();
}
function threadArchivedNotification(threadId = "thread-active"): ServerNotification {
return {
method: "thread/archived",

View file

@ -10,7 +10,7 @@ import { emptyRuntimeConfigSnapshot } from "../../../src/domain/runtime/config";
import { createServerDiagnostics } from "../../../src/domain/server/diagnostics";
import type { SharedServerMetadata } from "../../../src/domain/server/metadata";
import type { Thread } from "../../../src/domain/threads/model";
import type { CodexChatHost } from "../../../src/features/chat/host/runtime";
import type { CodexChatHost } from "../../../src/features/chat/host/environment";
import { DEFAULT_SETTINGS } from "../../../src/settings/model";
import type { ThreadCatalogEvent } from "../../../src/workspace/thread-catalog";
import { notices } from "../../mocks/obsidian";

View file

@ -2,7 +2,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ChatResumeWorkTracker, transitionRestoredThreadLifecycle } from "../../../src/features/chat/application/lifecycle";
import { createChatViewDeferredTasks } from "../../../src/features/chat/host/lifecycle";
import { createChatViewDeferredTasks } from "../../../src/features/chat/host/deferred-tasks";
import { ConnectionWorkTracker } from "../../../src/shared/lifecycle/connection-work";
describe("createChatViewDeferredTasks", () => {

View file

@ -812,6 +812,8 @@ class FakeSelectionRewriteClient implements SelectionRewriteClient {
this.disconnected = true;
}
async deleteThread(): Promise<void> {}
async listModels(): Promise<ModelListResponse> {
return this.modelList;
}

View file

@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { VIEW_TYPE_CODEX_PANEL, VIEW_TYPE_CODEX_THREADS } from "../src/constants";
import type { Thread } from "../src/domain/threads/model";
import type { CodexChatHost } from "../src/features/chat/host/runtime";
import type { CodexChatHost } from "../src/features/chat/host/environment";
import type { CodexChatView } from "../src/features/chat/host/view";
import type CodexPanelPlugin from "../src/main";
import { DEFAULT_SETTINGS } from "../src/settings/model";