diff --git a/src/app-server/connection/connection-manager.ts b/src/app-server/connection/connection-manager.ts index 948bd652..dd9c71a5 100644 --- a/src/app-server/connection/connection-manager.ts +++ b/src/app-server/connection/connection-manager.ts @@ -1,7 +1,6 @@ import type { ServerInitialization } from "../../domain/server/initialization"; import type { ServerNotification } from "../../generated/app-server/ServerNotification"; import type { ServerRequest } from "../../generated/app-server/ServerRequest"; -import { appServerInitializationFromResponse } from "../protocol/initialization"; import { AppServerClient, type AppServerClientHandlers } from "./client"; export interface ConnectionManagerHandlers { @@ -67,7 +66,7 @@ export class ConnectionManager { async connect(handlers: ConnectionManagerHandlers): Promise { const currentClient = this.currentClient(); if (currentClient) { - return appServerInitializationFromResponse(currentClient.initializeResponse); + return serverInitializationFromResponse(currentClient.initializeResponse); } if (this.state.kind === "connecting" && this.stateMatchesCurrentContext(this.state)) return this.state.promise; if (this.state.kind === "connecting" || this.state.kind === "connected") this.disconnect(); @@ -102,7 +101,7 @@ export class ConnectionManager { throw new StaleConnectionError(); } this.state = { kind: "connected", generation, codexPath, cwd, client }; - return appServerInitializationFromResponse(response); + return serverInitializationFromResponse(response); }) .catch((error: unknown) => { if (this.isStale(generation)) { @@ -139,3 +138,12 @@ export class ConnectionManager { return state.codexPath === this.codexPath() && state.cwd === this.cwd; } } + +function serverInitializationFromResponse(response: ServerInitialization): ServerInitialization { + return { + userAgent: response.userAgent, + codexHome: response.codexHome, + platformFamily: response.platformFamily, + platformOs: response.platformOs, + }; +} diff --git a/src/app-server/protocol/initialization.ts b/src/app-server/protocol/initialization.ts deleted file mode 100644 index 69d450c5..00000000 --- a/src/app-server/protocol/initialization.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { ServerInitialization } from "../../domain/server/initialization"; - -interface AppServerInitializeResponse { - userAgent: string; - codexHome: string; - platformFamily: string; - platformOs: string; -} - -export function appServerInitializationFromResponse(response: AppServerInitializeResponse): ServerInitialization { - return { - userAgent: response.userAgent, - codexHome: response.codexHome, - platformFamily: response.platformFamily, - platformOs: response.platformOs, - }; -} diff --git a/src/app-server/query/observed-result.ts b/src/app-server/query/observed-result.ts index 5ebc95f9..674c5ebf 100644 --- a/src/app-server/query/observed-result.ts +++ b/src/app-server/query/observed-result.ts @@ -6,10 +6,6 @@ export interface ObservedResult { export type ObservedResultListener = (result: ObservedResult) => void; -export function observedValue(result: ObservedResult): T | null { - return result.value; -} - export function observedInitialLoading(result: ObservedResult, currentValue: T | null | undefined): boolean { return currentValue == null && result.isFetching; } diff --git a/src/features/chat/app-server/session-gateway.ts b/src/features/chat/app-server/session-gateway.ts index 9232cff4..14e46e24 100644 --- a/src/features/chat/app-server/session-gateway.ts +++ b/src/features/chat/app-server/session-gateway.ts @@ -8,11 +8,15 @@ import type { ThreadGoalReadTransport, ThreadGoalTransport } from "../applicatio import type { ThreadHistoryTransport, ThreadResumeTransport } from "../application/threads/thread-loading-transport"; import type { ThreadMutationTransport } from "../application/threads/thread-mutation-transport"; import { createThreadReferenceResolver, type ThreadReferenceResolver } from "./thread-reference-resolver"; -import { createChatThreadGoalReadTransport, createChatThreadGoalTransport } from "./transports/goal-transport"; -import { createChatThreadHistoryTransport, createChatThreadResumeTransport } from "./transports/thread-loading-transport"; -import { createChatThreadMutationTransport } from "./transports/thread-mutation-transport"; -import { createChatRuntimeSettingsTransport } from "./transports/thread-settings-transport"; -import { createChatTurnTransport } from "./transports/turn-transport"; +import { + createChatRuntimeSettingsTransport, + createChatThreadGoalReadTransport, + createChatThreadGoalTransport, + createChatThreadHistoryTransport, + createChatThreadMutationTransport, + createChatThreadResumeTransport, + createChatTurnTransport, +} from "./transports/session-transports"; export interface ChatAppServerGatewayHost { vaultPath: string; diff --git a/src/features/chat/app-server/transports/client-scope.ts b/src/features/chat/app-server/transports/client-scope.ts deleted file mode 100644 index 7f127a53..00000000 --- a/src/features/chat/app-server/transports/client-scope.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { AppServerClient } from "../../../../app-server/connection/client"; - -export interface CurrentChatAppServerClientHost { - currentClient(): AppServerClient | null; -} - -export interface ConnectedChatAppServerClientHost extends CurrentChatAppServerClientHost { - connectedClient(): Promise; -} - -export function chatAppServerClientIsStale(host: CurrentChatAppServerClientHost, client: AppServerClient): boolean { - return host.currentClient() !== client; -} - -export async function withConnectedChatAppServerClient( - host: ConnectedChatAppServerClientHost, - operation: (client: AppServerClient) => Promise, -): Promise { - const client = await host.connectedClient(); - if (!client) return null; - const result = await operation(client); - return chatAppServerClientIsStale(host, client) ? null : result; -} - -export async function withCurrentChatAppServerClient( - host: CurrentChatAppServerClientHost, - operation: (client: AppServerClient) => Promise, -): Promise { - const client = host.currentClient(); - if (!client) return null; - const result = await operation(client); - return chatAppServerClientIsStale(host, client) ? null : result; -} diff --git a/src/features/chat/app-server/transports/goal-transport.ts b/src/features/chat/app-server/transports/goal-transport.ts deleted file mode 100644 index beeaede8..00000000 --- a/src/features/chat/app-server/transports/goal-transport.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { clearThreadGoal, readThreadGoal, recordThreadGoalUserMessage, setThreadGoal } from "../../../../app-server/services/threads"; -import type { ThreadGoalReadTransport, ThreadGoalTransport } from "../../application/threads/goal-transport"; -import type { ConnectedChatAppServerClientHost, CurrentChatAppServerClientHost } from "./client-scope"; -import { chatAppServerClientIsStale, withConnectedChatAppServerClient, withCurrentChatAppServerClient } from "./client-scope"; - -export function createChatThreadGoalReadTransport(host: CurrentChatAppServerClientHost): ThreadGoalReadTransport { - return { - readThreadGoal: (threadId) => readThreadGoalFromCurrentClient(host, threadId), - }; -} - -export function createChatThreadGoalTransport(host: ConnectedChatAppServerClientHost): ThreadGoalTransport { - return { - ensureConnected: async () => (await host.connectedClient()) !== null, - readThreadGoal: (threadId) => readThreadGoalFromCurrentClient(host, threadId), - setThreadGoal: async (threadId, params) => { - const client = await host.connectedClient(); - if (!client) return undefined; - const goal = await setThreadGoal(client, threadId, params); - return chatAppServerClientIsStale(host, client) ? undefined : goal; - }, - clearThreadGoal: async (threadId) => { - const result = await withConnectedChatAppServerClient(host, async (client) => { - await clearThreadGoal(client, threadId); - return true; - }); - return result ?? false; - }, - recordThreadGoalUserMessage: async (threadId, objective) => { - const result = await withCurrentChatAppServerClient(host, async (client) => { - await recordThreadGoalUserMessage(client, threadId, objective); - return true; - }); - return result ?? false; - }, - }; -} - -async function readThreadGoalFromCurrentClient( - host: CurrentChatAppServerClientHost, - threadId: string, -): ReturnType { - const client = host.currentClient(); - if (!client) return undefined; - const goal = await readThreadGoal(client, threadId); - return chatAppServerClientIsStale(host, client) ? undefined : goal; -} diff --git a/src/features/chat/app-server/transports/session-transports.ts b/src/features/chat/app-server/transports/session-transports.ts new file mode 100644 index 00000000..b1a2e5ea --- /dev/null +++ b/src/features/chat/app-server/transports/session-transports.ts @@ -0,0 +1,231 @@ +import type { AppServerClient } from "../../../../app-server/connection/client"; +import type { AppServerRequestClient } from "../../../../app-server/services/request-client"; +import { + clearThreadGoal, + compactThread, + forkThread, + listThreadTurns, + readThreadGoal, + recordThreadGoalUserMessage, + resumeThread, + rollbackThread, + setThreadGoal, + threadActivationSnapshotFromAppServerResponse, + updateThreadSettings, +} from "../../../../app-server/services/threads"; +import { interruptTurn, startTurn, steerTurn } from "../../../../app-server/services/turns"; +import type { RuntimeSettingsPatch } from "../../../../domain/runtime/thread-settings"; +import type { ThreadTurnsPage } from "../../../../domain/threads/history"; +import type { ChatTurnTransport } from "../../application/conversation/turn-transport"; +import type { RuntimeSettingsTransport } from "../../application/runtime/settings-transport"; +import type { ThreadGoalReadTransport, ThreadGoalTransport } from "../../application/threads/goal-transport"; +import type { + ThreadHistoryPage, + ThreadHistoryTransport, + ThreadResumeSnapshot, + ThreadResumeTransport, +} from "../../application/threads/thread-loading-transport"; +import type { ThreadMutationTransport, ThreadRollbackSnapshot } from "../../application/threads/thread-mutation-transport"; +import { messageStreamItemsFromTurns } from "../mappers/message-stream/turn-items"; + +interface CurrentChatAppServerClientHost { + currentClient(): AppServerClient | null; +} + +interface ConnectedChatAppServerClientHost extends CurrentChatAppServerClientHost { + connectedClient(): Promise; +} + +interface ChatAppServerTransportHost extends ConnectedChatAppServerClientHost { + vaultPath: string; +} + +type ChatThreadHistoryClient = AppServerRequestClient; +type ChatThreadResumeClient = AppServerRequestClient; + +interface AppServerThreadTurnsPage { + readonly data: ThreadTurnsPage["turns"]; + readonly nextCursor: string | null; +} + +export function createChatTurnTransport(host: ChatAppServerTransportHost): ChatTurnTransport { + return { + ensureConnected: async () => (await host.connectedClient()) !== null, + startTurn: (request) => + withCurrentChatAppServerClient(host, async (client) => { + const response = await startTurn(client, { + threadId: request.threadId, + cwd: host.vaultPath, + input: request.input, + clientUserMessageId: request.clientUserMessageId, + }); + return { turnId: response.turn.id }; + }), + steerTurn: async (request) => { + const steered = await withCurrentChatAppServerClient(host, async (client) => { + await steerTurn(client, request.threadId, request.turnId, request.input, request.clientUserMessageId); + return true; + }); + return steered ?? false; + }, + interruptTurn: async (threadId, turnId) => { + const interrupted = await withCurrentChatAppServerClient(host, async (client) => { + await interruptTurn(client, threadId, turnId); + return true; + }); + return interrupted ?? false; + }, + }; +} + +export function createChatRuntimeSettingsTransport(host: CurrentChatAppServerClientHost): RuntimeSettingsTransport { + return { + updateThreadSettings: async (threadId: string, update: RuntimeSettingsPatch) => { + const result = await withCurrentChatAppServerClient(host, async (client) => { + await updateThreadSettings(client, threadId, update); + return true; + }); + return result ?? false; + }, + }; +} + +export function createChatThreadHistoryTransport(host: CurrentChatAppServerClientHost): ThreadHistoryTransport { + return { + readHistoryPage: (threadId, cursor, limit): Promise => + withCurrentChatAppServerClient(host, (client) => readChatThreadHistoryPage(client, threadId, cursor, limit)), + }; +} + +export function createChatThreadResumeTransport(host: ChatAppServerTransportHost): ThreadResumeTransport { + return { + resumeThread: (threadId): Promise => + withConnectedChatAppServerClient(host, (client) => resumeChatThread(client, threadId, host.vaultPath)), + }; +} + +export function createChatThreadMutationTransport(host: ChatAppServerTransportHost): ThreadMutationTransport { + return { + compactThread: async (threadId) => { + const result = await withConnectedChatAppServerClient(host, async (client) => { + await compactThread(client, threadId); + return true; + }); + return result ?? false; + }, + forkThread: (threadId) => withConnectedChatAppServerClient(host, (client) => forkThread(client, threadId, host.vaultPath)), + rollbackForkedThread: (threadId, turnsToDrop) => + withConnectedChatAppServerClient(host, async (client) => (await rollbackThread(client, threadId, turnsToDrop)).thread), + rollbackThread: (threadId) => + withConnectedChatAppServerClient(host, async (client): Promise => { + const snapshot = await rollbackThread(client, threadId); + return { + thread: snapshot.thread, + cwd: snapshot.cwd, + items: messageStreamItemsFromTurns(snapshot.turns), + }; + }), + }; +} + +export function createChatThreadGoalReadTransport(host: CurrentChatAppServerClientHost): ThreadGoalReadTransport { + return { + readThreadGoal: (threadId) => readThreadGoalFromCurrentClient(host, threadId), + }; +} + +export function createChatThreadGoalTransport(host: ConnectedChatAppServerClientHost): ThreadGoalTransport { + return { + ensureConnected: async () => (await host.connectedClient()) !== null, + readThreadGoal: (threadId) => readThreadGoalFromCurrentClient(host, threadId), + setThreadGoal: async (threadId, params) => { + const client = await host.connectedClient(); + if (!client) return undefined; + const goal = await setThreadGoal(client, threadId, params); + return chatAppServerClientIsStale(host, client) ? undefined : goal; + }, + clearThreadGoal: async (threadId) => { + const result = await withConnectedChatAppServerClient(host, async (client) => { + await clearThreadGoal(client, threadId); + return true; + }); + return result ?? false; + }, + recordThreadGoalUserMessage: async (threadId, objective) => { + const result = await withCurrentChatAppServerClient(host, async (client) => { + await recordThreadGoalUserMessage(client, threadId, objective); + return true; + }); + return result ?? false; + }, + }; +} + +function chatAppServerClientIsStale(host: CurrentChatAppServerClientHost, client: AppServerClient): boolean { + return host.currentClient() !== client; +} + +async function withConnectedChatAppServerClient( + host: ConnectedChatAppServerClientHost, + operation: (client: AppServerClient) => Promise, +): Promise { + const client = await host.connectedClient(); + if (!client) return null; + const result = await operation(client); + return chatAppServerClientIsStale(host, client) ? null : result; +} + +async function withCurrentChatAppServerClient( + host: CurrentChatAppServerClientHost, + operation: (client: AppServerClient) => Promise, +): Promise { + const client = host.currentClient(); + if (!client) return null; + const result = await operation(client); + return chatAppServerClientIsStale(host, client) ? null : result; +} + +async function readChatThreadHistoryPage( + client: ChatThreadHistoryClient, + threadId: string, + cursor: string | null, + limit = 20, +): Promise { + return chatThreadHistoryPageFromTurnsPage(threadTurnsPageFromAppServerPage(await listThreadTurns(client, threadId, cursor, limit))); +} + +function chatThreadHistoryPageFromTurnsPage(page: ThreadTurnsPage): ThreadHistoryPage { + return { + items: messageStreamItemsFromTurns(page.turns), + nextCursor: page.nextCursor, + hadTurns: page.turns.length > 0, + }; +} + +async function resumeChatThread(client: ChatThreadResumeClient, threadId: string, cwd: string): Promise { + const response = await resumeThread(client, threadId, cwd); + return { + activation: threadActivationSnapshotFromAppServerResponse(response), + rolloutPath: response.thread.path, + initialHistoryPage: response.initialTurnsPage + ? chatThreadHistoryPageFromTurnsPage(threadTurnsPageFromAppServerPage(response.initialTurnsPage)) + : null, + }; +} + +async function readThreadGoalFromCurrentClient( + host: CurrentChatAppServerClientHost, + threadId: string, +): ReturnType { + const client = host.currentClient(); + if (!client) return undefined; + const goal = await readThreadGoal(client, threadId); + return chatAppServerClientIsStale(host, client) ? undefined : goal; +} + +function threadTurnsPageFromAppServerPage(page: AppServerThreadTurnsPage): ThreadTurnsPage { + return { + turns: page.data, + nextCursor: page.nextCursor, + }; +} diff --git a/src/features/chat/app-server/transports/thread-loading-transport.ts b/src/features/chat/app-server/transports/thread-loading-transport.ts deleted file mode 100644 index 337ff0f5..00000000 --- a/src/features/chat/app-server/transports/thread-loading-transport.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { AppServerRequestClient } from "../../../../app-server/services/request-client"; -import { listThreadTurns, resumeThread, threadActivationSnapshotFromAppServerResponse } from "../../../../app-server/services/threads"; -import type { ThreadTurnsPage } from "../../../../domain/threads/history"; -import type { - ThreadHistoryPage, - ThreadHistoryTransport, - ThreadResumeSnapshot, - ThreadResumeTransport, -} from "../../application/threads/thread-loading-transport"; -import { messageStreamItemsFromTurns } from "../mappers/message-stream/turn-items"; -import type { ConnectedChatAppServerClientHost, CurrentChatAppServerClientHost } from "./client-scope"; -import { withConnectedChatAppServerClient, withCurrentChatAppServerClient } from "./client-scope"; - -type ChatThreadHistoryClient = AppServerRequestClient; -type ChatThreadResumeClient = AppServerRequestClient; - -interface AppServerThreadTurnsPage { - readonly data: ThreadTurnsPage["turns"]; - readonly nextCursor: string | null; -} - -interface ChatThreadResumeTransportHost extends ConnectedChatAppServerClientHost { - vaultPath: string; -} - -export function createChatThreadHistoryTransport(host: CurrentChatAppServerClientHost): ThreadHistoryTransport { - return { - readHistoryPage: (threadId, cursor, limit): Promise => - withCurrentChatAppServerClient(host, (client) => readChatThreadHistoryPage(client, threadId, cursor, limit)), - }; -} - -export function createChatThreadResumeTransport(host: ChatThreadResumeTransportHost): ThreadResumeTransport { - return { - resumeThread: (threadId): Promise => - withConnectedChatAppServerClient(host, (client) => resumeChatThread(client, threadId, host.vaultPath)), - }; -} - -async function readChatThreadHistoryPage( - client: ChatThreadHistoryClient, - threadId: string, - cursor: string | null, - limit = 20, -): Promise { - return chatThreadHistoryPageFromTurnsPage(threadTurnsPageFromAppServerPage(await listThreadTurns(client, threadId, cursor, limit))); -} - -function chatThreadHistoryPageFromTurnsPage(page: ThreadTurnsPage): ThreadHistoryPage { - return { - items: messageStreamItemsFromTurns(page.turns), - nextCursor: page.nextCursor, - hadTurns: page.turns.length > 0, - }; -} - -async function resumeChatThread(client: ChatThreadResumeClient, threadId: string, cwd: string): Promise { - const response = await resumeThread(client, threadId, cwd); - return { - activation: threadActivationSnapshotFromAppServerResponse(response), - rolloutPath: response.thread.path, - initialHistoryPage: response.initialTurnsPage - ? chatThreadHistoryPageFromTurnsPage(threadTurnsPageFromAppServerPage(response.initialTurnsPage)) - : null, - }; -} - -function threadTurnsPageFromAppServerPage(page: AppServerThreadTurnsPage): ThreadTurnsPage { - return { - turns: page.data, - nextCursor: page.nextCursor, - }; -} diff --git a/src/features/chat/app-server/transports/thread-mutation-transport.ts b/src/features/chat/app-server/transports/thread-mutation-transport.ts deleted file mode 100644 index 25e95275..00000000 --- a/src/features/chat/app-server/transports/thread-mutation-transport.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { compactThread, forkThread, rollbackThread } from "../../../../app-server/services/threads"; -import type { ThreadMutationTransport, ThreadRollbackSnapshot } from "../../application/threads/thread-mutation-transport"; -import { messageStreamItemsFromTurns } from "../mappers/message-stream/turn-items"; -import type { ConnectedChatAppServerClientHost } from "./client-scope"; -import { withConnectedChatAppServerClient } from "./client-scope"; - -interface ChatThreadMutationTransportHost extends ConnectedChatAppServerClientHost { - vaultPath: string; -} - -export function createChatThreadMutationTransport(host: ChatThreadMutationTransportHost): ThreadMutationTransport { - return { - compactThread: async (threadId) => { - const result = await withConnectedChatAppServerClient(host, async (client) => { - await compactThread(client, threadId); - return true; - }); - return result ?? false; - }, - forkThread: (threadId) => withConnectedChatAppServerClient(host, (client) => forkThread(client, threadId, host.vaultPath)), - rollbackForkedThread: (threadId, turnsToDrop) => - withConnectedChatAppServerClient(host, async (client) => (await rollbackThread(client, threadId, turnsToDrop)).thread), - rollbackThread: (threadId) => - withConnectedChatAppServerClient(host, async (client): Promise => { - const snapshot = await rollbackThread(client, threadId); - return { - thread: snapshot.thread, - cwd: snapshot.cwd, - items: messageStreamItemsFromTurns(snapshot.turns), - }; - }), - }; -} diff --git a/src/features/chat/app-server/transports/thread-settings-transport.ts b/src/features/chat/app-server/transports/thread-settings-transport.ts deleted file mode 100644 index 1f1066a4..00000000 --- a/src/features/chat/app-server/transports/thread-settings-transport.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { updateThreadSettings } from "../../../../app-server/services/threads"; -import type { RuntimeSettingsPatch } from "../../../../domain/runtime/thread-settings"; -import type { RuntimeSettingsTransport } from "../../application/runtime/settings-transport"; -import type { CurrentChatAppServerClientHost } from "./client-scope"; -import { withCurrentChatAppServerClient } from "./client-scope"; - -export function createChatRuntimeSettingsTransport(host: CurrentChatAppServerClientHost): RuntimeSettingsTransport { - return { - updateThreadSettings: async (threadId: string, update: RuntimeSettingsPatch) => { - const result = await withCurrentChatAppServerClient(host, async (client) => { - await updateThreadSettings(client, threadId, update); - return true; - }); - return result ?? false; - }, - }; -} diff --git a/src/features/chat/app-server/transports/turn-transport.ts b/src/features/chat/app-server/transports/turn-transport.ts deleted file mode 100644 index 065cce0a..00000000 --- a/src/features/chat/app-server/transports/turn-transport.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { interruptTurn, startTurn, steerTurn } from "../../../../app-server/services/turns"; -import type { ChatTurnTransport } from "../../application/conversation/turn-transport"; -import type { ConnectedChatAppServerClientHost } from "./client-scope"; -import { withCurrentChatAppServerClient } from "./client-scope"; - -interface ChatTurnTransportHost extends ConnectedChatAppServerClientHost { - vaultPath: string; -} - -export function createChatTurnTransport(host: ChatTurnTransportHost): ChatTurnTransport { - return { - ensureConnected: async () => (await host.connectedClient()) !== null, - startTurn: (request) => - withCurrentChatAppServerClient(host, async (client) => { - const response = await startTurn(client, { - threadId: request.threadId, - cwd: host.vaultPath, - input: request.input, - clientUserMessageId: request.clientUserMessageId, - }); - return { turnId: response.turn.id }; - }), - steerTurn: async (request) => { - const steered = await withCurrentChatAppServerClient(host, async (client) => { - await steerTurn(client, request.threadId, request.turnId, request.input, request.clientUserMessageId); - return true; - }); - return steered ?? false; - }, - interruptTurn: async (threadId, turnId) => { - const interrupted = await withCurrentChatAppServerClient(host, async (client) => { - await interruptTurn(client, threadId, turnId); - return true; - }); - return interrupted ?? false; - }, - }; -} diff --git a/src/features/chat/application/threads/lifecycle-parts.ts b/src/features/chat/application/threads/lifecycle-parts.ts deleted file mode 100644 index 08c7d12a..00000000 --- a/src/features/chat/application/threads/lifecycle-parts.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { ThreadTokenUsage } from "../../../../domain/runtime/metrics"; -import type { ChatStateStore } from "../state/store"; -import { createActiveThreadIdentitySync } from "./active-thread-identity-sync"; -import type { GoalActions } from "./goal-actions"; -import type { HistoryController } from "./history-controller"; -import { RestorationController } from "./restoration-controller"; -import { createResumeActions, type ResumeActions } from "./resume-actions"; -import type { ChatResumeWorkTracker } from "./resume-work"; -import type { ThreadResumeTransport } from "./thread-loading-transport"; - -export interface ThreadLifecyclePartsContext { - stateStore: ChatStateStore; - resumeTransport: ThreadResumeTransport; - lifecycle: { - resumeWork: ChatResumeWorkTracker; - history: HistoryController; - invalidateThreadWork: () => void; - getClosing: () => boolean; - recoverTokenUsageFromRollout?: (path: string) => Promise; - }; - thread: { - notifyIdentityChanged: () => void; - refreshTabHeader: () => void; - }; - status: { - set: (status: string) => void; - addSystemMessage: (text: string) => void; - }; - liveState: { - refresh: () => void; - }; - goals: GoalActions; - resetThreadTurnPresence: (hadTurns: boolean) => void; -} - -export interface ThreadLifecycleParts { - history: HistoryController; - restoration: RestorationController; - resume: ResumeActions; - identity: ReturnType; -} - -export function createThreadLifecycleParts(context: ThreadLifecyclePartsContext): ThreadLifecycleParts { - const { stateStore, resumeTransport, lifecycle, thread, status, liveState, goals, resetThreadTurnPresence } = context; - const { resumeWork, history, invalidateThreadWork } = lifecycle; - const restoration = new RestorationController({ - invalidateThreadWork, - setStatus: status.set, - refreshTabHeader: thread.refreshTabHeader, - }); - const resume = createResumeActions({ - stateStore, - resumeWork, - history, - restoration, - resumeTransport, - closing: lifecycle.getClosing, - resetThreadTurnPresence, - notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged, - addSystemMessage: status.addSystemMessage, - refreshLiveState: liveState.refresh, - syncThreadGoal: (threadId) => goals.syncThreadGoal(threadId), - ...(lifecycle.recoverTokenUsageFromRollout ? { recoverTokenUsageFromRollout: lifecycle.recoverTokenUsageFromRollout } : {}), - }); - const identity = createActiveThreadIdentitySync({ - stateStore, - restoration, - invalidateThreadWork, - resetThreadTurnPresence, - notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged, - refreshTabHeader: thread.refreshTabHeader, - }); - - return { - history, - restoration, - resume, - identity, - }; -} diff --git a/src/features/chat/domain/runtime/collaboration-resolution.ts b/src/features/chat/domain/runtime/collaboration-resolution.ts deleted file mode 100644 index b049e12c..00000000 --- a/src/features/chat/domain/runtime/collaboration-resolution.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { effectiveCollaborationMode } from "./intent"; -import type { RuntimeSnapshot } from "./snapshot"; - -export interface CollaborationModeResolution { - readonly active: RuntimeSnapshot["active"]["collaborationMode"]; - readonly pending: RuntimeSnapshot["pending"]["collaborationMode"]; - readonly confirmed: NonNullable; - readonly effective: NonNullable; - readonly dirty: boolean; - readonly blockedReason: "missing-model" | null; -} - -export function resolveCollaborationMode(snapshot: RuntimeSnapshot, model: string | null): CollaborationModeResolution { - const active = snapshot.active.collaborationMode; - const pending = snapshot.pending.collaborationMode; - const confirmed = effectiveCollaborationMode(active); - const effective = pending.kind === "set" ? pending.value : confirmed; - const dirty = pending.kind === "set"; - return { - active, - pending, - confirmed, - effective, - dirty, - blockedReason: dirty && !model ? "missing-model" : null, - }; -} diff --git a/src/features/chat/domain/runtime/permission-resolution.ts b/src/features/chat/domain/runtime/permission-resolution.ts deleted file mode 100644 index 6007bccf..00000000 --- a/src/features/chat/domain/runtime/permission-resolution.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { RuntimeConfigSnapshot } from "../../../../domain/runtime/config"; -import { cloneRuntimePermissionState, type RuntimeApprovalPolicy, type RuntimeSandboxPolicy } from "../../../../domain/runtime/permissions"; -import { type PendingRuntimeIntent, resetRuntimeIntentToConfig, setRuntimeIntentValue, unchangedRuntimeIntent } from "./intent"; -import { type RuntimeLayeredValue, resolveRuntimeNullablePendingValue, resolveRuntimeValue } from "./layered-value"; -import type { RuntimeSnapshot } from "./snapshot"; - -export interface RuntimePermissionsResolution { - readonly permissionProfile: RuntimeLayeredValue; - readonly sandboxPolicy: RuntimeLayeredValue; - readonly approvalPolicy: RuntimeLayeredValue; -} - -export function resolveRuntimePermissions(snapshot: RuntimeSnapshot, config: RuntimeConfigSnapshot): RuntimePermissionsResolution { - return { - permissionProfile: resolveRuntimeValue({ - configured: config.startupPermissions.activePermissionProfile?.id ?? null, - active: snapshot.active.activePermissionProfile?.id ?? null, - pending: snapshot.pending.permissionProfile, - activeKnown: snapshot.activeThreadId !== null && snapshot.active.permissionProfileKnown, - }), - sandboxPolicy: resolveRuntimeSandboxPolicy(snapshot, config), - approvalPolicy: resolveRuntimeValue({ - configured: config.startupPermissions.approvalPolicy, - active: snapshot.active.approvalPolicy, - pending: snapshot.pending.approvalPolicy, - activeKnown: snapshot.activeThreadId !== null && snapshot.active.approvalPolicyKnown, - }), - }; -} - -function resolveRuntimeSandboxPolicy( - snapshot: RuntimeSnapshot, - config: RuntimeConfigSnapshot, -): RuntimeLayeredValue { - return resolveRuntimeNullablePendingValue({ - configured: cloneRuntimeSandboxPolicy(config.startupPermissions.sandboxPolicy), - active: cloneRuntimeSandboxPolicy(snapshot.active.sandboxPolicy), - pending: sandboxPolicyIntentFromPermissionProfile(snapshot.pending.permissionProfile, config), - activeKnown: snapshot.activeThreadId !== null && snapshot.active.sandboxPolicyKnown, - }); -} - -function sandboxPolicyIntentFromPermissionProfile( - intent: PendingRuntimeIntent, - config: RuntimeConfigSnapshot, -): PendingRuntimeIntent { - if (intent.kind === "set") return setRuntimeIntentValue(sandboxPolicyForPermissionProfile(intent.value, config)); - if (intent.kind === "resetToConfig") return resetRuntimeIntentToConfig(); - return unchangedRuntimeIntent(); -} - -function sandboxPolicyForPermissionProfile(profile: string, config: RuntimeConfigSnapshot): RuntimeSandboxPolicy | null { - return profile === config.startupPermissions.activePermissionProfile?.id - ? cloneRuntimeSandboxPolicy(config.startupPermissions.sandboxPolicy) - : null; -} - -function cloneRuntimeSandboxPolicy(value: RuntimeSandboxPolicy | null): RuntimeSandboxPolicy | null { - return cloneRuntimePermissionState({ approvalPolicy: null, activePermissionProfile: null, sandboxPolicy: value }).sandboxPolicy; -} diff --git a/src/features/chat/domain/runtime/resolution.ts b/src/features/chat/domain/runtime/resolution.ts index 57edd96d..3994f732 100644 --- a/src/features/chat/domain/runtime/resolution.ts +++ b/src/features/chat/domain/runtime/resolution.ts @@ -1,14 +1,58 @@ -import type { ReasoningEffort } from "../../../../domain/catalog/metadata"; +import type { ModelMetadata, ReasoningEffort } from "../../../../domain/catalog/metadata"; import { findModelMetadataByIdOrName, supportedEffortsForModelMetadata } from "../../../../domain/catalog/metadata"; import type { RuntimeConfigSnapshot } from "../../../../domain/runtime/config"; -import type { RuntimeApprovalPolicy, RuntimeSandboxPolicy } from "../../../../domain/runtime/permissions"; +import { cloneRuntimePermissionState, type RuntimeApprovalPolicy, type RuntimeSandboxPolicy } from "../../../../domain/runtime/permissions"; import type { ApprovalsReviewer, ServiceTier } from "../../../../domain/runtime/policy"; -import { type CollaborationModeResolution, resolveCollaborationMode } from "./collaboration-resolution"; -import { type RuntimeLayeredValue, resolveRuntimeValue, runtimeLayeredValue } from "./layered-value"; -import { resolveRuntimePermissions } from "./permission-resolution"; -import { type AutoReviewResolution, type FastModeResolution, resolveAutoReview, resolveFastMode } from "./runtime-toggles"; +import { + effectiveCollaborationMode, + type PendingRuntimeIntent, + type RequestedFastMode, + resetRuntimeIntentToConfig, + setRuntimeIntentValue, + unchangedRuntimeIntent, +} from "./intent"; +import { + type RuntimeLayeredValue, + type RuntimeValueSource, + resolveRuntimeNullablePendingValue, + resolveRuntimeValue, + runtimeLayeredValue, +} from "./layered-value"; import type { RuntimeSnapshot } from "./snapshot"; +interface AutoReviewResolution { + readonly active: boolean; + readonly confirmedActive: boolean; + readonly source: RuntimeValueSource; + readonly confirmedSource: RuntimeValueSource; +} + +interface CollaborationModeResolution { + readonly active: RuntimeSnapshot["active"]["collaborationMode"]; + readonly pending: RuntimeSnapshot["pending"]["collaborationMode"]; + readonly confirmed: NonNullable; + readonly effective: NonNullable; + readonly dirty: boolean; + readonly blockedReason: "missing-model" | null; +} + +interface FastModeResolution { + readonly requested: PendingRuntimeIntent; + readonly active: boolean; + readonly confirmedActive: boolean; + readonly source: RuntimeValueSource; + readonly confirmedSource: RuntimeValueSource; + readonly effectiveServiceTier: ServiceTier | null; + readonly confirmedServiceTier: ServiceTier | null; + readonly serviceTierRequestValue: string; +} + +interface RuntimePermissionsResolution { + readonly permissionProfile: RuntimeLayeredValue; + readonly sandboxPolicy: RuntimeLayeredValue; + readonly approvalPolicy: RuntimeLayeredValue; +} + export interface RuntimeControlsResolution { readonly model: RuntimeLayeredValue; readonly reasoningEffort: RuntimeLayeredValue; @@ -95,3 +139,105 @@ function serviceTierValue( source, }); } + +function resolveAutoReview(reviewer: RuntimeLayeredValue): AutoReviewResolution { + return { + active: autoReviewActive(reviewer.effective), + confirmedActive: autoReviewActive(reviewer.confirmed), + source: reviewer.source, + confirmedSource: reviewer.confirmedSource, + }; +} + +function resolveFastMode( + requested: PendingRuntimeIntent, + serviceTier: RuntimeLayeredValue, + serviceTiers: ModelMetadata["serviceTiers"], +): FastModeResolution { + return { + requested, + active: isFastServiceTier(serviceTier.effective, serviceTiers), + confirmedActive: isFastServiceTier(serviceTier.confirmed, serviceTiers), + source: serviceTier.source, + confirmedSource: serviceTier.confirmedSource, + effectiveServiceTier: serviceTier.effective, + confirmedServiceTier: serviceTier.confirmed, + serviceTierRequestValue: serviceTiers.find((tier) => tier.name.trim().toLowerCase() === "fast")?.id ?? "fast", + }; +} + +function autoReviewActive(value: ApprovalsReviewer | null): boolean { + return value === "auto_review" || value === "guardian_subagent"; +} + +function isFastServiceTier(value: string | null | undefined, serviceTiers: ModelMetadata["serviceTiers"]): boolean { + if (!value) return false; + if (value === "fast") return true; + if (serviceTiers.length === 0) return value === "priority"; + return serviceTiers.some((tier) => tier.id === value && tier.name.trim().toLowerCase() === "fast"); +} + +function resolveCollaborationMode(snapshot: RuntimeSnapshot, model: string | null): CollaborationModeResolution { + const active = snapshot.active.collaborationMode; + const pending = snapshot.pending.collaborationMode; + const confirmed = effectiveCollaborationMode(active); + const effective = pending.kind === "set" ? pending.value : confirmed; + const dirty = pending.kind === "set"; + return { + active, + pending, + confirmed, + effective, + dirty, + blockedReason: dirty && !model ? "missing-model" : null, + }; +} + +function resolveRuntimePermissions(snapshot: RuntimeSnapshot, config: RuntimeConfigSnapshot): RuntimePermissionsResolution { + return { + permissionProfile: resolveRuntimeValue({ + configured: config.startupPermissions.activePermissionProfile?.id ?? null, + active: snapshot.active.activePermissionProfile?.id ?? null, + pending: snapshot.pending.permissionProfile, + activeKnown: snapshot.activeThreadId !== null && snapshot.active.permissionProfileKnown, + }), + sandboxPolicy: resolveRuntimeSandboxPolicy(snapshot, config), + approvalPolicy: resolveRuntimeValue({ + configured: config.startupPermissions.approvalPolicy, + active: snapshot.active.approvalPolicy, + pending: snapshot.pending.approvalPolicy, + activeKnown: snapshot.activeThreadId !== null && snapshot.active.approvalPolicyKnown, + }), + }; +} + +function resolveRuntimeSandboxPolicy( + snapshot: RuntimeSnapshot, + config: RuntimeConfigSnapshot, +): RuntimeLayeredValue { + return resolveRuntimeNullablePendingValue({ + configured: cloneRuntimeSandboxPolicy(config.startupPermissions.sandboxPolicy), + active: cloneRuntimeSandboxPolicy(snapshot.active.sandboxPolicy), + pending: sandboxPolicyIntentFromPermissionProfile(snapshot.pending.permissionProfile, config), + activeKnown: snapshot.activeThreadId !== null && snapshot.active.sandboxPolicyKnown, + }); +} + +function sandboxPolicyIntentFromPermissionProfile( + intent: PendingRuntimeIntent, + config: RuntimeConfigSnapshot, +): PendingRuntimeIntent { + if (intent.kind === "set") return setRuntimeIntentValue(sandboxPolicyForPermissionProfile(intent.value, config)); + if (intent.kind === "resetToConfig") return resetRuntimeIntentToConfig(); + return unchangedRuntimeIntent(); +} + +function sandboxPolicyForPermissionProfile(profile: string, config: RuntimeConfigSnapshot): RuntimeSandboxPolicy | null { + return profile === config.startupPermissions.activePermissionProfile?.id + ? cloneRuntimeSandboxPolicy(config.startupPermissions.sandboxPolicy) + : null; +} + +function cloneRuntimeSandboxPolicy(value: RuntimeSandboxPolicy | null): RuntimeSandboxPolicy | null { + return cloneRuntimePermissionState({ approvalPolicy: null, activePermissionProfile: null, sandboxPolicy: value }).sandboxPolicy; +} diff --git a/src/features/chat/domain/runtime/runtime-toggles.ts b/src/features/chat/domain/runtime/runtime-toggles.ts deleted file mode 100644 index 1033c1e5..00000000 --- a/src/features/chat/domain/runtime/runtime-toggles.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { ModelMetadata } from "../../../../domain/catalog/metadata"; -import type { ApprovalsReviewer, ServiceTier } from "../../../../domain/runtime/policy"; -import type { PendingRuntimeIntent, RequestedFastMode } from "./intent"; -import type { RuntimeLayeredValue, RuntimeValueSource } from "./layered-value"; - -export interface FastModeResolution { - readonly requested: PendingRuntimeIntent; - readonly active: boolean; - readonly confirmedActive: boolean; - readonly source: RuntimeValueSource; - readonly confirmedSource: RuntimeValueSource; - readonly effectiveServiceTier: ServiceTier | null; - readonly confirmedServiceTier: ServiceTier | null; - readonly serviceTierRequestValue: string; -} - -export interface AutoReviewResolution { - readonly active: boolean; - readonly confirmedActive: boolean; - readonly source: RuntimeValueSource; - readonly confirmedSource: RuntimeValueSource; -} - -export function resolveAutoReview(reviewer: RuntimeLayeredValue): AutoReviewResolution { - return { - active: autoReviewActive(reviewer.effective), - confirmedActive: autoReviewActive(reviewer.confirmed), - source: reviewer.source, - confirmedSource: reviewer.confirmedSource, - }; -} - -export function resolveFastMode( - requested: PendingRuntimeIntent, - serviceTier: RuntimeLayeredValue, - serviceTiers: ModelMetadata["serviceTiers"], -): FastModeResolution { - return { - requested, - active: isFastServiceTier(serviceTier.effective, serviceTiers), - confirmedActive: isFastServiceTier(serviceTier.confirmed, serviceTiers), - source: serviceTier.source, - confirmedSource: serviceTier.confirmedSource, - effectiveServiceTier: serviceTier.effective, - confirmedServiceTier: serviceTier.confirmed, - serviceTierRequestValue: serviceTiers.find((tier) => tier.name.trim().toLowerCase() === "fast")?.id ?? "fast", - }; -} - -function autoReviewActive(value: ApprovalsReviewer | null): boolean { - return value === "auto_review" || value === "guardian_subagent"; -} - -function isFastServiceTier(value: string | null | undefined, serviceTiers: ModelMetadata["serviceTiers"]): boolean { - if (!value) return false; - if (value === "fast") return true; - if (serviceTiers.length === 0) return value === "priority"; - return serviceTiers.some((tier) => tier.id === value && tier.name.trim().toLowerCase() === "fast"); -} diff --git a/src/features/chat/host/bundles/reconnect-bundle.ts b/src/features/chat/host/bundles/reconnect-bundle.ts deleted file mode 100644 index a88d8210..00000000 --- a/src/features/chat/host/bundles/reconnect-bundle.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { ConnectionManager } from "../../../../app-server/connection/connection-manager"; -import type { ConnectionWorkTracker } from "../../application/connection/connection-work"; -import { type ChatReconnectActionsHost, reconnectPanel } from "../../application/connection/reconnect-actions"; -import type { ChatConnectionPhase } from "../../application/state/root-reducer"; -import type { ChatStateStore } from "../../application/state/store"; -import type { ChatViewDeferredTasks } from "../session/deferred-work"; - -interface ChatPanelReconnectStatus { - set: (statusText: string, phase?: ChatConnectionPhase) => void; - addSystemMessage: (text: string) => void; -} - -interface ChatPanelReconnectHost { - stateStore: ChatStateStore; - connectionWork: ConnectionWorkTracker; - deferredTasks: ChatViewDeferredTasks; -} - -interface ChatPanelReconnectInput { - connection: ConnectionManager; - ensureConnected: () => Promise; - invalidateThreadWork: () => void; - resumeThread: (threadId: string) => Promise; - status: ChatPanelReconnectStatus; -} - -export function createReconnectAction(host: ChatPanelReconnectHost, input: ChatPanelReconnectInput): () => Promise { - const reconnectHost: ChatReconnectActionsHost = { - stateStore: host.stateStore, - invalidateConnectionWork: () => { - host.connectionWork.invalidate(); - }, - invalidateThreadWork: () => { - input.invalidateThreadWork(); - }, - clearDeferredDiagnostics: () => { - host.deferredTasks.clearDiagnostics(); - }, - resetConnection: () => { - input.connection.resetConnection(); - }, - setStatus: (statusText, phase) => { - input.status.set(statusText, phase); - }, - ensureConnected: input.ensureConnected, - resumeThread: input.resumeThread, - addSystemMessage: (text) => { - input.status.addSystemMessage(text); - }, - }; - - return () => reconnectPanel(reconnectHost); -} diff --git a/src/features/chat/host/bundles/thread-bundle.ts b/src/features/chat/host/bundles/thread-bundle.ts index 18d82bf2..3bc90094 100644 --- a/src/features/chat/host/bundles/thread-bundle.ts +++ b/src/features/chat/host/bundles/thread-bundle.ts @@ -9,18 +9,17 @@ import type { ChatAppServerGateway } from "../../app-server/session-gateway"; import type { LocalIdSource } from "../../application/local-id-source"; import { messageStreamItems } from "../../application/state/message-stream"; import type { ChatStateStore } from "../../application/state/store"; -import type { ActiveThreadIdentitySync } from "../../application/threads/active-thread-identity-sync"; +import { type ActiveThreadIdentitySync, createActiveThreadIdentitySync } from "../../application/threads/active-thread-identity-sync"; import { type AutoTitleCoordinator, createAutoTitleCoordinator } from "../../application/threads/auto-title-coordinator"; import { createGoalActions, createThreadGoalSyncActions } from "../../application/threads/goal-actions"; import { HistoryController } from "../../application/threads/history-controller"; -import { createThreadLifecycleParts } from "../../application/threads/lifecycle-parts"; import { activeThreadRenameTitleContext, createThreadRenameEditorActions, type ThreadRenameEditorActions, } from "../../application/threads/rename-editor-actions"; -import type { RestorationController } from "../../application/threads/restoration-controller"; -import type { ResumeActions } from "../../application/threads/resume-actions"; +import { RestorationController } from "../../application/threads/restoration-controller"; +import { createResumeActions, type ResumeActions } from "../../application/threads/resume-actions"; import type { ChatResumeWorkTracker } from "../../application/threads/resume-work"; import { createThreadManagementActions, type ThreadManagementActionsHost } from "../../application/threads/thread-management-actions"; import { createThreadNavigationActions } from "../../application/threads/thread-navigation-actions"; @@ -31,10 +30,16 @@ import type { ChatPanelEnvironment } from "../contracts"; type ChatPanelGoalSyncActions = ReturnType; export type ChatPanelGoalActions = ReturnType; -export type ChatPanelThreadLifecycle = ReturnType; export type ChatPanelThreadActions = ReturnType; export type ChatPanelThreadNavigationActions = ReturnType; +export interface ChatPanelThreadLifecycle { + history: HistoryController; + restoration: RestorationController; + resume: ResumeActions; + identity: ActiveThreadIdentitySync; +} + interface ChatPanelThreadStatus { set: (statusText: string) => void; addSystemMessage: (text: string) => void; @@ -378,28 +383,42 @@ function createSessionThreadLifecycle( refreshLiveState, notifyActiveThreadIdentityChanged, } = input; - return createThreadLifecycleParts({ + const restoration = new RestorationController({ + invalidateThreadWork, + setStatus: status.set, + refreshTabHeader, + }); + const resetThreadTurnPresence = (hadTurns: boolean) => { + autoTitleCoordinator.resetThreadTurnPresence(hadTurns); + }; + const resume = createResumeActions({ stateStore: host.stateStore, resumeTransport: appServer.threadResume, - lifecycle: { - resumeWork: host.resumeWork, - history, - invalidateThreadWork, - getClosing: host.getClosing, - recoverTokenUsageFromRollout: (path) => - recoverRolloutTokenUsage(path, (filePath, options) => appServer.readFileBase64(filePath, options)), - }, - thread: { - notifyIdentityChanged: notifyActiveThreadIdentityChanged, - refreshTabHeader, - }, - status, - liveState: { - refresh: refreshLiveState, - }, - goals, - resetThreadTurnPresence: (hadTurns) => { - autoTitleCoordinator.resetThreadTurnPresence(hadTurns); - }, + resumeWork: host.resumeWork, + history, + restoration, + closing: host.getClosing, + resetThreadTurnPresence, + notifyActiveThreadIdentityChanged, + addSystemMessage: status.addSystemMessage, + refreshLiveState, + syncThreadGoal: (threadId) => goals.syncThreadGoal(threadId), + recoverTokenUsageFromRollout: (path) => + recoverRolloutTokenUsage(path, (filePath, options) => appServer.readFileBase64(filePath, options)), }); + const identity = createActiveThreadIdentitySync({ + stateStore: host.stateStore, + restoration, + invalidateThreadWork, + resetThreadTurnPresence, + notifyActiveThreadIdentityChanged, + refreshTabHeader, + }); + + return { + history, + restoration, + resume, + identity, + }; } diff --git a/src/features/chat/host/session-graph.ts b/src/features/chat/host/session-graph.ts index df777923..16de53c0 100644 --- a/src/features/chat/host/session-graph.ts +++ b/src/features/chat/host/session-graph.ts @@ -2,6 +2,7 @@ import { ConnectionManager } from "../../../app-server/connection/connection-man import { isStaleAppServerSharedQueryContextError } from "../../../app-server/query/shared-queries"; import { createChatAppServerGateway } from "../app-server/session-gateway"; import type { ConnectionWorkTracker } from "../application/connection/connection-work"; +import { reconnectPanel } from "../application/connection/reconnect-actions"; import { createLocalIdSource, type LocalIdSource } from "../application/local-id-source"; import type { ChatAction, ChatConnectionPhase } from "../application/state/root-reducer"; import type { ChatStateStore } from "../application/state/store"; @@ -15,7 +16,6 @@ import type { ChatComposerController } from "../panel/composer-controller"; import type { ChatMessageStreamScrollBinding } from "../panel/message-stream-scroll-binding"; import { createChatComposerController } from "./bundles/composer-bundle"; import { type ChatPanelConnectionBundle, createConnectionBundle } from "./bundles/connection-bundle"; -import { createReconnectAction } from "./bundles/reconnect-bundle"; import { createRuntimeBundle } from "./bundles/runtime-bundle"; import { type ChatPanelShellBundle, createShellBundle } from "./bundles/shell-bundle"; import { createThreadActionBundle, createThreadFoundation, createThreadLifecycleBundle } from "./bundles/thread-bundle"; @@ -164,15 +164,30 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch refreshActiveThreads, notifyActiveThreadIdentityChanged, }); - const reconnect = createReconnectAction(host, { - connection, - ensureConnected, - invalidateThreadWork: () => { - threadFoundation.invalidateThreadWork(); - }, - resumeThread: (threadId) => threadLifecycle.resume.resumeThread(threadId), - status, - }); + const reconnect = () => + reconnectPanel({ + stateStore, + invalidateConnectionWork: () => { + host.connectionWork.invalidate(); + }, + invalidateThreadWork: () => { + threadFoundation.invalidateThreadWork(); + }, + clearDeferredDiagnostics: () => { + host.deferredTasks.clearDiagnostics(); + }, + resetConnection: () => { + connection.resetConnection(); + }, + setStatus: (statusText, phase) => { + status.set(statusText, phase); + }, + ensureConnected, + resumeThread: (threadId) => threadLifecycle.resume.resumeThread(threadId), + addSystemMessage: (text) => { + status.addSystemMessage(text); + }, + }); const turn = createTurnBundle(host, { localItemIds, appServer, diff --git a/src/features/chat/host/session/shared-state-binding.ts b/src/features/chat/host/session/shared-state-binding.ts index 3e3f65fb..6da3c857 100644 --- a/src/features/chat/host/session/shared-state-binding.ts +++ b/src/features/chat/host/session/shared-state-binding.ts @@ -1,5 +1,4 @@ import type { ObservedResult } from "../../../../app-server/query/observed-result"; -import { observedValue } from "../../../../app-server/query/observed-result"; import type { ModelMetadata } from "../../../../domain/catalog/metadata"; import type { SharedServerMetadata } from "../../../../domain/server/metadata"; import type { Thread } from "../../../../domain/threads/model"; @@ -45,21 +44,21 @@ export function createChatPanelSharedStateBinding(options: ChatPanelSharedStateB refreshTabHeader(); }; const receiveThreadResult = (result: ObservedResult): void => { - const observedThreads = observedValue(result); + const observedThreads = result.value; if (observedThreads) receiveThreads(observedThreads); }; const receiveAppServerMetadata = (metadata: SharedServerMetadata): void => { serverActions.metadata.applyAppServerMetadata(metadata); }; const receiveAppServerMetadataResult = (result: ObservedResult): void => { - const observedMetadata = observedValue(result); + const observedMetadata = result.value; if (observedMetadata) receiveAppServerMetadata(observedMetadata); }; const receiveModels = (models: readonly ModelMetadata[]): void => { stateStore.dispatch({ type: "connection/metadata-applied", availableModels: models }); }; const receiveModelsResult = (result: ObservedResult): void => { - const observedModels = observedValue(result); + const observedModels = result.value; if (observedModels) receiveModels(observedModels); }; const unsubscribe = (): void => { diff --git a/src/features/chat/panel/surface/message-stream-presenter.ts b/src/features/chat/panel/surface/message-stream-presenter.ts index d5a6b171..d2ad3d43 100644 --- a/src/features/chat/panel/surface/message-stream-presenter.ts +++ b/src/features/chat/panel/surface/message-stream-presenter.ts @@ -10,11 +10,7 @@ import type { MessageStreamScrollPortBinding } from "../../ui/message-stream/flo import { MarkdownMessageRenderer, renderStreamMarkdown } from "../../ui/message-stream/markdown-renderer.obsidian"; import { MessageStreamViewport, type MessageStreamViewportState } from "../../ui/message-stream/stream-blocks"; import type { ChatPanelMessageStreamReadModel } from "../shell-read-model"; -import { - type ChatMessageStreamSurfaceContext, - createMessageStreamSurfaceContext, - messageStreamSurfaceProjectionFromModel, -} from "./message-stream-projection"; +import { type ChatMessageStreamSurfaceContext, messageStreamSurfaceProjectionFromModel } from "./message-stream-projection"; export interface ChatPanelMessageStreamPresenter { renderState(model: ChatPanelMessageStreamReadModel): MessageStreamViewportState; @@ -79,6 +75,7 @@ export interface MessageStreamPresenterOptions { export class MessageStreamPresenter { private readonly obsidianMarkdownRenderer: MarkdownMessageRenderer; + private readonly surfaceContext: ChatMessageStreamSurfaceContext; constructor(private readonly options: MessageStreamPresenterOptions) { this.obsidianMarkdownRenderer = new MarkdownMessageRenderer({ @@ -86,18 +83,34 @@ export class MessageStreamPresenter { owner: options.obsidian.owner, vaultPath: options.workspace.vaultPath, }); - } - - private dispatch(action: ChatAction): void { - this.options.state.store.dispatch(action); + this.surfaceContext = { + vaultPath: options.workspace.vaultPath, + setDisclosureOpen: (bucket, id, open) => { + this.dispatch({ type: "ui/disclosure-set", bucket, id, open }); + }, + setForkMenuItem: (itemId) => { + this.dispatch({ type: "ui/message-fork-menu-set", itemId }); + }, + loadOlderTurns: () => { + options.history.loadOlderTurns(); + }, + renderObsidianMarkdown: (element, text) => { + this.obsidianMarkdownRenderer.renderObsidianMarkdown(element, text); + }, + renderStreamMarkdown: (element, text) => { + renderStreamMarkdown(element, text, { + app: options.obsidian.app, + vaultPath: options.workspace.vaultPath, + }); + }, + copyMessageText: (text) => void this.copyMessageText(text), + actions: options.actions, + requests: options.requests, + }; } renderState(model: ChatPanelMessageStreamReadModel): MessageStreamViewportState { - return this.renderStateFor(model); - } - - private renderStateFor(model: ChatPanelMessageStreamReadModel): MessageStreamViewportState { - const projection = messageStreamSurfaceProjectionFromModel(model, this.messageStreamSurfaceContext()); + const projection = messageStreamSurfaceProjectionFromModel(model, this.surfaceContext); return { blocks: projection.blocks, @@ -106,28 +119,8 @@ export class MessageStreamPresenter { }; } - private messageStreamSurfaceContext(): ChatMessageStreamSurfaceContext { - return createMessageStreamSurfaceContext({ - vaultPath: this.options.workspace.vaultPath, - dispatch: (action) => { - this.dispatch(action); - }, - loadOlderTurns: () => { - this.options.history.loadOlderTurns(); - }, - renderObsidianMarkdown: (element, text) => { - this.obsidianMarkdownRenderer.renderObsidianMarkdown(element, text); - }, - renderStreamMarkdown: (element, text) => { - renderStreamMarkdown(element, text, { - app: this.options.obsidian.app, - vaultPath: this.options.workspace.vaultPath, - }); - }, - copyMessageText: (text) => void this.copyMessageText(text), - actions: this.options.actions, - requests: this.options.requests, - }); + private dispatch(action: ChatAction): void { + this.options.state.store.dispatch(action); } dispose(): void { diff --git a/src/features/chat/panel/surface/message-stream-projection.ts b/src/features/chat/panel/surface/message-stream-projection.ts index 59c76c0a..64eae63e 100644 --- a/src/features/chat/panel/surface/message-stream-projection.ts +++ b/src/features/chat/panel/surface/message-stream-projection.ts @@ -1,14 +1,14 @@ import type { TurnDiffViewState } from "../../../turn-diff/model"; -import type { PendingRequestBlockActions } from "../../application/pending-requests/block"; +import { type PendingRequestBlockActions, pendingRequestBlockStateFromRequestState } from "../../application/pending-requests/block"; +import type { ChatRequestState } from "../../application/pending-requests/state"; import type { MessageStreamRollbackCandidate } from "../../application/state/message-stream"; -import type { ChatAction } from "../../application/state/root-reducer"; import { type ForkCandidate, messageStreamSegmentsEmpty, type PlanImplementationTarget } from "../../domain/message-stream/selectors"; +import { pendingRequestsSignature } from "../../domain/pending-requests/signatures"; import type { MessageStreamTextActionTargets } from "../../presentation/message-stream/text-view"; import { type MessageStreamViewBlock, messageStreamViewBlocks } from "../../presentation/message-stream/view-model"; -import type { PendingRequestBlockSnapshot } from "../../presentation/pending-requests/view-model"; +import { type PendingRequestBlockSnapshot, pendingRequestBlockSnapshotFromState } from "../../presentation/pending-requests/view-model"; import type { MessageStreamContext, MessageStreamDisclosureBucket, MessageStreamDisclosureState } from "../../ui/message-stream/context"; import type { ChatPanelMessageStreamReadModel } from "../shell-read-model"; -import { pendingRequestSurfaceProjectionFromState } from "./pending-request-block-projection"; interface ChatMessageStreamActions { rollbackThread: (threadId: string) => void; @@ -34,17 +34,6 @@ export interface ChatMessageStreamSurfaceContext { requests: ChatMessageStreamRequests; } -export interface MessageStreamSurfaceContextOptions { - vaultPath: string; - dispatch: (action: ChatAction) => void; - loadOlderTurns: () => void; - renderObsidianMarkdown: (element: HTMLElement, text: string) => void; - renderStreamMarkdown: (element: HTMLElement, text: string) => void; - copyMessageText: (text: string) => void; - actions: ChatMessageStreamActions; - requests: ChatMessageStreamRequests; -} - interface MessageStreamStateProjection { activeThreadId: string | null; workspaceRoot: string; @@ -54,29 +43,16 @@ interface MessageStreamStateProjection { viewBlocks: readonly MessageStreamViewBlock[]; } +interface PendingRequestSurfaceProjection { + readonly signature: string; + readonly snapshot: PendingRequestBlockSnapshot; +} + export interface MessageStreamSurfaceProjection { blocks: readonly MessageStreamViewBlock[]; context: MessageStreamContext; } -export function createMessageStreamSurfaceContext(options: MessageStreamSurfaceContextOptions): ChatMessageStreamSurfaceContext { - return { - vaultPath: options.vaultPath, - setDisclosureOpen: (bucket, id, open) => { - options.dispatch({ type: "ui/disclosure-set", bucket, id, open }); - }, - setForkMenuItem: (itemId) => { - options.dispatch({ type: "ui/message-fork-menu-set", itemId }); - }, - loadOlderTurns: options.loadOlderTurns, - renderObsidianMarkdown: options.renderObsidianMarkdown, - renderStreamMarkdown: options.renderStreamMarkdown, - copyMessageText: options.copyMessageText, - actions: options.actions, - requests: options.requests, - }; -} - export function messageStreamSurfaceProjectionFromModel( model: ChatPanelMessageStreamReadModel, context: ChatMessageStreamSurfaceContext, @@ -188,6 +164,24 @@ function textActionTargetsForMessageStreamItems( return byItemId; } +function pendingRequestSurfaceProjectionFromState( + requests: ChatRequestState, + approvalDetails: ReadonlySet, +): PendingRequestSurfaceProjection | null { + const signature = pendingRequestsSignature( + requests.approvals, + requests.pendingUserInputs, + requests.pendingMcpElicitations, + requests.userInputDrafts, + requests.mcpElicitationDrafts, + ); + if (!signature) return null; + return { + signature, + snapshot: pendingRequestBlockSnapshotFromState(pendingRequestBlockStateFromRequestState(requests, approvalDetails)), + }; +} + function patchTextActionTargets( byItemId: Map, itemId: string, diff --git a/src/features/chat/panel/surface/pending-request-block-projection.ts b/src/features/chat/panel/surface/pending-request-block-projection.ts deleted file mode 100644 index 4ad72b3a..00000000 --- a/src/features/chat/panel/surface/pending-request-block-projection.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { pendingRequestBlockStateFromRequestState } from "../../application/pending-requests/block"; -import type { ChatRequestState } from "../../application/pending-requests/state"; -import { pendingRequestsSignature } from "../../domain/pending-requests/signatures"; -import { type PendingRequestBlockSnapshot, pendingRequestBlockSnapshotFromState } from "../../presentation/pending-requests/view-model"; - -export interface PendingRequestSurfaceProjection { - readonly signature: string; - readonly snapshot: PendingRequestBlockSnapshot; -} - -export function pendingRequestSurfaceProjectionFromState( - requests: ChatRequestState, - approvalDetails: ReadonlySet, -): PendingRequestSurfaceProjection | null { - const signature = pendingRequestsSignature( - requests.approvals, - requests.pendingUserInputs, - requests.pendingMcpElicitations, - requests.userInputDrafts, - requests.mcpElicitationDrafts, - ); - if (!signature) return null; - return { - signature, - snapshot: pendingRequestBlockSnapshotFromState(pendingRequestBlockStateFromRequestState(requests, approvalDetails)), - }; -} diff --git a/src/features/threads-view/session.ts b/src/features/threads-view/session.ts index de8740bb..a609f1fb 100644 --- a/src/features/threads-view/session.ts +++ b/src/features/threads-view/session.ts @@ -2,7 +2,7 @@ import { Notice } from "obsidian"; import type { AppServerClientAccess } from "../../app-server/connection/client-access"; import type { ObservedResult } from "../../app-server/query/observed-result"; -import { observedInitialError, observedInitialLoading, observedValue } from "../../app-server/query/observed-result"; +import { observedInitialError, observedInitialLoading } from "../../app-server/query/observed-result"; import { isStaleAppServerSharedQueryContextError } from "../../app-server/query/shared-queries"; import type { ReasoningEffort } from "../../domain/catalog/metadata"; import type { ArchiveExportSettings } from "../../domain/threads/archive-markdown"; @@ -161,7 +161,7 @@ export class ThreadsViewSession { } private receiveObservedThreadsResult(result: ObservedResult): void { - const observedThreads = observedValue(result); + const observedThreads = result.value; if (observedThreads) { this.receiveObservedThreads(observedThreads); return; diff --git a/src/settings/dynamic-sections-controller.ts b/src/settings/dynamic-sections-controller.ts index 621cadcb..be51609e 100644 --- a/src/settings/dynamic-sections-controller.ts +++ b/src/settings/dynamic-sections-controller.ts @@ -1,24 +1,22 @@ import type { ObservedResult } from "../app-server/query/observed-result"; -import { observedValue } from "../app-server/query/observed-result"; import type { HookItem, ModelMetadata, ReasoningEffort } from "../domain/catalog/metadata"; import { findModelMetadataByIdOrName, sortedModelMetadata, supportedEffortsForModelMetadata } from "../domain/catalog/metadata"; import type { Thread } from "../domain/threads/model"; import { threadArchiveDisplayTitle } from "../domain/threads/title"; import { isStaleSettingsDynamicDataContextError } from "./dynamic-data"; import type { SettingsDynamicSectionsHost } from "./host"; -import { - createSettingsDynamicSectionLifecycle, - type SettingsDynamicSectionLifecycleState, - settingsDynamicSectionFailed, - settingsDynamicSectionLoaded, - settingsDynamicSectionLoading, -} from "./lifecycle"; interface SettingsDynamicSectionsControllerCallbacks { display(): void; notify(message: string): void; } +type SettingsDynamicSectionLifecycleState = + | { kind: "idle"; status: "" } + | { kind: "loading"; status: string } + | { kind: "loaded"; status: string } + | { kind: "failed"; status: string }; + export interface SettingsDynamicSectionsSnapshot { archivedThreads: readonly Thread[]; archivedThreadsLifecycle: SettingsDynamicSectionLifecycleState; @@ -110,14 +108,14 @@ export class SettingsDynamicSectionsController { } private receiveObservedModelsResult(result: ObservedResult): void { - const observedModels = observedValue(result); + const observedModels = result.value; if (!observedModels) return; this.models = [...observedModels]; this.callbacks.display(); } private receiveObservedArchivedThreadsResult(result: ObservedResult): void { - const observedThreads = observedValue(result); + const observedThreads = result.value; if (!observedThreads) return; this.archivedThreads = [...observedThreads]; this.archivedThreadsLoaded = true; @@ -391,6 +389,22 @@ function archivedThreadsStatus(count: number): string { return `Loaded ${String(count)} archived thread${count === 1 ? "" : "s"}.`; } +function createSettingsDynamicSectionLifecycle(): SettingsDynamicSectionLifecycleState { + return { kind: "idle", status: "" }; +} + +function settingsDynamicSectionLoading(status: string): SettingsDynamicSectionLifecycleState { + return { kind: "loading", status }; +} + +function settingsDynamicSectionLoaded(status: string): SettingsDynamicSectionLifecycleState { + return { kind: "loaded", status }; +} + +function settingsDynamicSectionFailed(status: string): SettingsDynamicSectionLifecycleState { + return { kind: "failed", status }; +} + function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/src/settings/lifecycle.ts b/src/settings/lifecycle.ts deleted file mode 100644 index 8bcfb4a7..00000000 --- a/src/settings/lifecycle.ts +++ /dev/null @@ -1,21 +0,0 @@ -export type SettingsDynamicSectionLifecycleState = - | { kind: "idle"; status: "" } - | { kind: "loading"; status: string } - | { kind: "loaded"; status: string } - | { kind: "failed"; status: string }; - -export function createSettingsDynamicSectionLifecycle(): SettingsDynamicSectionLifecycleState { - return { kind: "idle", status: "" }; -} - -export function settingsDynamicSectionLoading(status: string): SettingsDynamicSectionLifecycleState { - return { kind: "loading", status }; -} - -export function settingsDynamicSectionLoaded(status: string): SettingsDynamicSectionLifecycleState { - return { kind: "loaded", status }; -} - -export function settingsDynamicSectionFailed(status: string): SettingsDynamicSectionLifecycleState { - return { kind: "failed", status }; -} diff --git a/tests/app-server/query/observed-result.test.ts b/tests/app-server/query/observed-result.test.ts index 07968db9..e8b263f9 100644 --- a/tests/app-server/query/observed-result.test.ts +++ b/tests/app-server/query/observed-result.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import type { ObservedResult } from "../../../src/app-server/query/observed-result"; -import { observedInitialError, observedInitialLoading, observedValue } from "../../../src/app-server/query/observed-result"; +import { observedInitialError, observedInitialLoading } from "../../../src/app-server/query/observed-result"; describe("observed query result helpers", () => { it("treats successful empty arrays as current values", () => { @@ -19,11 +19,6 @@ describe("observed query result helpers", () => { expect(observedInitialLoading(observedResult({ value: null, isFetching: true }), null)).toBe(true); expect(observedInitialError(observedResult({ value: null, error }), null)).toBe(error); }); - - it("projects nullable observed values without reinterpreting empty values", () => { - expect(observedValue(observedResult({ value: [] as readonly string[] }))).toEqual([]); - expect(observedValue(observedResult({ value: null }))).toBeNull(); - }); }); function observedResult(overrides: Partial> & Pick, "value">): ObservedResult { diff --git a/tests/features/chat/app-server/transports.test.ts b/tests/features/chat/app-server/transports.test.ts index 39b85e18..bc6d2c0d 100644 --- a/tests/features/chat/app-server/transports.test.ts +++ b/tests/features/chat/app-server/transports.test.ts @@ -4,18 +4,8 @@ import type { AppServerClient, ClientResponseByMethod } from "../../../../src/ap import type { ThreadRecord } from "../../../../src/app-server/protocol/thread"; import type { TurnItem, TurnRecord } from "../../../../src/app-server/protocol/turn"; import type { CodexInput } from "../../../../src/domain/chat/input"; +import { createChatAppServerGateway } from "../../../../src/features/chat/app-server/session-gateway"; import { createThreadReferenceResolver } from "../../../../src/features/chat/app-server/thread-reference-resolver"; -import { - createChatThreadGoalReadTransport, - createChatThreadGoalTransport, -} from "../../../../src/features/chat/app-server/transports/goal-transport"; -import { - createChatThreadHistoryTransport, - createChatThreadResumeTransport, -} from "../../../../src/features/chat/app-server/transports/thread-loading-transport"; -import { createChatThreadMutationTransport } from "../../../../src/features/chat/app-server/transports/thread-mutation-transport"; -import { createChatRuntimeSettingsTransport } from "../../../../src/features/chat/app-server/transports/thread-settings-transport"; -import { createChatTurnTransport } from "../../../../src/features/chat/app-server/transports/turn-transport"; import { deferred } from "../../../support/async"; const textInput = (text: string): CodexInput => [{ type: "text", text }]; @@ -24,11 +14,10 @@ describe("chat app-server transports", () => { it("starts turns with the session vault path and returns chat-owned turn ids", async () => { const request = vi.fn().mockResolvedValue({ turn: { id: "turn-1" } }); const client = { request } as unknown as AppServerClient; - const transport = createChatTurnTransport({ - vaultPath: "/vault", + const transport = createTestGateway({ currentClient: () => client, connectedClient: vi.fn().mockResolvedValue(client), - }); + }).turn; await expect( transport.startTurn({ @@ -51,11 +40,10 @@ describe("chat app-server transports", () => { const firstClient = { request: vi.fn().mockReturnValue(start.promise) } as unknown as AppServerClient; const secondClient = {} as unknown as AppServerClient; let currentClient = firstClient; - const transport = createChatTurnTransport({ - vaultPath: "/vault", + const transport = createTestGateway({ currentClient: () => currentClient, connectedClient: vi.fn().mockResolvedValue(firstClient), - }); + }).turn; const starting = transport.startTurn({ threadId: "thread", input: textInput("hello"), clientUserMessageId: "local-user" }); currentClient = secondClient; @@ -67,11 +55,10 @@ describe("chat app-server transports", () => { it("compacts threads through a connected app-server client", async () => { const request = vi.fn().mockResolvedValue({}); const client = { request } as unknown as AppServerClient; - const transport = createChatThreadMutationTransport({ - vaultPath: "/vault", + const transport = createTestGateway({ currentClient: () => client, connectedClient: vi.fn().mockResolvedValue(client), - }); + }).threadMutation; await expect(transport.compactThread("thread")).resolves.toBe(true); @@ -81,11 +68,10 @@ describe("chat app-server transports", () => { it("forks threads with the session vault path and returns panel threads", async () => { const request = vi.fn().mockResolvedValue({ thread: threadRecord("forked") }); const client = { request } as unknown as AppServerClient; - const transport = createChatThreadMutationTransport({ - vaultPath: "/vault", + const transport = createTestGateway({ currentClient: () => client, connectedClient: vi.fn().mockResolvedValue(client), - }); + }).threadMutation; const thread = await transport.forkThread("source"); @@ -98,11 +84,10 @@ describe("chat app-server transports", () => { const firstClient = { request: vi.fn().mockReturnValue(fork.promise) } as unknown as AppServerClient; const secondClient = {} as unknown as AppServerClient; let currentClient = firstClient; - const transport = createChatThreadMutationTransport({ - vaultPath: "/vault", + const transport = createTestGateway({ currentClient: () => currentClient, connectedClient: vi.fn().mockResolvedValue(firstClient), - }); + }).threadMutation; const forking = transport.forkThread("source"); currentClient = secondClient; @@ -114,11 +99,10 @@ describe("chat app-server transports", () => { it("projects rollback turns into message stream items", async () => { const request = vi.fn().mockResolvedValue({ thread: threadRecord("thread", [turn([userMessage("u1", "prompt")])]) }); const client = { request } as unknown as AppServerClient; - const transport = createChatThreadMutationTransport({ - vaultPath: "/vault", + const transport = createTestGateway({ currentClient: () => client, connectedClient: vi.fn().mockResolvedValue(client), - }); + }).threadMutation; const snapshot = await transport.rollbackThread("thread"); @@ -134,9 +118,10 @@ describe("chat app-server transports", () => { nextCursor: "older", }); const client = { request } as unknown as AppServerClient; - const transport = createChatThreadHistoryTransport({ + const transport = createTestGateway({ currentClient: () => client, - }); + connectedClient: vi.fn().mockResolvedValue(client), + }).threadHistory; const page = await transport.readHistoryPage("thread", "cursor", 20); @@ -160,9 +145,10 @@ describe("chat app-server transports", () => { const firstClient = { request: vi.fn().mockReturnValue(history.promise) } as unknown as AppServerClient; const secondClient = {} as unknown as AppServerClient; let currentClient = firstClient; - const transport = createChatThreadHistoryTransport({ + const transport = createTestGateway({ currentClient: () => currentClient, - }); + connectedClient: vi.fn().mockResolvedValue(firstClient), + }).threadHistory; const loading = transport.readHistoryPage("thread", "cursor", 20); currentClient = secondClient; @@ -185,11 +171,10 @@ describe("chat app-server transports", () => { }, }); const client = { request } as unknown as AppServerClient; - const transport = createChatThreadResumeTransport({ - vaultPath: "/vault", + const transport = createTestGateway({ currentClient: () => client, connectedClient: vi.fn().mockResolvedValue(client), - }); + }).threadResume; const snapshot = await transport.resumeThread("thread"); @@ -214,11 +199,10 @@ describe("chat app-server transports", () => { const firstClient = { request: vi.fn().mockReturnValue(resume.promise) } as unknown as AppServerClient; const secondClient = {} as unknown as AppServerClient; let currentClient = firstClient; - const transport = createChatThreadResumeTransport({ - vaultPath: "/vault", + const transport = createTestGateway({ currentClient: () => currentClient, connectedClient: vi.fn().mockResolvedValue(firstClient), - }); + }).threadResume; const resuming = transport.resumeThread("thread"); currentClient = secondClient; @@ -228,28 +212,28 @@ describe("chat app-server transports", () => { }); it("returns no resume snapshot when no connected client is available", async () => { - const transport = createChatThreadResumeTransport({ - vaultPath: "/vault", + const transport = createTestGateway({ currentClient: () => null, connectedClient: vi.fn().mockResolvedValue(null), - }); + }).threadResume; await expect(transport.resumeThread("thread")).resolves.toBeNull(); }); it("distinguishes absent goals from unavailable goal clients", async () => { const client = { request: vi.fn().mockResolvedValue({ goal: null }) } as unknown as AppServerClient; - const transport = createChatThreadGoalTransport({ + const transport = createTestGateway({ currentClient: () => client, connectedClient: vi.fn().mockResolvedValue(client), - }); - const unavailable = createChatThreadGoalTransport({ + }).threadGoal; + const unavailable = createTestGateway({ currentClient: () => null, connectedClient: vi.fn().mockResolvedValue(null), - }); - const readOnlyUnavailable = createChatThreadGoalReadTransport({ + }).threadGoal; + const readOnlyUnavailable = createTestGateway({ currentClient: () => null, - }); + connectedClient: vi.fn().mockResolvedValue(null), + }).threadGoalRead; await expect(transport.readThreadGoal("thread")).resolves.toBeNull(); await expect(unavailable.readThreadGoal("thread")).resolves.toBeUndefined(); @@ -262,9 +246,10 @@ describe("chat app-server transports", () => { const firstClient = { request } as unknown as AppServerClient; const secondClient = {} as unknown as AppServerClient; let currentClient = firstClient; - const transport = createChatRuntimeSettingsTransport({ + const transport = createTestGateway({ currentClient: () => currentClient, - }); + connectedClient: vi.fn().mockResolvedValue(firstClient), + }).runtimeSettings; const updating = transport.updateThreadSettings("thread", { model: "gpt-5.5" }); currentClient = secondClient; @@ -312,6 +297,18 @@ describe("chat app-server transports", () => { type AppServerThreadResumeResponse = ClientResponseByMethod["thread/resume"]; +function createTestGateway(options: { + vaultPath?: string; + currentClient: () => AppServerClient | null; + connectedClient?: () => Promise; +}) { + return createChatAppServerGateway({ + vaultPath: options.vaultPath ?? "/vault", + currentClient: options.currentClient, + connectedClient: options.connectedClient ?? (async () => options.currentClient()), + }); +} + function threadRecord(id: string, turns: readonly TurnRecord[] = [], overrides: Partial = {}): ThreadRecord { return { id, diff --git a/tests/features/chat/host/reconnect-action.test.ts b/tests/features/chat/host/reconnect-action.test.ts deleted file mode 100644 index 14b81108..00000000 --- a/tests/features/chat/host/reconnect-action.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { ConnectionManager } from "../../../../src/app-server/connection/connection-manager"; -import { ConnectionWorkTracker } from "../../../../src/features/chat/application/connection/connection-work"; -import { createChatStateStore } from "../../../../src/features/chat/application/state/store"; -import { createReconnectAction } from "../../../../src/features/chat/host/bundles/reconnect-bundle"; -import { createChatViewDeferredTasks } from "../../../../src/features/chat/host/session/deferred-work"; - -describe("createReconnectAction", () => { - it("wires host connection lifecycle cleanup into panel reconnect", async () => { - const stateStore = createChatStateStore(); - stateStore.dispatch({ - type: "active-thread/resumed", - approvalPolicyKnown: true, - sandboxPolicyKnown: true, - permissionProfileKnown: true, - approvalPolicy: null, - sandboxPolicy: null, - activePermissionProfile: null, - thread: { id: "thread-1" } as never, - cwd: "/vault", - model: null, - reasoningEffort: null, - serviceTier: null, - approvalsReviewer: null, - }); - const connectionWork = new ConnectionWorkTracker(); - const activeConnectionWork = connectionWork.begin(); - const deferredTasks = createChatViewDeferredTasks(() => ({ setTimeout, clearTimeout }) as Pick); - const connection = new ConnectionManager(() => "codex", "/vault"); - const resetConnection = vi.spyOn(connection, "resetConnection"); - const clearDiagnostics = vi.spyOn(deferredTasks, "clearDiagnostics"); - const status = { - set: vi.fn(), - addSystemMessage: vi.fn(), - }; - const ensureConnected = vi.fn().mockResolvedValue(undefined); - const resumeThread = vi.fn().mockResolvedValue(undefined); - const reconnect = createReconnectAction( - { - stateStore, - connectionWork, - deferredTasks, - }, - { - connection, - ensureConnected, - invalidateThreadWork: vi.fn(), - resumeThread, - status, - }, - ); - - await reconnect(); - - expect(connectionWork.isStale(activeConnectionWork)).toBe(true); - expect(clearDiagnostics).toHaveBeenCalledOnce(); - expect(resetConnection).toHaveBeenCalledOnce(); - expect(status.set).toHaveBeenCalledWith("Reconnecting...", { kind: "connecting" }); - expect(ensureConnected).toHaveBeenCalledOnce(); - expect(resumeThread).toHaveBeenCalledWith("thread-1"); - }); -}); diff --git a/tests/features/chat/host/session-graph.test.ts b/tests/features/chat/host/session-graph.test.ts index 62c7cd6e..48e2be2f 100644 --- a/tests/features/chat/host/session-graph.test.ts +++ b/tests/features/chat/host/session-graph.test.ts @@ -16,6 +16,7 @@ import { ChatComposerController } from "../../../../src/features/chat/panel/comp import { createChatMessageStreamScrollBinding } from "../../../../src/features/chat/panel/message-stream-scroll-binding"; import { MessageStreamPresenter } from "../../../../src/features/chat/panel/surface/message-stream-presenter"; import { type CodexPanelSettings, DEFAULT_SETTINGS } from "../../../../src/settings/model"; +import { waitForAsyncWork } from "../../../support/async"; import { installObsidianDomShims } from "../../../support/dom"; import { chatPanelSettingsAccess } from "../support/settings"; @@ -199,6 +200,41 @@ describe("createChatPanelSessionGraph actions", () => { expect(focusComposer).not.toHaveBeenCalled(); }); + it("wires reconnect cleanup through the graph toolbar action", async () => { + const { graph, stateStore, connectionWork, deferredTasks } = sessionGraphFixture(); + stateStore.dispatch({ + type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, + thread: threadFixture({ id: "thread-1", preview: "Active" }), + cwd: "/vault", + model: null, + reasoningEffort: null, + serviceTier: null, + approvalsReviewer: null, + }); + const activeConnectionWork = connectionWork.begin(); + const clearDiagnostics = vi.spyOn(deferredTasks, "clearDiagnostics"); + const resetConnection = vi.spyOn(graph.connection.manager, "resetConnection"); + const ensureConnected = vi.spyOn(graph.connection.actions, "ensureConnected").mockResolvedValue(undefined); + const resumeThread = vi.spyOn(graph.thread.resume, "resumeThread").mockResolvedValue(undefined); + + graph.shell.parts.toolbar.actions.status.connect(); + + await waitForAsyncWork(() => { + expect(resumeThread).toHaveBeenCalledWith("thread-1"); + }); + expect(connectionWork.isStale(activeConnectionWork)).toBe(true); + expect(clearDiagnostics).toHaveBeenCalledOnce(); + expect(resetConnection).toHaveBeenCalledOnce(); + expect(stateStore.getState().connection.statusText).toBe("Reconnecting..."); + expect(ensureConnected).toHaveBeenCalledOnce(); + }); + it("disposes presenter and composer resources from the graph action", () => { const disposePresenter = vi.spyOn(MessageStreamPresenter.prototype, "dispose").mockImplementation(() => undefined); const disposeComposer = vi.spyOn(ChatComposerController.prototype, "dispose").mockImplementation(() => undefined); @@ -214,21 +250,25 @@ describe("createChatPanelSessionGraph actions", () => { graph: ReturnType; stateStore: ChatStateStore; resumeWork: ChatResumeWorkTracker; + connectionWork: ConnectionWorkTracker; + deferredTasks: ReturnType; } { const stateStore = createChatStateStore(); const resumeWork = new ChatResumeWorkTracker(); + const connectionWork = new ConnectionWorkTracker(); + const deferredTasks = createChatViewDeferredTasks(() => window); const environment = chatPanelEnvironmentFixture(options.environment); const graph = createChatPanelSessionGraph({ environment, stateStore, - deferredTasks: createChatViewDeferredTasks(() => window), + deferredTasks, resumeWork, - connectionWork: new ConnectionWorkTracker(), + connectionWork, messageScrollBinding: createChatMessageStreamScrollBinding(), getClosing: () => false, viewWindow: () => window, }); - return { graph, stateStore, resumeWork }; + return { graph, stateStore, resumeWork, connectionWork, deferredTasks }; } interface PartialChatPanelEnvironment { diff --git a/tests/features/chat/panel/surface/message-stream-presenter.test.ts b/tests/features/chat/panel/surface/message-stream-presenter.test.ts index a4826d8a..b025e795 100644 --- a/tests/features/chat/panel/surface/message-stream-presenter.test.ts +++ b/tests/features/chat/panel/surface/message-stream-presenter.test.ts @@ -58,7 +58,7 @@ describe("MessageStreamPresenter scroll pinning", () => { const projection = messageStreamSurfaceProjectionFromModel( messageStreamReadModelFromChatState(store.getState()), - messageStreamSurfaceContext({ + testMessageStreamSurfaceContext({ vaultPath: "/vault", dispatch: (action) => { store.dispatch(action); @@ -75,7 +75,7 @@ describe("MessageStreamPresenter scroll pinning", () => { it("wires message stream disclosure actions through the surface context", () => { const store = createChatStateStore(chatStateFixture()); - const surfaceContext = messageStreamSurfaceContext({ + const surfaceContext = testMessageStreamSurfaceContext({ vaultPath: "/vault", dispatch: (action) => { store.dispatch(action); @@ -95,7 +95,7 @@ describe("MessageStreamPresenter scroll pinning", () => { state = chatStateWith(state, { requests: { approvals: [pendingApproval()] } }); const projection = messageStreamSurfaceProjectionFromModel( messageStreamReadModelFromChatState(state), - messageStreamSurfaceContext({ + testMessageStreamSurfaceContext({ vaultPath: "/vault", dispatch: () => undefined, }), @@ -522,7 +522,7 @@ describe("MessageStreamPresenter scroll pinning", () => { }); }); -function messageStreamSurfaceContext(options: { +function testMessageStreamSurfaceContext(options: { vaultPath: string; dispatch: (action: ChatAction) => void; }): ChatMessageStreamSurfaceContext { diff --git a/tests/settings/settings-lifecycle.test.ts b/tests/settings/settings-lifecycle.test.ts deleted file mode 100644 index 66399edf..00000000 --- a/tests/settings/settings-lifecycle.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - createSettingsDynamicSectionLifecycle, - settingsDynamicSectionFailed, - settingsDynamicSectionLoaded, - settingsDynamicSectionLoading, -} from "../../src/settings/lifecycle"; - -describe("settings lifecycle", () => { - it("tracks dynamic section lifecycle", () => { - const idle = createSettingsDynamicSectionLifecycle(); - expect(idle).toEqual({ kind: "idle", status: "" }); - - const loading = settingsDynamicSectionLoading("Loading hooks..."); - expect(loading).toEqual({ kind: "loading", status: "Loading hooks..." }); - - const loaded = settingsDynamicSectionLoaded("Loaded 1 hook."); - expect(loaded).toEqual({ kind: "loaded", status: "Loaded 1 hook." }); - - const failed = settingsDynamicSectionFailed("Could not load hooks."); - expect(failed).toEqual({ kind: "failed", status: "Could not load hooks." }); - - expect(createSettingsDynamicSectionLifecycle()).toEqual(idle); - }); -});