diff --git a/src/app-server/connection/client.ts b/src/app-server/connection/client.ts index fd23df9e..b210e23b 100644 --- a/src/app-server/connection/client.ts +++ b/src/app-server/connection/client.ts @@ -379,8 +379,8 @@ export class AppServerClient { return this.request("thread/archive", { threadId }); } - deleteThread(threadId: string): Promise { - return this.request("thread/delete", { threadId }); + deleteThread(threadId: string, options: { timeoutMs?: number } = {}): Promise { + return this.request("thread/delete", { threadId }, options); } readThread(threadId: string, includeTurns = true): Promise { diff --git a/src/app-server/services/ephemeral-structured-turn.ts b/src/app-server/services/ephemeral-structured-turn.ts index b12cbb50..f1ec594c 100644 --- a/src/app-server/services/ephemeral-structured-turn.ts +++ b/src/app-server/services/ephemeral-structured-turn.ts @@ -16,6 +16,8 @@ type StructuredTurnRuntimeOverride = NonNullable void, delayMs: number): ReturnType; clearTimeout(timer: ReturnType): 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; } 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 { + 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)); } diff --git a/src/features/chat/app-server/inbound/handler.ts b/src/features/chat/app-server/inbound/handler.ts index 7377146d..be34ec0f 100644 --- a/src/features/chat/app-server/inbound/handler.ts +++ b/src/features/chat/app-server/inbound/handler.ts @@ -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; diff --git a/src/features/chat/app-server/inbound/notification-plan.ts b/src/features/chat/app-server/inbound/notification-plan.ts index 5d584b46..25baaf86 100644 --- a/src/features/chat/app-server/inbound/notification-plan.ts +++ b/src/features/chat/app-server/inbound/notification-plan.ts @@ -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; 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["kind"], "inactive" | "unhandled">, + Exclude["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": diff --git a/src/features/chat/app-server/inbound/notification-routing.ts b/src/features/chat/app-server/inbound/notification-routing.ts index 1bb2bd00..8afd45b8 100644 --- a/src/features/chat/app-server/inbound/notification-routing.ts +++ b/src/features/chat/app-server/inbound/notification-routing.ts @@ -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, readonly ServerNotificationMethod[]>; +} satisfies Record, 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 { + return notificationMethodIn(notification.method, IGNORED_SERVER_NOTIFICATION_METHODS); +} + function notificationMethodIn(method: ServerNotificationMethod, methods: readonly M[]): method is M { return (methods as readonly ServerNotificationMethod[]).includes(method); } diff --git a/src/features/chat/host/connection-bundle.ts b/src/features/chat/host/connection-bundle.ts index 23a0a294..8496d7ed 100644 --- a/src/features/chat/host/connection-bundle.ts +++ b/src/features/chat/host/connection-bundle.ts @@ -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)); diff --git a/src/features/chat/host/lifecycle.ts b/src/features/chat/host/deferred-tasks.ts similarity index 100% rename from src/features/chat/host/lifecycle.ts rename to src/features/chat/host/deferred-tasks.ts diff --git a/src/features/chat/host/runtime.ts b/src/features/chat/host/environment.ts similarity index 100% rename from src/features/chat/host/runtime.ts rename to src/features/chat/host/environment.ts diff --git a/src/features/chat/host/panel-surfaces.ts b/src/features/chat/host/panel-surfaces.ts new file mode 100644 index 00000000..604ca441 --- /dev/null +++ b/src/features/chat/host/panel-surfaces.ts @@ -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; +type ChatPanelThreadActions = ReturnType; +type ChatPanelSelectionActions = ReturnType; +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; + history: HistoryController; + pendingRequests: PendingRequestActions; + turnActions: ChatPanelConversationTurnActions; + startNewThread: () => Promise; +} + +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, + }; +} diff --git a/src/features/chat/host/session-graph.ts b/src/features/chat/host/session-graph.ts index 1c5a3c70..8966942f 100644 --- a/src/features/chat/host/session-graph.ts +++ b/src/features/chat/host/session-graph.ts @@ -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; - history: HistoryController; - pendingRequests: PendingRequestActions; - turnActions: ChatPanelConversationTurnActions; - startNewThread: () => Promise; -} - -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): void => { - const data = observedData(result); - if (data) receiveThreads(data); - }; - const receiveAppServerMetadata = (metadata: SharedServerMetadata): void => { - serverActions.metadata.applyAppServerMetadata(metadata); - }; - const receiveAppServerMetadataResult = (result: ObservedDataResult): 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): 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); } diff --git a/src/features/chat/host/session.ts b/src/features/chat/host/session.ts index 4122507b..c6837e08 100644 --- a/src/features/chat/host/session.ts +++ b/src/features/chat/host/session.ts @@ -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"; diff --git a/src/features/chat/host/shared-state-binding.ts b/src/features/chat/host/shared-state-binding.ts new file mode 100644 index 00000000..cec5f619 --- /dev/null +++ b/src/features/chat/host/shared-state-binding.ts @@ -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) => void; +type MetadataObserver = (result: ObservedDataResult) => void; +type ModelsObserver = (result: ObservedDataResult) => 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): void => { + const data = observedData(result); + if (data) receiveThreads(data); + }; + const receiveAppServerMetadata = (metadata: SharedServerMetadata): void => { + serverActions.metadata.applyAppServerMetadata(metadata); + }; + const receiveAppServerMetadataResult = (result: ObservedDataResult): 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): 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, + }; +} diff --git a/src/features/chat/host/view.ts b/src/features/chat/host/view.ts index 5ad90bc7..e6535e49 100644 --- a/src/features/chat/host/view.ts +++ b/src/features/chat/host/view.ts @@ -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"; diff --git a/src/plugin-runtime.ts b/src/plugin-runtime.ts index 8f11676c..75d1235b 100644 --- a/src/plugin-runtime.ts +++ b/src/plugin-runtime.ts @@ -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, diff --git a/tests/app-server/ephemeral-structured-turn.test.ts b/tests/app-server/ephemeral-structured-turn.test.ts index 3af6ed84..ba5a5f6c 100644 --- a/tests/app-server/ephemeral-structured-turn.test.ts +++ b/tests/app-server/ephemeral-structured-turn.test.ts @@ -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(() => 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(() => 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[0] { @@ -256,6 +318,7 @@ class FakeStructuredTurnClient implements EphemeralStructuredTurnClient { startStructuredTurnOptions: AppServerStartStructuredTurnOptions | null = null; readonly listModels = vi.fn(async (): Promise => ({ data: [], nextCursor: null })); readonly rejectServerRequest = vi.fn(); + readonly deleteThread = vi.fn(async () => undefined); readonly disconnect = vi.fn(); readonly structuredTurnStarted: Promise; private resolveStructuredTurnStarted!: () => void; diff --git a/tests/app-server/thread-title-generation.test.ts b/tests/app-server/thread-title-generation.test.ts index d3d66b31..18c7b0e7 100644 --- a/tests/app-server/thread-title-generation.test.ts +++ b/tests/app-server/thread-title-generation.test.ts @@ -242,6 +242,8 @@ class FakeThreadTitleClient implements EphemeralStructuredTurnClient { disconnect(): void {} + async deleteThread(): Promise {} + async listModels(): Promise { return { data: this.modelList, nextCursor: null }; } diff --git a/tests/features/chat/host/session-graph.test.ts b/tests/features/chat/host/session-graph.test.ts index 790612f2..3fb0e54c 100644 --- a/tests/features/chat/host/session-graph.test.ts +++ b/tests/features/chat/host/session-graph.test.ts @@ -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"; diff --git a/tests/features/chat/protocol/inbound/handler.test.ts b/tests/features/chat/protocol/inbound/handler.test.ts index 988a7b28..695e0e70 100644 --- a/tests/features/chat/protocol/inbound/handler.test.ts +++ b/tests/features/chat/protocol/inbound/handler.test.ts @@ -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); + + 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); + + 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); + + 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); + + 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); + + 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" } } }); diff --git a/tests/features/chat/protocol/inbound/routing.test.ts b/tests/features/chat/protocol/inbound/routing.test.ts index 3e48e36d..798a6a99 100644 --- a/tests/features/chat/protocol/inbound/routing.test.ts +++ b/tests/features/chat/protocol/inbound/routing.test.ts @@ -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): R return Object.fromEntries(Object.entries(methodsByRouteKind).map(([kind, methods]) => [kind, [...methods].sort()])); } +function flattenedMethods(methodsByRouteKind: Record): 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", diff --git a/tests/features/chat/view-connection.test.ts b/tests/features/chat/view-connection.test.ts index 8616e3cd..e556a620 100644 --- a/tests/features/chat/view-connection.test.ts +++ b/tests/features/chat/view-connection.test.ts @@ -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"; diff --git a/tests/features/chat/view-lifecycle.test.ts b/tests/features/chat/view-lifecycle.test.ts index e5e69b7c..afe36072 100644 --- a/tests/features/chat/view-lifecycle.test.ts +++ b/tests/features/chat/view-lifecycle.test.ts @@ -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", () => { diff --git a/tests/features/selection-rewrite/selection-rewrite.test.ts b/tests/features/selection-rewrite/selection-rewrite.test.ts index 74242b0a..86057201 100644 --- a/tests/features/selection-rewrite/selection-rewrite.test.ts +++ b/tests/features/selection-rewrite/selection-rewrite.test.ts @@ -812,6 +812,8 @@ class FakeSelectionRewriteClient implements SelectionRewriteClient { this.disconnected = true; } + async deleteThread(): Promise {} + async listModels(): Promise { return this.modelList; } diff --git a/tests/main.test.ts b/tests/main.test.ts index 5cdb6663..f06eb86f 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -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";