From d81044c17b97d7f966ebedfddc7caec285216320 Mon Sep 17 00:00:00 2001 From: murashit Date: Wed, 8 Jul 2026 21:00:48 +0900 Subject: [PATCH] Move chat app-server actions to owned workflows --- .../chat/app-server/actions/diagnostics.ts | 123 --- src/features/chat/app-server/actions/host.ts | 21 - .../chat/app-server/actions/threads.ts | 71 -- .../chat/app-server/inbound/handler.ts | 2 +- .../app-server/inbound/notification-plan.ts | 2 +- .../chat/app-server/session-gateway.ts | 4 +- .../transports/metadata-transports.ts | 106 +++ .../transports/session-transports.ts | 18 + .../connection/metadata-transport.ts | 40 + .../connection/server-diagnostics-actions.ts | 80 ++ .../connection/server-metadata-actions.ts} | 48 +- .../threads/thread-start-actions.ts | 62 ++ .../threads/thread-start-transport.ts | 11 + .../chat/host/bundles/connection-bundle.ts | 53 +- .../chat/host/bundles/thread-bundle.ts | 8 +- src/features/chat/host/bundles/turn-bundle.ts | 8 +- src/features/chat/host/session-graph.ts | 22 +- .../chat/host/session/shared-state-binding.ts | 13 +- .../chat/app-server/actions/actions.test.ts | 853 ------------------ .../chat/app-server/transports.test.ts | 143 +++ .../connection/server-actions.test.ts | 412 +++++++++ .../threads/thread-start-actions.test.ts | 204 +++++ tests/features/chat/host/turn-bundle.test.ts | 2 +- tests/scripts/grit-policy.test.mjs | 48 +- 24 files changed, 1177 insertions(+), 1177 deletions(-) delete mode 100644 src/features/chat/app-server/actions/diagnostics.ts delete mode 100644 src/features/chat/app-server/actions/host.ts delete mode 100644 src/features/chat/app-server/actions/threads.ts create mode 100644 src/features/chat/app-server/transports/metadata-transports.ts create mode 100644 src/features/chat/application/connection/metadata-transport.ts create mode 100644 src/features/chat/application/connection/server-diagnostics-actions.ts rename src/features/chat/{app-server/actions/metadata.ts => application/connection/server-metadata-actions.ts} (75%) create mode 100644 src/features/chat/application/threads/thread-start-actions.ts create mode 100644 src/features/chat/application/threads/thread-start-transport.ts delete mode 100644 tests/features/chat/app-server/actions/actions.test.ts create mode 100644 tests/features/chat/application/connection/server-actions.test.ts create mode 100644 tests/features/chat/application/threads/thread-start-actions.test.ts diff --git a/src/features/chat/app-server/actions/diagnostics.ts b/src/features/chat/app-server/actions/diagnostics.ts deleted file mode 100644 index 84e601f9..00000000 --- a/src/features/chat/app-server/actions/diagnostics.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { AppServerClient } from "../../../../app-server/connection/client"; -import { readRateLimitMetadataProbe } from "../../../../app-server/query/metadata-probes"; -import { listModelMetadata } from "../../../../app-server/services/catalog"; -import { readToolInventory } from "../../../../app-server/services/tool-inventory"; -import { - cloneServerDiagnostics, - type DiagnosticProbeId, - type DiagnosticProbeResult, - diagnosticProbeError, - diagnosticProbeOk, - diagnosticsWithProbe, - diagnosticsWithToolInventory, - upsertMcpServerStatusDiagnostics, -} from "../../../../domain/server/diagnostics"; -import type { SharedServerMetadata } from "../../../../domain/server/metadata"; -import { type ChatServerActionsHost, captureChatServerClientScope } from "./host"; - -interface RefreshServerDiagnosticsOptions { - appServerMetadataSnapshot?: boolean; - forceResourceProbes?: boolean; -} - -interface DiagnosticProbeSnapshot { - probe: DiagnosticProbeResult; -} - -export interface ChatServerDiagnosticsActionsHost extends ChatServerActionsHost { - updateAppServerMetadata: (updater: (metadata: SharedServerMetadata | null) => SharedServerMetadata | null) => SharedServerMetadata | null; - appServerMetadataSnapshot: () => SharedServerMetadata | null; -} - -export interface ChatServerDiagnosticsActions { - refreshServerDiagnostics: (options?: RefreshServerDiagnosticsOptions) => Promise; -} - -export function createChatServerDiagnosticsActions(host: ChatServerDiagnosticsActionsHost): ChatServerDiagnosticsActions { - return { - refreshServerDiagnostics: async (options) => { - await refreshServerDiagnostics(host, options); - }, - }; -} - -async function refreshServerDiagnostics( - host: ChatServerDiagnosticsActionsHost, - options: RefreshServerDiagnosticsOptions = {}, -): Promise { - const scope = captureChatServerClientScope(host); - if (!scope.client) return false; - const client = scope.client; - - const initialDiagnostics = currentMetadataDiagnostics(host); - const state = host.stateStore.getState(); - const activeThreadId = state.activeThread.id; - const metadataSnapshot = host.appServerMetadataSnapshot(); - const cachedSkills = - options.forceResourceProbes === true ? undefined : (metadataSnapshot?.availableSkills ?? state.connection.availableSkills); - const cachedSkillsProbe = - options.forceResourceProbes === true - ? undefined - : (metadataSnapshot?.serverDiagnostics.probes.skills ?? state.connection.serverDiagnostics.probes.skills); - const toolInventory = readToolInventory(client, host.vaultPath, { - threadId: activeThreadId, - mcpDiagnostics: initialDiagnostics.mcpServers, - ...(cachedSkills !== undefined ? { cachedSkills } : {}), - ...(cachedSkillsProbe !== undefined ? { cachedSkillsProbe } : {}), - }); - const probes: Promise[] = []; - if (options.forceResourceProbes === true && options.appServerMetadataSnapshot !== true) { - probes.push( - probeDiagnostic( - "models", - () => listModelMetadata(client), - (models) => `${String(models.length)} models`, - ), - readRateLimitDiagnosticProbe(client), - ); - } - - const [results, toolInventoryResult] = await Promise.all([Promise.all(probes), toolInventory]); - if (scope.isStale()) return false; - - let diagnostics = currentMetadataDiagnostics(host); - for (const result of results) { - diagnostics = diagnosticsWithProbe(diagnostics, result.probe); - } - for (const probe of toolInventoryResult.probes) { - diagnostics = diagnosticsWithProbe(diagnostics, probe); - } - if (toolInventoryResult.mcpServerStatuses) { - diagnostics = upsertMcpServerStatusDiagnostics(diagnostics, toolInventoryResult.mcpServerStatuses); - } - diagnostics = diagnosticsWithToolInventory(diagnostics, toolInventoryResult.inventory); - host.updateAppServerMetadata((metadata) => (metadata ? { ...metadata, serverDiagnostics: diagnostics } : null)); - host.stateStore.dispatch({ type: "connection/metadata-applied", serverDiagnostics: diagnostics }); - return true; -} - -async function readRateLimitDiagnosticProbe(client: AppServerClient): Promise { - const result = await readRateLimitMetadataProbe(client); - return { probe: result.probe }; -} - -function currentMetadataDiagnostics(host: ChatServerDiagnosticsActionsHost): SharedServerMetadata["serverDiagnostics"] { - return ( - host.appServerMetadataSnapshot()?.serverDiagnostics ?? cloneServerDiagnostics(host.stateStore.getState().connection.serverDiagnostics) - ); -} - -async function probeDiagnostic( - id: DiagnosticProbeId, - request: () => Promise, - summarize: (response: T) => string | null, -): Promise { - try { - const response = await request(); - return { - probe: diagnosticProbeOk(id, summarize(response), Date.now()), - }; - } catch (error) { - return { probe: diagnosticProbeError(id, error, Date.now()) }; - } -} diff --git a/src/features/chat/app-server/actions/host.ts b/src/features/chat/app-server/actions/host.ts deleted file mode 100644 index c4f55105..00000000 --- a/src/features/chat/app-server/actions/host.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { AppServerClient } from "../../../../app-server/connection/client"; -import type { ChatStateStore } from "../../application/state/store"; - -export interface ChatServerActionsHost { - stateStore: ChatStateStore; - vaultPath: string; - currentClient: () => AppServerClient | null; -} - -export interface ChatServerClientScope { - client: AppServerClient | null; - isStale: () => boolean; -} - -export function captureChatServerClientScope(host: ChatServerActionsHost): ChatServerClientScope { - const client = host.currentClient(); - return { - client, - isStale: () => client !== null && host.currentClient() !== client, - }; -} diff --git a/src/features/chat/app-server/actions/threads.ts b/src/features/chat/app-server/actions/threads.ts deleted file mode 100644 index c5635e39..00000000 --- a/src/features/chat/app-server/actions/threads.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { - startThread as startAppServerThread, - threadActivationSnapshotFromAppServerResponse, -} from "../../../../app-server/services/threads"; -import { runtimeConfigOrDefault } from "../../../../domain/runtime/config"; -import type { Thread } from "../../../../domain/threads/model"; -import type { ThreadCatalogEvent } from "../../../threads/catalog/thread-catalog"; -import { resumedThreadAction } from "../../application/state/actions"; -import type { ChatState } from "../../application/state/root-reducer"; -import type { RuntimeSnapshot } from "../../domain/runtime/snapshot"; -import { permissionProfileRequestForThreadStart, serviceTierRequestForThreadStart } from "../../domain/runtime/thread-settings-patch"; -import { type ChatServerActionsHost, captureChatServerClientScope } from "./host"; - -interface StartedThreadSummary { - threadId: string; -} - -export interface ChatServerThreadActionsHost extends ChatServerActionsHost { - runtimeSnapshotForState: (state: ChatState) => RuntimeSnapshot; - applyThreadCatalogEvent: (event: ThreadCatalogEvent) => void; - syncThreadGoal: (threadId: string) => void; -} - -export interface ChatServerThreadActions { - applyThreadList: (threads: readonly Thread[]) => void; - startThread: (preview?: string, options?: { syncGoal?: boolean }) => Promise; -} - -export function createChatServerThreadActions(host: ChatServerThreadActionsHost): ChatServerThreadActions { - return { - applyThreadList: (threads) => { - applyThreadList(host, threads); - }, - startThread: (preview, options) => startThread(host, preview, options), - }; -} - -function applyThreadList(host: ChatServerThreadActionsHost, threads: readonly Thread[]): void { - host.stateStore.dispatch({ type: "thread-list/applied", threads, threadsLoaded: true }); -} - -async function startThread( - host: ChatServerThreadActionsHost, - preview?: string, - options: { syncGoal?: boolean } = {}, -): Promise { - const scope = captureChatServerClientScope(host); - if (!scope.client) return null; - const requestState = host.stateStore.getState(); - const runtimeSnapshot = host.runtimeSnapshotForState(requestState); - const runtimeConfig = runtimeConfigOrDefault(requestState.connection.runtimeConfig); - const serviceTier = serviceTierRequestForThreadStart(runtimeSnapshot, runtimeConfig); - const permissions = permissionProfileRequestForThreadStart(runtimeSnapshot, runtimeConfig); - const response = await startAppServerThread(scope.client, { cwd: host.vaultPath, serviceTier, permissions }); - if (scope.isStale()) return null; - const state = host.stateStore.getState(); - const fallbackPreview = preview?.trim(); - const activationResponse = - response.thread.preview.trim().length > 0 || !fallbackPreview - ? response - : { ...response, thread: { ...response.thread, preview: fallbackPreview } }; - const action = resumedThreadAction({ - response: threadActivationSnapshotFromAppServerResponse(activationResponse), - listedThreads: state.threadList.listedThreads, - preserveRequestedRuntimeSettings: requestState.activeThread.id === null, - }); - host.stateStore.dispatch(action); - host.applyThreadCatalogEvent({ type: "thread-started", thread: action.thread }); - if (options.syncGoal ?? true) host.syncThreadGoal(response.thread.id); - return { threadId: response.thread.id }; -} diff --git a/src/features/chat/app-server/inbound/handler.ts b/src/features/chat/app-server/inbound/handler.ts index 64c7b960..7f699a3d 100644 --- a/src/features/chat/app-server/inbound/handler.ts +++ b/src/features/chat/app-server/inbound/handler.ts @@ -15,6 +15,7 @@ import { } from "../../../../domain/pending-requests/model"; import type { ThreadConversationSummary } from "../../../../domain/threads/transcript"; import type { ThreadCatalogEvent } from "../../../threads/catalog/thread-catalog"; +import type { AppServerResourceEvent } from "../../application/connection/server-metadata-actions"; import { activeTurnId } from "../../application/conversation/turn-state"; import type { LocalIdSource } from "../../application/local-id-source"; import type { ChatAction, ChatState } from "../../application/state/root-reducer"; @@ -26,7 +27,6 @@ import { createMcpElicitationResultItem, createUserInputResultItem, } from "../../domain/pending-requests/result-items"; -import type { AppServerResourceEvent } from "../actions/metadata"; import { classifyAppServerLog } from "./app-server-logs"; import { type ChatNotificationEffect, planChatNotification } from "./notification-plan"; diff --git a/src/features/chat/app-server/inbound/notification-plan.ts b/src/features/chat/app-server/inbound/notification-plan.ts index 78a87c8a..5bbd2642 100644 --- a/src/features/chat/app-server/inbound/notification-plan.ts +++ b/src/features/chat/app-server/inbound/notification-plan.ts @@ -3,11 +3,11 @@ import { threadFromAppServerRecord } from "../../../../app-server/services/threa import { threadTokenUsageFromRuntimeUsage } from "../../../../domain/runtime/metrics"; import { normalizeExplicitThreadName } from "../../../../domain/threads/model"; import type { ThreadCatalogEvent } from "../../../threads/catalog/thread-catalog"; +import type { AppServerResourceEvent } from "../../application/connection/server-metadata-actions"; import { type ConversationRuntimeOutcome, planConversationRuntimeEvents } from "../../application/conversation/runtime-event-plan"; import { activeThreadSettingsAppliedAction } from "../../application/state/actions"; import type { ChatAction, ChatState } from "../../application/state/root-reducer"; import { goalChangeItem } from "../../domain/message-stream/factories/goal-items"; -import type { AppServerResourceEvent } from "../actions/metadata"; import { type DiagnosticStatusNotification, routeServerNotification, type ThreadLifecycleNotification } from "./notification-routing"; import { conversationRuntimeEventsFromNotification } from "./runtime-events"; diff --git a/src/features/chat/app-server/session-gateway.ts b/src/features/chat/app-server/session-gateway.ts index d9925d78..75b09920 100644 --- a/src/features/chat/app-server/session-gateway.ts +++ b/src/features/chat/app-server/session-gateway.ts @@ -4,6 +4,7 @@ import { withShortLivedAppServerClient } from "../../../app-server/connection/sh import type { CodexInput } from "../../../domain/chat/input"; import type { ComposerInputSnapshot } from "../application/composer/input-snapshot"; import { createThreadReferenceResolver, type ThreadReferenceResolver } from "./thread-reference-resolver"; +import { type ChatMetadataTransports, createChatMetadataTransports } from "./transports/metadata-transports"; import { type ChatSessionTransports, createChatSessionTransports } from "./transports/session-transports"; export interface ChatAppServerGatewayHost { @@ -19,7 +20,7 @@ interface ChatThreadReferenceResolverOptions { setStatus(status: string): void; } -export interface ChatAppServerGateway extends ChatSessionTransports { +export interface ChatAppServerGateway extends ChatSessionTransports, ChatMetadataTransports { clientAccess: AppServerClientAccess; connectionAvailable(): boolean; readFileBase64(path: string, options?: { timeoutMs?: number }): Promise; @@ -30,6 +31,7 @@ export function createChatAppServerGateway(host: ChatAppServerGatewayHost): Chat return { clientAccess: createCurrentClientAccess(host), ...createChatSessionTransports(host), + ...createChatMetadataTransports(host), connectionAvailable: () => host.currentClient() !== null, readFileBase64: (path, options) => readCurrentClientFileBase64(host, path, options), threadReferences: (options) => diff --git a/src/features/chat/app-server/transports/metadata-transports.ts b/src/features/chat/app-server/transports/metadata-transports.ts new file mode 100644 index 00000000..5712a528 --- /dev/null +++ b/src/features/chat/app-server/transports/metadata-transports.ts @@ -0,0 +1,106 @@ +import type { AppServerClient } from "../../../../app-server/connection/client"; +import { readRateLimitMetadataProbe, readSkillMetadataProbe } from "../../../../app-server/query/metadata-probes"; +import { listModelMetadata } from "../../../../app-server/services/catalog"; +import type { AppServerRequestClient } from "../../../../app-server/services/request-client"; +import { readToolInventory } from "../../../../app-server/services/tool-inventory"; +import type { DiagnosticProbeId, DiagnosticProbeResult } from "../../../../domain/server/diagnostics"; +import { diagnosticProbeError, diagnosticProbeOk } from "../../../../domain/server/diagnostics"; +import type { + MetadataResourceTransport, + ServerDiagnosticsSnapshot, + ServerDiagnosticsTransport, +} from "../../application/connection/metadata-transport"; + +interface CurrentChatAppServerClientHost { + currentClient(): AppServerClient | null; +} + +interface ChatAppServerMetadataTransportHost extends CurrentChatAppServerClientHost { + vaultPath: string; +} + +export interface ChatMetadataTransports { + readonly metadataResource: MetadataResourceTransport; + readonly serverDiagnostics: ServerDiagnosticsTransport; +} + +interface DiagnosticProbeSnapshot { + probe: DiagnosticProbeResult; +} + +export function createChatMetadataTransports(host: ChatAppServerMetadataTransportHost): ChatMetadataTransports { + return { + metadataResource: createChatMetadataResourceTransport(host), + serverDiagnostics: createChatServerDiagnosticsTransport(host), + }; +} + +function createChatMetadataResourceTransport(host: ChatAppServerMetadataTransportHost): MetadataResourceTransport { + return { + readSkillMetadata: (forceReload) => + withCapturedChatAppServerClient(host, (client) => readSkillMetadataProbe(client, host.vaultPath, forceReload)), + readRateLimitMetadata: () => withCapturedChatAppServerClient(host, (client) => readRateLimitMetadataProbe(client)), + }; +} + +function createChatServerDiagnosticsTransport(host: ChatAppServerMetadataTransportHost): ServerDiagnosticsTransport { + return { + readServerDiagnostics: async (request): Promise => { + const client = host.currentClient(); + if (!client) return null; + const toolInventory = readToolInventory(client, host.vaultPath, { + threadId: request.threadId, + mcpDiagnostics: request.initialDiagnostics.mcpServers, + ...(request.cachedSkills !== undefined ? { cachedSkills: request.cachedSkills } : {}), + ...(request.cachedSkillsProbe !== undefined ? { cachedSkillsProbe: request.cachedSkillsProbe } : {}), + }); + const probes: Promise[] = []; + if (request.forceResourceProbes && !request.appServerMetadataSnapshot) { + probes.push( + probeDiagnostic( + "models", + () => listModelMetadata(client), + (models) => `${String(models.length)} models`, + ), + readRateLimitDiagnosticProbe(client), + ); + } + + const [resourceProbes, toolInventoryResult] = await Promise.all([Promise.all(probes), toolInventory]); + if (host.currentClient() !== client) return null; + return { + resourceProbes: resourceProbes.map((result) => result.probe), + toolInventory: toolInventoryResult, + }; + }, + }; +} + +async function withCapturedChatAppServerClient( + host: CurrentChatAppServerClientHost, + operation: (client: AppServerClient | null) => Promise, +): Promise { + const client = host.currentClient(); + const result = await operation(client); + return client !== null && host.currentClient() !== client ? null : result; +} + +async function readRateLimitDiagnosticProbe(client: AppServerRequestClient): Promise { + const result = await readRateLimitMetadataProbe(client); + return { probe: result.probe }; +} + +async function probeDiagnostic( + id: DiagnosticProbeId, + request: () => Promise, + summarize: (response: T) => string | null, +): Promise { + try { + const response = await request(); + return { + probe: diagnosticProbeOk(id, summarize(response), Date.now()), + }; + } catch (error) { + return { probe: diagnosticProbeError(id, error, Date.now()) }; + } +} diff --git a/src/features/chat/app-server/transports/session-transports.ts b/src/features/chat/app-server/transports/session-transports.ts index 6b632375..18c550e4 100644 --- a/src/features/chat/app-server/transports/session-transports.ts +++ b/src/features/chat/app-server/transports/session-transports.ts @@ -10,6 +10,7 @@ import { resumeThread, rollbackThread, setThreadGoal, + startThread, threadActivationSnapshotFromAppServerResponse, updateThreadSettings, } from "../../../../app-server/services/threads"; @@ -26,6 +27,7 @@ import type { ThreadResumeTransport, } from "../../application/threads/thread-loading-transport"; import type { ThreadMutationTransport, ThreadRollbackSnapshot } from "../../application/threads/thread-mutation-transport"; +import type { ThreadStartTransport } from "../../application/threads/thread-start-transport"; import { messageStreamItemsFromTurns } from "../mappers/message-stream/turn-items"; interface CurrentChatAppServerClientHost { @@ -51,6 +53,7 @@ interface AppServerThreadTurnsPage { export interface ChatSessionTransports { readonly turn: ChatTurnTransport; readonly runtimeSettings: RuntimeSettingsTransport; + readonly threadStart: ThreadStartTransport; readonly threadHistory: ThreadHistoryTransport; readonly threadResume: ThreadResumeTransport; readonly threadMutation: ThreadMutationTransport; @@ -62,6 +65,7 @@ export function createChatSessionTransports(host: ChatAppServerTransportHost): C return { turn: createChatTurnTransport(host), runtimeSettings: createChatRuntimeSettingsTransport(host), + threadStart: createChatThreadStartTransport(host), threadHistory: createChatThreadHistoryTransport(host), threadResume: createChatThreadResumeTransport(host), threadMutation: createChatThreadMutationTransport(host), @@ -70,6 +74,20 @@ export function createChatSessionTransports(host: ChatAppServerTransportHost): C }; } +function createChatThreadStartTransport(host: ChatAppServerTransportHost): ThreadStartTransport { + return { + startThread: (request) => + withCurrentChatAppServerClient(host, async (client) => { + const response = await startThread(client, { + cwd: host.vaultPath, + serviceTier: request.serviceTier, + permissions: request.permissions, + }); + return threadActivationSnapshotFromAppServerResponse(response); + }), + }; +} + function createChatTurnTransport(host: ChatAppServerTransportHost): ChatTurnTransport { return { ensureConnected: async () => (await host.connectedClient()) !== null, diff --git a/src/features/chat/application/connection/metadata-transport.ts b/src/features/chat/application/connection/metadata-transport.ts new file mode 100644 index 00000000..428978c6 --- /dev/null +++ b/src/features/chat/application/connection/metadata-transport.ts @@ -0,0 +1,40 @@ +import type { SkillMetadata } from "../../../../domain/catalog/metadata"; +import type { RateLimitSnapshot } from "../../../../domain/runtime/metrics"; +import type { DiagnosticProbeResult, Diagnostics } from "../../../../domain/server/diagnostics"; +import type { McpServerStatusSummary } from "../../../../domain/server/mcp-status"; +import type { ToolInventorySnapshot } from "../../../../domain/server/tool-inventory"; + +interface MetadataProbeResult { + value: T; + probe: Diagnostics["probes"][K]; +} + +type SkillMetadataProbeResult = MetadataProbeResult; +export type RateLimitMetadataProbeResult = MetadataProbeResult; + +export interface MetadataResourceTransport { + readSkillMetadata(forceReload?: boolean): Promise; + readRateLimitMetadata(): Promise; +} + +interface ServerDiagnosticsReadRequest { + threadId: string | null; + initialDiagnostics: Diagnostics; + cachedSkills?: readonly SkillMetadata[]; + cachedSkillsProbe?: DiagnosticProbeResult; + forceResourceProbes: boolean; + appServerMetadataSnapshot: boolean; +} + +export interface ServerDiagnosticsSnapshot { + resourceProbes: readonly DiagnosticProbeResult[]; + toolInventory: { + inventory: ToolInventorySnapshot; + probes: readonly DiagnosticProbeResult[]; + mcpServerStatuses: readonly McpServerStatusSummary[] | null; + }; +} + +export interface ServerDiagnosticsTransport { + readServerDiagnostics(request: ServerDiagnosticsReadRequest): Promise; +} diff --git a/src/features/chat/application/connection/server-diagnostics-actions.ts b/src/features/chat/application/connection/server-diagnostics-actions.ts new file mode 100644 index 00000000..df9424fe --- /dev/null +++ b/src/features/chat/application/connection/server-diagnostics-actions.ts @@ -0,0 +1,80 @@ +import { + cloneServerDiagnostics, + diagnosticsWithProbe, + diagnosticsWithToolInventory, + upsertMcpServerStatusDiagnostics, +} from "../../../../domain/server/diagnostics"; +import type { SharedServerMetadata } from "../../../../domain/server/metadata"; +import type { ChatStateStore } from "../state/store"; +import type { ServerDiagnosticsTransport } from "./metadata-transport"; + +interface RefreshServerDiagnosticsOptions { + appServerMetadataSnapshot?: boolean; + forceResourceProbes?: boolean; +} + +export interface ServerDiagnosticsActionsHost { + stateStore: ChatStateStore; + diagnosticsTransport: ServerDiagnosticsTransport; + updateAppServerMetadata: (updater: (metadata: SharedServerMetadata | null) => SharedServerMetadata | null) => SharedServerMetadata | null; + appServerMetadataSnapshot: () => SharedServerMetadata | null; +} + +export interface ServerDiagnosticsActions { + refreshServerDiagnostics: (options?: RefreshServerDiagnosticsOptions) => Promise; +} + +export function createServerDiagnosticsActions(host: ServerDiagnosticsActionsHost): ServerDiagnosticsActions { + return { + refreshServerDiagnostics: async (options) => { + await refreshServerDiagnostics(host, options); + }, + }; +} + +async function refreshServerDiagnostics( + host: ServerDiagnosticsActionsHost, + options: RefreshServerDiagnosticsOptions = {}, +): Promise { + const initialDiagnostics = currentMetadataDiagnostics(host); + const state = host.stateStore.getState(); + const activeThreadId = state.activeThread.id; + const metadataSnapshot = host.appServerMetadataSnapshot(); + const cachedSkills = + options.forceResourceProbes === true ? undefined : (metadataSnapshot?.availableSkills ?? state.connection.availableSkills); + const cachedSkillsProbe = + options.forceResourceProbes === true + ? undefined + : (metadataSnapshot?.serverDiagnostics.probes.skills ?? state.connection.serverDiagnostics.probes.skills); + const request = { + threadId: activeThreadId, + initialDiagnostics, + forceResourceProbes: options.forceResourceProbes === true, + appServerMetadataSnapshot: options.appServerMetadataSnapshot === true, + ...(cachedSkills !== undefined ? { cachedSkills } : {}), + ...(cachedSkillsProbe !== undefined ? { cachedSkillsProbe } : {}), + }; + const snapshot = await host.diagnosticsTransport.readServerDiagnostics(request); + if (!snapshot) return false; + + let diagnostics = currentMetadataDiagnostics(host); + for (const probe of snapshot.resourceProbes) { + diagnostics = diagnosticsWithProbe(diagnostics, probe); + } + for (const probe of snapshot.toolInventory.probes) { + diagnostics = diagnosticsWithProbe(diagnostics, probe); + } + if (snapshot.toolInventory.mcpServerStatuses) { + diagnostics = upsertMcpServerStatusDiagnostics(diagnostics, snapshot.toolInventory.mcpServerStatuses); + } + diagnostics = diagnosticsWithToolInventory(diagnostics, snapshot.toolInventory.inventory); + host.updateAppServerMetadata((metadata) => (metadata ? { ...metadata, serverDiagnostics: diagnostics } : null)); + host.stateStore.dispatch({ type: "connection/metadata-applied", serverDiagnostics: diagnostics }); + return true; +} + +function currentMetadataDiagnostics(host: ServerDiagnosticsActionsHost): SharedServerMetadata["serverDiagnostics"] { + return ( + host.appServerMetadataSnapshot()?.serverDiagnostics ?? cloneServerDiagnostics(host.stateStore.getState().connection.serverDiagnostics) + ); +} diff --git a/src/features/chat/app-server/actions/metadata.ts b/src/features/chat/application/connection/server-metadata-actions.ts similarity index 75% rename from src/features/chat/app-server/actions/metadata.ts rename to src/features/chat/application/connection/server-metadata-actions.ts index 6cb11c73..7d0c0afc 100644 --- a/src/features/chat/app-server/actions/metadata.ts +++ b/src/features/chat/application/connection/server-metadata-actions.ts @@ -1,32 +1,30 @@ -import { - type RateLimitMetadataProbeResult, - readRateLimitMetadataProbe, - readSkillMetadataProbe, -} from "../../../../app-server/query/metadata-probes"; -import { isStaleAppServerSharedQueryContextError } from "../../../../app-server/query/shared-queries"; import { cloneServerDiagnostics, diagnosticsWithProbe, upsertMcpServerDiagnostic } from "../../../../domain/server/diagnostics"; import type { McpServerStartupStatus } from "../../../../domain/server/mcp-status"; import type { SharedServerMetadata } from "../../../../domain/server/metadata"; -import { type ChatServerActionsHost, captureChatServerClientScope } from "./host"; +import type { ChatStateStore } from "../state/store"; +import type { MetadataResourceTransport, RateLimitMetadataProbeResult } from "./metadata-transport"; export type AppServerResourceEvent = | { type: "skills-changed"; forceReload: boolean } | { type: "rate-limits-updated"; preserveExistingOnFailure?: boolean } | { type: "mcp-startup-status-updated"; name: string; status: McpServerStartupStatus; message: string | null }; -export interface ChatServerMetadataActionsHost extends ChatServerActionsHost { +export interface ServerMetadataActionsHost { + stateStore: ChatStateStore; + metadataResourceTransport: MetadataResourceTransport; updateAppServerMetadata: (updater: (metadata: SharedServerMetadata | null) => SharedServerMetadata | null) => SharedServerMetadata | null; appServerMetadataSnapshot: () => SharedServerMetadata | null; refreshAppServerMetadata: (options?: { forceSkills?: boolean }) => Promise; + isStaleSharedQueryError: (error: unknown) => boolean; } -export interface ChatServerMetadataActions { +export interface ServerMetadataActions { applyAppServerMetadata: (metadata: SharedServerMetadata) => void; refreshAppServerMetadata: () => Promise; applyAppServerResourceEvent: (event: AppServerResourceEvent) => Promise; } -export function createChatServerMetadataActions(host: ChatServerMetadataActionsHost): ChatServerMetadataActions { +export function createServerMetadataActions(host: ServerMetadataActionsHost): ServerMetadataActions { return { applyAppServerMetadata: (metadata) => { applyAppServerMetadata(host, metadata); @@ -38,7 +36,7 @@ export function createChatServerMetadataActions(host: ChatServerMetadataActionsH }; } -async function applyAppServerResourceEvent(host: ChatServerMetadataActionsHost, event: AppServerResourceEvent): Promise { +async function applyAppServerResourceEvent(host: ServerMetadataActionsHost, event: AppServerResourceEvent): Promise { switch (event.type) { case "skills-changed": await refreshSkillResource(host, event.forceReload); @@ -55,7 +53,7 @@ async function applyAppServerResourceEvent(host: ChatServerMetadataActionsHost, } } -function applyAppServerMetadata(host: ChatServerMetadataActionsHost, metadata: SharedServerMetadata): void { +function applyAppServerMetadata(host: ServerMetadataActionsHost, metadata: SharedServerMetadata): void { host.stateStore.dispatch({ type: "connection/metadata-applied", runtimeConfig: metadata.runtimeConfig, @@ -67,12 +65,12 @@ function applyAppServerMetadata(host: ChatServerMetadataActionsHost, metadata: S }); } -async function refreshAppServerMetadata(host: ChatServerMetadataActionsHost): Promise { +async function refreshAppServerMetadata(host: ServerMetadataActionsHost): Promise { let metadata: SharedServerMetadata | null; try { metadata = await host.refreshAppServerMetadata(); } catch (error) { - if (isStaleAppServerSharedQueryContextError(error)) return null; + if (host.isStaleSharedQueryError(error)) return null; throw error; } if (!metadata) return null; @@ -80,15 +78,14 @@ async function refreshAppServerMetadata(host: ChatServerMetadataActionsHost): Pr return metadata; } -function applyCurrentAppServerMetadataSnapshot(host: ChatServerMetadataActionsHost): void { +function applyCurrentAppServerMetadataSnapshot(host: ServerMetadataActionsHost): void { const metadata = host.appServerMetadataSnapshot(); if (metadata) applyAppServerMetadata(host, metadata); } -async function refreshSkillResource(host: ChatServerMetadataActionsHost, forceReload = false): Promise { - const scope = captureChatServerClientScope(host); - const skills = await readSkillMetadataProbe(scope.client, host.vaultPath, forceReload); - if (scope.isStale()) return null; +async function refreshSkillResource(host: ServerMetadataActionsHost, forceReload = false): Promise { + const skills = await host.metadataResourceTransport.readSkillMetadata(forceReload); + if (!skills) return null; const next = host.updateAppServerMetadata((metadata) => { if (!metadata) return null; return { @@ -115,12 +112,11 @@ async function refreshSkillResource(host: ChatServerMetadataActionsHost, forceRe } async function refreshRateLimitResource( - host: ChatServerMetadataActionsHost, + host: ServerMetadataActionsHost, options: { preserveExistingOnFailure?: boolean } = {}, ): Promise { - const scope = captureChatServerClientScope(host); - const rateLimit = await readRateLimitMetadataProbe(scope.client); - if (scope.isStale()) return; + const rateLimit = await host.metadataResourceTransport.readRateLimitMetadata(); + if (!rateLimit) return; const preserveExistingOnFailure = options.preserveExistingOnFailure === true; const next = updateRateLimitMetadata(host, rateLimit, { preserveRateLimitOnFailure: preserveExistingOnFailure }); if (next) { @@ -140,7 +136,7 @@ async function refreshRateLimitResource( } function updateRateLimitMetadata( - host: ChatServerMetadataActionsHost, + host: ServerMetadataActionsHost, rateLimit: RateLimitMetadataProbeResult, options: { preserveRateLimitOnFailure: boolean }, ): SharedServerMetadata | null { @@ -156,7 +152,7 @@ function updateRateLimitMetadata( } function applyMcpStartupStatusEvent( - host: ChatServerMetadataActionsHost, + host: ServerMetadataActionsHost, name: string, startupStatus: McpServerStartupStatus, message: string | null, @@ -175,7 +171,7 @@ function applyMcpStartupStatusEvent( }); } -function currentMetadataDiagnostics(host: ChatServerMetadataActionsHost): SharedServerMetadata["serverDiagnostics"] { +function currentMetadataDiagnostics(host: ServerMetadataActionsHost): SharedServerMetadata["serverDiagnostics"] { return ( host.appServerMetadataSnapshot()?.serverDiagnostics ?? cloneServerDiagnostics(host.stateStore.getState().connection.serverDiagnostics) ); diff --git a/src/features/chat/application/threads/thread-start-actions.ts b/src/features/chat/application/threads/thread-start-actions.ts new file mode 100644 index 00000000..45eb38f6 --- /dev/null +++ b/src/features/chat/application/threads/thread-start-actions.ts @@ -0,0 +1,62 @@ +import { runtimeConfigOrDefault } from "../../../../domain/runtime/config"; +import type { Thread } from "../../../../domain/threads/model"; +import type { RuntimeSnapshot } from "../../domain/runtime/snapshot"; +import { permissionProfileRequestForThreadStart, serviceTierRequestForThreadStart } from "../../domain/runtime/thread-settings-patch"; +import { resumedThreadAction } from "../state/actions"; +import type { ChatState } from "../state/root-reducer"; +import type { ChatStateStore } from "../state/store"; +import type { ThreadStartTransport } from "./thread-start-transport"; + +interface StartedThreadSummary { + threadId: string; +} + +export interface ThreadStartActionsHost { + stateStore: ChatStateStore; + threadStartTransport: ThreadStartTransport; + runtimeSnapshotForState: (state: ChatState) => RuntimeSnapshot; + recordStartedThread: (thread: Thread) => void; + syncThreadGoal: (threadId: string) => void; +} + +export interface ThreadStartActions { + startThread: (preview?: string, options?: { syncGoal?: boolean }) => Promise; +} + +export function createThreadStartActions(host: ThreadStartActionsHost): ThreadStartActions { + return { + startThread: (preview, options) => startThread(host, preview, options), + }; +} + +async function startThread( + host: ThreadStartActionsHost, + preview?: string, + options: { syncGoal?: boolean } = {}, +): Promise { + const requestState = host.stateStore.getState(); + const runtimeSnapshot = host.runtimeSnapshotForState(requestState); + const runtimeConfig = runtimeConfigOrDefault(requestState.connection.runtimeConfig); + const activation = await host.threadStartTransport.startThread({ + serviceTier: serviceTierRequestForThreadStart(runtimeSnapshot, runtimeConfig), + permissions: permissionProfileRequestForThreadStart(runtimeSnapshot, runtimeConfig), + }); + if (!activation) return null; + + const state = host.stateStore.getState(); + const fallbackPreview = preview?.trim(); + const thread = + activation.thread.preview.trim().length > 0 || !fallbackPreview + ? activation.thread + : { ...activation.thread, preview: fallbackPreview }; + const patchedActivation = thread === activation.thread ? activation : { ...activation, thread }; + const action = resumedThreadAction({ + response: patchedActivation, + listedThreads: state.threadList.listedThreads, + preserveRequestedRuntimeSettings: requestState.activeThread.id === null, + }); + host.stateStore.dispatch(action); + host.recordStartedThread(action.thread); + if (options.syncGoal ?? true) host.syncThreadGoal(action.thread.id); + return { threadId: action.thread.id }; +} diff --git a/src/features/chat/application/threads/thread-start-transport.ts b/src/features/chat/application/threads/thread-start-transport.ts new file mode 100644 index 00000000..d51f3249 --- /dev/null +++ b/src/features/chat/application/threads/thread-start-transport.ts @@ -0,0 +1,11 @@ +import type { RuntimeServiceTierRequest, RuntimeSettingsPatch } from "../../../../domain/runtime/thread-settings"; +import type { ThreadActivationSnapshot } from "../../../../domain/threads/activation"; + +interface ThreadStartRequest { + serviceTier?: RuntimeServiceTierRequest; + permissions?: RuntimeSettingsPatch["permissions"]; +} + +export interface ThreadStartTransport { + startThread(request: ThreadStartRequest): Promise; +} diff --git a/src/features/chat/host/bundles/connection-bundle.ts b/src/features/chat/host/bundles/connection-bundle.ts index c30b9ed4..d75f9164 100644 --- a/src/features/chat/host/bundles/connection-bundle.ts +++ b/src/features/chat/host/bundles/connection-bundle.ts @@ -3,22 +3,21 @@ import { Notice } from "obsidian"; import type { AppServerClient } from "../../../../app-server/connection/client"; import { type ConnectionManager, StaleConnectionError } from "../../../../app-server/connection/connection-manager"; import { isStaleAppServerSharedQueryContextError } from "../../../../app-server/query/shared-queries"; -import { type ChatServerDiagnosticsActions, createChatServerDiagnosticsActions } from "../../app-server/actions/diagnostics"; -import { type ChatServerMetadataActions, createChatServerMetadataActions } from "../../app-server/actions/metadata"; -import { type ChatServerThreadActions, createChatServerThreadActions } from "../../app-server/actions/threads"; +import type { SharedServerMetadata } from "../../../../domain/server/metadata"; import { type ChatInboundHandler, createChatInboundHandler } from "../../app-server/inbound/handler"; +import type { ChatAppServerGateway } from "../../app-server/session-gateway"; import { type ChatConnectionActions, createChatConnectionActions, handleChatConnectionExit, } from "../../application/connection/connection-actions"; import type { ConnectionWorkTracker } from "../../application/connection/connection-work"; +import { createServerDiagnosticsActions } from "../../application/connection/server-diagnostics-actions"; +import { createServerMetadataActions } from "../../application/connection/server-metadata-actions"; import type { LocalIdSource } from "../../application/local-id-source"; -import { runtimeSnapshotForChatState } from "../../application/runtime/snapshot"; 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 "../contracts"; import type { ChatViewDeferredTasks } from "../session/deferred-work"; @@ -26,7 +25,6 @@ type CurrentAppServerClient = () => AppServerClient | null; type RespondRequestId = Parameters[0]; type RejectRequestId = Parameters[0]; -type ChatPanelGoalSyncActions = ReturnType; interface ChatPanelConnectionStatus { set: (statusText: string, phase?: ChatConnectionPhase) => void; @@ -36,9 +34,9 @@ interface ChatPanelConnectionStatus { interface ChatPanelConnectionBundleInput { connection: ConnectionManager; currentClient: CurrentAppServerClient; + appServer: ChatAppServerGateway; localItemIds: LocalIdSource; status: ChatPanelConnectionStatus; - goalSync: ChatPanelGoalSyncActions; autoTitleCoordinator: AutoTitleCoordinator; } @@ -59,10 +57,8 @@ export interface ChatPanelConnectionBundle { actions: ChatConnectionActions; }; inboundHandler: ChatInboundHandler; - serverActions: { - threads: ChatServerThreadActions; - metadata: ChatServerMetadataActions; - diagnostics: ChatServerDiagnosticsActions; + sharedStateActions: { + applyAppServerMetadata: (metadata: SharedServerMetadata) => void; }; refreshSharedThreads: () => Promise; } @@ -113,37 +109,24 @@ export function createConnectionBundle( input: ChatPanelConnectionBundleInput, ): ChatPanelConnectionBundle { const { environment, stateStore } = host; - const { connection, currentClient, localItemIds, status, goalSync, autoTitleCoordinator } = input; - const serverMetadata = createChatServerMetadataActions({ + const { connection, currentClient, appServer, localItemIds, status, autoTitleCoordinator } = input; + const serverMetadata = createServerMetadataActions({ stateStore, - vaultPath: environment.plugin.settingsRef.vaultPath, - currentClient, + metadataResourceTransport: appServer.metadataResource, updateAppServerMetadata: (updater) => environment.plugin.appServerQueries.updateAppServerMetadata(updater), appServerMetadataSnapshot: () => environment.plugin.appServerQueries.appServerMetadataSnapshot(), refreshAppServerMetadata: (options) => environment.plugin.appServerQueries.refreshAppServerMetadata(options), + isStaleSharedQueryError: isStaleAppServerSharedQueryContextError, }); - const serverDiagnostics = createChatServerDiagnosticsActions({ + const serverDiagnostics = createServerDiagnosticsActions({ stateStore, - vaultPath: environment.plugin.settingsRef.vaultPath, - currentClient, + diagnosticsTransport: appServer.serverDiagnostics, updateAppServerMetadata: (updater) => environment.plugin.appServerQueries.updateAppServerMetadata(updater), appServerMetadataSnapshot: () => environment.plugin.appServerQueries.appServerMetadataSnapshot(), }); - const serverThreads = createChatServerThreadActions({ - stateStore, - vaultPath: environment.plugin.settingsRef.vaultPath, - currentClient, - runtimeSnapshotForState: runtimeSnapshotForChatState, - applyThreadCatalogEvent: (event) => { - environment.plugin.threadCatalog.apply(event); - }, - syncThreadGoal: (threadId) => { - void goalSync.syncThreadGoal(threadId); - }, - }); const refreshSharedThreads = async (): Promise => { const threads = await environment.plugin.threadCatalog.refreshActive(); - serverThreads.applyThreadList(threads); + stateStore.dispatch({ type: "thread-list/applied", threads, threadsLoaded: true }); }; const refreshSharedThreadsQuietly = (): void => { void refreshSharedThreads().catch((error: unknown) => { @@ -258,10 +241,10 @@ export function createConnectionBundle( actions: connectionActions, }, inboundHandler, - serverActions: { - threads: serverThreads, - metadata: serverMetadata, - diagnostics: serverDiagnostics, + sharedStateActions: { + applyAppServerMetadata: (metadata) => { + serverMetadata.applyAppServerMetadata(metadata); + }, }, refreshSharedThreads, }; diff --git a/src/features/chat/host/bundles/thread-bundle.ts b/src/features/chat/host/bundles/thread-bundle.ts index 940ae903..3783430e 100644 --- a/src/features/chat/host/bundles/thread-bundle.ts +++ b/src/features/chat/host/bundles/thread-bundle.ts @@ -3,7 +3,6 @@ import { Notice } from "obsidian"; import { recoverRolloutTokenUsage } from "../../../../app-server/services/rollout-token-usage"; import { createThreadOperations, type ThreadOperations } from "../../../threads/workflows/thread-operations"; import { createThreadTitleService, type ThreadTitleService } from "../../../threads/workflows/thread-title-service"; -import type { ChatServerThreadActions } from "../../app-server/actions/threads"; import type { ChatAppServerGateway } from "../../app-server/session-gateway"; import type { LocalIdSource } from "../../application/local-id-source"; import { messageStreamItems } from "../../application/state/message-stream"; @@ -22,6 +21,7 @@ import { createResumeActions, type ResumeActions } from "../../application/threa 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"; +import type { ThreadStartActions } from "../../application/threads/thread-start-actions"; import { threadTitleContextFromMessageStreamItems } from "../../application/threads/title-context"; import type { ChatComposerController } from "../../panel/composer-controller"; import { createToolbarPanelActions, type ToolbarPanelActions } from "../../panel/toolbar-actions"; @@ -75,7 +75,7 @@ interface ChatPanelThreadLifecycleInput { localItemIds: LocalIdSource; ensureConnected: () => Promise; status: ChatPanelThreadStatus; - serverThreads: ChatServerThreadActions; + threadStart: ThreadStartActions; foundation: ChatPanelThreadFoundation; refreshTabHeader: () => void; refreshLiveState: () => void; @@ -188,7 +188,7 @@ export function createThreadLifecycleBundle( localItemIds, ensureConnected, status, - serverThreads, + threadStart, foundation, refreshTabHeader, refreshLiveState, @@ -198,7 +198,7 @@ export function createThreadLifecycleBundle( stateStore: host.stateStore, goalTransport: appServer.threadGoal, localItemIds, - startThread: (preview, options) => serverThreads.startThread(preview, options), + startThread: (preview, options) => threadStart.startThread(preview, options), addSystemMessage: (text) => { status.addSystemMessage(text); }, diff --git a/src/features/chat/host/bundles/turn-bundle.ts b/src/features/chat/host/bundles/turn-bundle.ts index ef7ee1e9..d9ca77e3 100644 --- a/src/features/chat/host/bundles/turn-bundle.ts +++ b/src/features/chat/host/bundles/turn-bundle.ts @@ -1,4 +1,3 @@ -import type { ChatServerThreadActions } from "../../app-server/actions/threads"; import type { ChatInboundHandler } from "../../app-server/inbound/handler"; import type { ChatAppServerGateway } from "../../app-server/session-gateway"; import { @@ -9,6 +8,7 @@ import type { LocalIdSource } from "../../application/local-id-source"; import { createPendingRequestActions, type PendingRequestActions } from "../../application/pending-requests/pending-request-actions"; import type { ChatStateStore } from "../../application/state/store"; import type { AutoTitleCoordinator } from "../../application/threads/auto-title-coordinator"; +import type { ThreadStartActions } from "../../application/threads/thread-start-actions"; import type { MessageStreamNoticeSection } from "../../domain/message-stream/items"; import type { ChatComposerController } from "../../panel/composer-controller"; import type { ChatPanelRuntimeProjection } from "../../panel/runtime-status-projection"; @@ -51,7 +51,7 @@ interface ChatPanelTurnInput { navigation: ChatPanelThreadNavigationActions; composerController: ChatComposerController; runtimeSettings: ChatPanelRuntimeSettingsActions; - serverThreads: ChatServerThreadActions; + threadStart: ThreadStartActions; goals: ChatPanelGoalActions; autoTitleCoordinator: AutoTitleCoordinator; reconnect: () => Promise; @@ -72,7 +72,7 @@ export function createTurnBundle(host: ChatPanelTurnHost, input: ChatPanelTurnIn navigation, composerController, runtimeSettings, - serverThreads, + threadStart, goals, autoTitleCoordinator, reconnect, @@ -153,7 +153,7 @@ export function createTurnBundle(host: ChatPanelTurnHost, input: ChatPanelTurnIn }, }, { - threadStarter: serverThreads, + threadStarter: threadStart, runtimeSettings, threadActions, reconnectPanel: reconnect, diff --git a/src/features/chat/host/session-graph.ts b/src/features/chat/host/session-graph.ts index 50f07536..d321f818 100644 --- a/src/features/chat/host/session-graph.ts +++ b/src/features/chat/host/session-graph.ts @@ -5,12 +5,14 @@ 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 { runtimeSnapshotForChatState } from "../application/runtime/snapshot"; import type { ChatAction, ChatConnectionPhase } from "../application/state/root-reducer"; import type { ChatStateStore } from "../application/state/store"; import type { ActiveThreadIdentitySync } from "../application/threads/active-thread-identity-sync"; import type { RestorationController } from "../application/threads/restoration-controller"; import type { ResumeActions } from "../application/threads/resume-actions"; import type { ChatResumeWorkTracker } from "../application/threads/resume-work"; +import { createThreadStartActions } from "../application/threads/thread-start-actions"; import { createStructuredSystemItem, createSystemItem } from "../domain/message-stream/factories/system-items"; import type { MessageStreamNoticeSection } from "../domain/message-stream/items"; import type { ChatComposerController } from "../panel/composer-controller"; @@ -125,8 +127,8 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch { connection, currentClient, + appServer, localItemIds, - goalSync: threadFoundation.goalSync, autoTitleCoordinator: threadFoundation.autoTitleCoordinator, status, }, @@ -135,7 +137,6 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch connection: { actions: connectionActions }, inboundHandler, } = connectionBundle; - const { threads: serverThreads } = connectionBundle.serverActions; ensureConnected = () => connectionActions.ensureConnected(); const refreshActiveThreads = () => connectionActions.refreshActiveThreads(); const runtime = createRuntimeBundle(host, { @@ -143,12 +144,23 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch appServer, status, }); + const threadStart = createThreadStartActions({ + stateStore, + threadStartTransport: appServer.threadStart, + runtimeSnapshotForState: runtimeSnapshotForChatState, + recordStartedThread: (thread) => { + environment.plugin.threadCatalog.apply({ type: "thread-started", thread }); + }, + syncThreadGoal: (threadId) => { + void threadFoundation.goalSync.syncThreadGoal(threadId); + }, + }); const threadLifecycle = createThreadLifecycleBundle(host, { appServer, localItemIds, ensureConnected, status, - serverThreads, + threadStart, foundation: threadFoundation, refreshTabHeader, refreshLiveState, @@ -200,7 +212,7 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch navigation: threadActions.navigation, composerController, runtimeSettings: runtime.settings, - serverThreads, + threadStart, goals: threadLifecycle.goals, autoTitleCoordinator: threadFoundation.autoTitleCoordinator, reconnect, @@ -235,7 +247,7 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch stateStore, threadCatalog: environment.plugin.threadCatalog, appServerQueries: environment.plugin.appServerQueries, - serverActions: connectionBundle.serverActions, + applyAppServerMetadata: connectionBundle.sharedStateActions.applyAppServerMetadata, refreshTabHeader, }); diff --git a/src/features/chat/host/session/shared-state-binding.ts b/src/features/chat/host/session/shared-state-binding.ts index 6da3c857..5517d650 100644 --- a/src/features/chat/host/session/shared-state-binding.ts +++ b/src/features/chat/host/session/shared-state-binding.ts @@ -3,7 +3,6 @@ import type { ModelMetadata } from "../../../../domain/catalog/metadata"; 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 "../bundles/connection-bundle"; type ThreadObserver = (result: ObservedResult) => void; type MetadataObserver = (result: ObservedResult) => void; @@ -31,16 +30,16 @@ export interface ChatPanelSharedStateBindingOptions { stateStore: ChatStateStore; threadCatalog: SharedStateThreadCatalog; appServerQueries: SharedStateAppServerQueries; - serverActions: ChatPanelConnectionBundle["serverActions"]; + applyAppServerMetadata: (metadata: SharedServerMetadata) => void; refreshTabHeader: () => void; } export function createChatPanelSharedStateBinding(options: ChatPanelSharedStateBindingOptions): ChatPanelSharedStateBinding { const unsubscribers: (() => void)[] = []; - const { stateStore, threadCatalog, appServerQueries, serverActions, refreshTabHeader } = options; + const { stateStore, threadCatalog, appServerQueries, applyAppServerMetadata, refreshTabHeader } = options; const receiveThreads = (threads: readonly Thread[]): void => { - serverActions.threads.applyThreadList(threads); + stateStore.dispatch({ type: "thread-list/applied", threads, threadsLoaded: true }); refreshTabHeader(); }; const receiveThreadResult = (result: ObservedResult): void => { @@ -48,7 +47,7 @@ export function createChatPanelSharedStateBinding(options: ChatPanelSharedStateB if (observedThreads) receiveThreads(observedThreads); }; const receiveAppServerMetadata = (metadata: SharedServerMetadata): void => { - serverActions.metadata.applyAppServerMetadata(metadata); + applyAppServerMetadata(metadata); }; const receiveAppServerMetadataResult = (result: ObservedResult): void => { const observedMetadata = result.value; @@ -68,9 +67,9 @@ export function createChatPanelSharedStateBinding(options: ChatPanelSharedStateB }; const applyCached = (): void => { const threads = threadCatalog.activeSnapshot(); - if (threads) serverActions.threads.applyThreadList(threads); + if (threads) stateStore.dispatch({ type: "thread-list/applied", threads, threadsLoaded: true }); const metadata = appServerQueries.appServerMetadataSnapshot(); - if (metadata) serverActions.metadata.applyAppServerMetadata(metadata); + if (metadata) applyAppServerMetadata(metadata); const models = appServerQueries.modelsSnapshot(); if (models) receiveModels(models); }; diff --git a/tests/features/chat/app-server/actions/actions.test.ts b/tests/features/chat/app-server/actions/actions.test.ts deleted file mode 100644 index a4737b76..00000000 --- a/tests/features/chat/app-server/actions/actions.test.ts +++ /dev/null @@ -1,853 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import type { AppServerClient, ClientResponseByMethod } from "../../../../../src/app-server/connection/client"; -import type { ClientRequestParams } from "../../../../../src/app-server/connection/rpc-messages"; -import { - type CatalogModel, - type CatalogSkillMetadata, - modelMetadataFromCatalogModels, -} from "../../../../../src/app-server/protocol/catalog"; -import { threadFromThreadRecord } from "../../../../../src/app-server/protocol/thread"; -import { StaleAppServerSharedQueryContextError } from "../../../../../src/app-server/query/shared-queries"; -import { emptyRuntimeConfigSnapshot } from "../../../../../src/domain/runtime/config"; -import type { RateLimitSnapshot } from "../../../../../src/domain/runtime/metrics"; -import { - createServerDiagnostics, - diagnosticProbeError, - diagnosticProbeOk, - diagnosticsWithProbe, -} from "../../../../../src/domain/server/diagnostics"; -import type { McpServerStatus } from "../../../../../src/domain/server/mcp-status"; -import type { SharedServerMetadata } from "../../../../../src/domain/server/metadata"; -import { createChatServerDiagnosticsActions } from "../../../../../src/features/chat/app-server/actions/diagnostics"; -import { createChatServerMetadataActions } from "../../../../../src/features/chat/app-server/actions/metadata"; -import { createChatServerThreadActions } from "../../../../../src/features/chat/app-server/actions/threads"; -import { runtimeSnapshotForChatState } from "../../../../../src/features/chat/application/runtime/snapshot"; -import { createChatStateStore } from "../../../../../src/features/chat/application/state/store"; -import { setCollaborationModeIntent } from "../../../../../src/features/chat/domain/runtime/intent"; -import { toolInventoryDiagnosticSections } from "../../../../../src/features/chat/presentation/runtime/tool-inventory-diagnostic-sections"; -import { chatStateFixture, chatStateWith } from "../../support/state"; - -type ThreadStartResponse = ClientResponseByMethod["thread/start"]; - -describe("chat app-server actions", () => { - it("publishes newly started threads before the first turn completes", async () => { - let state = chatStateFixture(); - const existing = threadFixture("existing"); - state = chatStateWith(state, { threadList: { listedThreads: [threadFromThreadRecord(existing)] } }); - const stateStore = createChatStateStore(state); - const started = threadFixture("started"); - const optimistic = threadFromThreadRecord({ ...started, preview: "first prompt" }); - const existingThread = threadFromThreadRecord(existing); - const applyThreadCatalogEvent = vi.fn(); - const syncThreadGoal = vi.fn(); - const client = startThreadClient( - vi.fn().mockResolvedValue({ - thread: started, - cwd: "/vault", - model: "gpt-5", - serviceTier: null, - approvalsReviewer: null, - reasoningEffort: null, - }), - ); - - const actions = createChatServerThreadActions({ - stateStore, - vaultPath: "/vault", - currentClient: () => client, - runtimeSnapshotForState: runtimeSnapshotForChatState, - applyThreadCatalogEvent, - syncThreadGoal, - }); - - await actions.startThread("first prompt"); - - expect(stateStore.getState().threadList.listedThreads).toEqual([optimistic, existingThread]); - expect(applyThreadCatalogEvent).toHaveBeenCalledWith({ type: "thread-started", thread: optimistic }); - expect(syncThreadGoal).toHaveBeenCalledWith("started"); - }); - - it("keeps empty-panel runtime reservations when starting the first thread", async () => { - const stateStore = createChatStateStore(chatStateFixture()); - stateStore.dispatch({ type: "runtime/model-requested", model: "gpt-5.5" }); - stateStore.dispatch({ type: "runtime/permission-profile-requested", permissionProfile: ":workspace" }); - stateStore.dispatch({ type: "runtime/reasoning-effort-requested", effort: "high" }); - stateStore.dispatch({ type: "runtime/fast-mode-requested", fastMode: "enabled" }); - stateStore.dispatch({ type: "runtime/approvals-reviewer-requested", approvalsReviewer: "auto_review" }); - stateStore.dispatch({ type: "runtime/requested-collaboration-mode-set", collaborationMode: "plan" }); - const started = threadFixture("started"); - const startThread = vi.fn().mockResolvedValue({ - thread: started, - cwd: "/vault", - model: "gpt-5", - serviceTier: "fast", - approvalsReviewer: null, - reasoningEffort: null, - }); - const client = startThreadClient(startThread); - - const actions = createChatServerThreadActions({ - stateStore, - vaultPath: "/vault", - currentClient: () => client, - runtimeSnapshotForState: runtimeSnapshotForChatState, - applyThreadCatalogEvent: vi.fn(), - syncThreadGoal: vi.fn(), - }); - - await actions.startThread("first prompt"); - - expect(startThread).toHaveBeenCalledWith({ - cwd: "/vault", - permissions: ":workspace", - serviceName: "codex-panel", - serviceTier: "fast", - }); - expect(stateStore.getState().runtime.active.model).toBe("gpt-5"); - expect(stateStore.getState().runtime.pending.model).toEqual({ kind: "set", value: "gpt-5.5" }); - expect(stateStore.getState().runtime.pending.permissionProfile).toEqual({ kind: "set", value: ":workspace" }); - expect(stateStore.getState().runtime.pending.reasoningEffort).toEqual({ kind: "set", value: "high" }); - expect(stateStore.getState().runtime.pending.fastMode).toEqual({ kind: "set", value: "enabled" }); - expect(stateStore.getState().runtime.pending.approvalsReviewer).toEqual({ kind: "set", value: "auto_review" }); - expect(stateStore.getState().runtime.pending.collaborationMode).toEqual(setCollaborationModeIntent("plan")); - }); - - it("can skip newly started thread goal sync when the caller sets the first goal", async () => { - const stateStore = createChatStateStore(chatStateFixture()); - const started = threadFixture("started"); - const syncThreadGoal = vi.fn(); - const client = startThreadClient( - vi.fn().mockResolvedValue({ - thread: started, - cwd: "/vault", - model: "gpt-5", - serviceTier: null, - approvalsReviewer: null, - reasoningEffort: null, - }), - ); - - const actions = createChatServerThreadActions({ - stateStore, - vaultPath: "/vault", - currentClient: () => client, - runtimeSnapshotForState: runtimeSnapshotForChatState, - applyThreadCatalogEvent: vi.fn(), - syncThreadGoal, - }); - - await actions.startThread("first goal", { syncGoal: false }); - - expect(syncThreadGoal).not.toHaveBeenCalled(); - }); - - it("starts threads with service tier from explicit effective config", async () => { - let state = chatStateFixture(); - state = chatStateWith(state, { connection: { runtimeConfig: { ...emptyRuntimeConfigSnapshot(), serviceTier: "flex" } } }); - const stateStore = createChatStateStore(state); - const started = threadFixture("started"); - const startThread = vi.fn().mockResolvedValue({ - thread: started, - cwd: "/vault", - model: "gpt-5", - serviceTier: "flex", - approvalsReviewer: null, - reasoningEffort: null, - }); - const client = startThreadClient(startThread); - - const actions = createChatServerThreadActions({ - stateStore, - vaultPath: "/vault", - currentClient: () => client, - runtimeSnapshotForState: runtimeSnapshotForChatState, - applyThreadCatalogEvent: vi.fn(), - syncThreadGoal: vi.fn(), - }); - - await actions.startThread(); - - expect(startThread).toHaveBeenCalledWith({ - cwd: "/vault", - serviceName: "codex-panel", - serviceTier: "flex", - }); - }); - - it("starts threads with permission profile from explicit config", async () => { - let state = chatStateFixture(); - state = chatStateWith(state, { - connection: { - runtimeConfig: { - ...emptyRuntimeConfigSnapshot(), - startupPermissions: { - ...emptyRuntimeConfigSnapshot().startupPermissions, - activePermissionProfile: { id: ":workspace", extends: null }, - }, - }, - }, - }); - const stateStore = createChatStateStore(state); - const started = threadFixture("started"); - const startThread = vi.fn().mockResolvedValue({ - thread: started, - cwd: "/vault", - model: "gpt-5", - serviceTier: null, - approvalsReviewer: null, - reasoningEffort: null, - }); - const client = startThreadClient(startThread); - - const actions = createChatServerThreadActions({ - stateStore, - vaultPath: "/vault", - currentClient: () => client, - runtimeSnapshotForState: runtimeSnapshotForChatState, - applyThreadCatalogEvent: vi.fn(), - syncThreadGoal: vi.fn(), - }); - - await actions.startThread(); - - expect(startThread).toHaveBeenCalledWith({ - cwd: "/vault", - permissions: ":workspace", - serviceName: "codex-panel", - }); - }); - - it("keeps app-server preview when newly started threads already have one", async () => { - const stateStore = createChatStateStore(chatStateFixture()); - const started = threadFixture("started", { preview: "server preview" }); - const applyThreadCatalogEvent = vi.fn(); - const client = startThreadClient( - vi.fn().mockResolvedValue({ - thread: started, - cwd: "/vault", - model: "gpt-5", - serviceTier: null, - approvalsReviewer: null, - reasoningEffort: null, - }), - ); - - const actions = createChatServerThreadActions({ - stateStore, - vaultPath: "/vault", - currentClient: () => client, - runtimeSnapshotForState: runtimeSnapshotForChatState, - applyThreadCatalogEvent, - syncThreadGoal: () => undefined, - }); - - await actions.startThread("local preview"); - - expect(applyThreadCatalogEvent).toHaveBeenCalledWith({ type: "thread-started", thread: threadFromThreadRecord(started) }); - }); - - it("does not apply newly started threads after the client changes", async () => { - const stateStore = createChatStateStore(chatStateFixture()); - const start = deferred(); - const firstClient = startThreadClient(vi.fn().mockReturnValue(start.promise)); - const secondClient = {} as unknown as AppServerClient; - let currentClient = firstClient; - const applyThreadCatalogEvent = vi.fn(); - const syncThreadGoal = vi.fn(); - const actions = createChatServerThreadActions({ - stateStore, - vaultPath: "/vault", - currentClient: () => currentClient, - runtimeSnapshotForState: runtimeSnapshotForChatState, - applyThreadCatalogEvent, - syncThreadGoal, - }); - - const starting = actions.startThread("local preview"); - currentClient = secondClient; - start.resolve({ - thread: threadFixture("stale-started"), - cwd: "/vault", - model: "gpt-5", - modelProvider: "openai", - serviceTier: null, - approvalPolicy: "on-request", - runtimeWorkspaceRoots: [], - instructionSources: [], - approvalsReviewer: "user", - activePermissionProfile: null, - sandbox: { type: "readOnly", networkAccess: false }, - reasoningEffort: null, - multiAgentMode: "explicitRequestOnly", - }); - - await expect(starting).resolves.toBeNull(); - expect(stateStore.getState().activeThread.id).toBeNull(); - expect(stateStore.getState().threadList.listedThreads).toEqual([]); - expect(applyThreadCatalogEvent).not.toHaveBeenCalled(); - expect(syncThreadGoal).not.toHaveBeenCalled(); - }); - - it("reuses cached app-server metadata for deferred diagnostics", async () => { - const state = chatStateFixture(); - const stateStore = createChatStateStore(state); - - const listMcpServerStatus = vi.fn().mockResolvedValue({ data: [] }); - const refreshedMetadata = serverMetadataFixture({ - availableModels: modelMetadataFromCatalogModels([modelFixture("gpt-5.1")]), - availableSkills: [{ name: "writer", description: "", path: "/tmp/writer", enabled: true }], - rateLimit: rateLimitFixture(), - serverDiagnostics: diagnosticsWithProbe( - diagnosticsWithProbe( - diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeOk("models", "1 models", 1)), - diagnosticProbeOk("skills", "1 skills", 1), - ), - diagnosticProbeOk("rateLimits", "available", 1), - ), - }); - const refreshAppServerMetadata = vi.fn<() => Promise>().mockResolvedValue(refreshedMetadata); - const client = requestClient({ - "mcpServerStatus/list": listMcpServerStatus, - }); - const metadataCache = metadataCacheHost({ current: null }); - - const metadata = createChatServerMetadataActions({ - stateStore, - vaultPath: "/vault", - currentClient: () => client, - ...metadataCache, - refreshAppServerMetadata: async () => { - const next = await refreshAppServerMetadata(); - metadataCache.updateAppServerMetadata(() => next); - return next; - }, - }); - const diagnostics = createChatServerDiagnosticsActions({ - stateStore, - vaultPath: "/vault", - currentClient: () => client, - ...metadataCache, - }); - - await metadata.refreshAppServerMetadata(); - - await diagnostics.refreshServerDiagnostics({ appServerMetadataSnapshot: true }); - - expect(refreshAppServerMetadata).toHaveBeenCalledOnce(); - expect(listMcpServerStatus).toHaveBeenCalledWith({ detail: "toolsAndAuthOnly", limit: 100 }); - expect(stateStore.getState().connection.serverDiagnostics.probes.models).toMatchObject({ - status: "ok", - summary: "1 models", - }); - expect(stateStore.getState().connection.serverDiagnostics.probes.skills).toMatchObject({ - status: "ok", - summary: "1 skills", - }); - expect(stateStore.getState().connection.serverDiagnostics.probes.rateLimits).toMatchObject({ - status: "ok", - summary: "available", - }); - }); - - it("ignores stale shared app-server metadata refreshes without applying state", async () => { - const stateStore = createChatStateStore(chatStateFixture()); - const metadata = createChatServerMetadataActions({ - stateStore, - vaultPath: "/vault", - currentClient: () => null, - ...metadataCacheHost({ current: null }), - refreshAppServerMetadata: async () => { - throw new StaleAppServerSharedQueryContextError(); - }, - }); - - await expect(metadata.refreshAppServerMetadata()).resolves.toBeNull(); - - expect(stateStore.getState().connection.availableModels).toEqual([]); - expect(stateStore.getState().connection.runtimeConfig).toBeNull(); - }); - - it("uses metadata diagnostics as the default resource probe source", async () => { - const stateStore = createChatStateStore(chatStateFixture()); - const metadataCache = metadataCacheHost({ - current: serverMetadataFixture({ - serverDiagnostics: diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeOk("models", "cached models", 1)), - }), - }); - const listModels = vi.fn().mockResolvedValue({ data: [modelFixture("gpt-direct")] }); - const listSkills = vi.fn().mockResolvedValue({ data: [{ skills: [skillFixture("direct-skill")] }] }); - const readAccountRateLimits = vi.fn().mockResolvedValue({ rateLimits: rateLimitFixture(), rateLimitsByLimitId: null }); - const listMcpServerStatus = vi.fn().mockResolvedValue({ data: [] }); - const client = requestClient({ - "model/list": listModels, - "skills/list": listSkills, - "account/rateLimits/read": readAccountRateLimits, - "mcpServerStatus/list": listMcpServerStatus, - }); - const diagnostics = createChatServerDiagnosticsActions({ - stateStore, - vaultPath: "/vault", - currentClient: () => client, - ...metadataCache, - }); - - await diagnostics.refreshServerDiagnostics(); - - expect(listModels).not.toHaveBeenCalled(); - expect(listSkills).not.toHaveBeenCalled(); - expect(readAccountRateLimits).not.toHaveBeenCalled(); - expect(stateStore.getState().connection.serverDiagnostics.probes.models).toMatchObject({ - status: "ok", - summary: "cached models", - }); - expect(listMcpServerStatus).toHaveBeenCalledWith({ detail: "toolsAndAuthOnly", limit: 100 }); - }); - - it("can force resource probes for explicit health checks", async () => { - const stateStore = createChatStateStore(chatStateFixture()); - const listModels = vi.fn().mockResolvedValue({ data: [modelFixture("gpt-direct")] }); - const listSkills = vi.fn().mockResolvedValue({ data: [{ skills: [skillFixture("direct-skill")] }] }); - const readAccountRateLimits = vi.fn().mockResolvedValue({ rateLimits: rateLimitFixture(), rateLimitsByLimitId: null }); - const client = requestClient({ - "model/list": listModels, - "skills/list": listSkills, - "account/rateLimits/read": readAccountRateLimits, - "mcpServerStatus/list": vi.fn().mockResolvedValue({ data: [] }), - }); - const diagnostics = createChatServerDiagnosticsActions({ - stateStore, - vaultPath: "/vault", - currentClient: () => client, - ...metadataCacheHost(), - }); - - await diagnostics.refreshServerDiagnostics({ forceResourceProbes: true }); - - expect(listModels).toHaveBeenCalledWith({ includeHidden: false, limit: 100 }); - expect(listSkills).toHaveBeenCalledWith({ cwds: ["/vault"], forceReload: false }); - expect(readAccountRateLimits).toHaveBeenCalledWith(undefined); - expect(stateStore.getState().connection.serverDiagnostics.probes.models).toMatchObject({ - status: "ok", - summary: "1 models", - }); - }); - - it("does not apply or publish diagnostic probes after the client changes", async () => { - const stateStore = createChatStateStore(chatStateFixture()); - const mcpStatusRefresh = deferred<{ data: ReturnType[] }>(); - const listMcpServerStatus = vi.fn().mockReturnValue(mcpStatusRefresh.promise); - const firstClient = requestClient({ - "mcpServerStatus/list": listMcpServerStatus, - }); - const secondClient = {} as unknown as AppServerClient; - let currentClient = firstClient; - const updateAppServerMetadata = vi.fn(() => null); - const diagnostics = createChatServerDiagnosticsActions({ - stateStore, - vaultPath: "/vault", - currentClient: () => currentClient, - appServerMetadataSnapshot: () => null, - updateAppServerMetadata, - }); - - const refreshing = diagnostics.refreshServerDiagnostics({ appServerMetadataSnapshot: true }); - currentClient = secondClient; - mcpStatusRefresh.resolve({ data: [mcpServerStatus()] }); - - await refreshing; - expect(listMcpServerStatus).toHaveBeenCalledWith({ detail: "toolsAndAuthOnly", limit: 100 }); - expect(stateStore.getState().connection.serverDiagnostics.probes.mcpServers.status).toBe("unknown"); - expect(stateStore.getState().connection.serverDiagnostics.mcpServers).toEqual([]); - expect(updateAppServerMetadata).not.toHaveBeenCalled(); - }); - - it("does not apply or publish app-server metadata when the client changes before refresh completes", async () => { - const stateStore = createChatStateStore(chatStateFixture()); - const refreshAppServerMetadata = vi.fn().mockResolvedValue(null); - const actions = createChatServerMetadataActions({ - stateStore, - vaultPath: "/vault", - currentClient: () => ({}) as AppServerClient, - ...metadataCacheHost(), - refreshAppServerMetadata, - }); - - const refreshing = actions.refreshAppServerMetadata(); - - await expect(refreshing).resolves.toBeNull(); - expect(stateStore.getState().connection.availableModels).toEqual([]); - expect(stateStore.getState().connection.availableSkills).toEqual([]); - }); - - it("keeps query-cached models visible when metadata model refresh fails", async () => { - const state = chatStateFixture(); - const stateStore = createChatStateStore(state); - const metadata = serverMetadataFixture({ - availableModels: modelMetadataFromCatalogModels([modelFixture("gpt-cached")]), - serverDiagnostics: diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeError("models", new Error("offline"), 1)), - }); - const actions = createChatServerMetadataActions({ - stateStore, - vaultPath: "/vault", - currentClient: () => ({}) as AppServerClient, - ...metadataCacheHost({ current: metadata }), - refreshAppServerMetadata: async () => metadata, - }); - - await actions.refreshAppServerMetadata(); - - expect(stateStore.getState().connection.availableModels.map((model) => model.model)).toEqual(["gpt-cached"]); - expect(stateStore.getState().connection.serverDiagnostics.probes.models.status).toBe("failed"); - }); - - it("does not use chat state as a second model source when metadata model refresh fails", async () => { - let state = chatStateFixture(); - state = chatStateWith(state, { connection: { availableModels: modelMetadataFromCatalogModels([modelFixture("gpt-state-only")]) } }); - const stateStore = createChatStateStore(state); - const metadata = serverMetadataFixture({ - availableModels: [], - serverDiagnostics: diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeError("models", new Error("offline"), 1)), - }); - const actions = createChatServerMetadataActions({ - stateStore, - vaultPath: "/vault", - currentClient: () => ({}) as AppServerClient, - ...metadataCacheHost({ current: metadata }), - refreshAppServerMetadata: async () => metadata, - }); - - await actions.refreshAppServerMetadata(); - - expect(stateStore.getState().connection.availableModels).toEqual([]); - expect(stateStore.getState().connection.serverDiagnostics.probes.models.status).toBe("failed"); - }); - - it("does not apply or publish refreshed skills after the client changes", async () => { - const stateStore = createChatStateStore(chatStateFixture()); - const skillRefresh = deferred<{ data: { skills: CatalogSkillMetadata[] }[] }>(); - const listSkills = vi.fn().mockReturnValue(skillRefresh.promise); - const firstClient = requestClient({ "skills/list": listSkills }); - const secondClient = {} as unknown as AppServerClient; - let currentClient = firstClient; - const updateAppServerMetadata = vi.fn(() => null); - const actions = createChatServerMetadataActions({ - stateStore, - vaultPath: "/vault", - currentClient: () => currentClient, - appServerMetadataSnapshot: () => null, - updateAppServerMetadata, - refreshAppServerMetadata: async () => null, - }); - - const refreshing = actions.applyAppServerResourceEvent({ type: "skills-changed", forceReload: true }); - currentClient = secondClient; - skillRefresh.resolve({ data: [{ skills: [skillFixture("stale-skill")] }] }); - - await refreshing; - expect(listSkills).toHaveBeenCalledWith({ cwds: ["/vault"], forceReload: true }); - expect(stateStore.getState().connection.availableSkills).toEqual([]); - expect(stateStore.getState().connection.serverDiagnostics.probes.skills.status).toBe("unknown"); - expect(updateAppServerMetadata).not.toHaveBeenCalled(); - }); - - it("keeps previous skills when sparse skill refresh fails", async () => { - let state = chatStateFixture(); - const previousSkills = [{ name: "writer", description: "", path: "/tmp/writer", enabled: true }]; - state = chatStateWith(state, { connection: { availableSkills: previousSkills } }); - const stateStore = createChatStateStore(state); - const listSkills = vi.fn().mockRejectedValue(new Error("offline")); - const client = requestClient({ "skills/list": listSkills }); - const actions = createChatServerMetadataActions({ - stateStore, - vaultPath: "/vault", - currentClient: () => client, - ...metadataCacheHost(), - refreshAppServerMetadata: async () => null, - }); - - await actions.applyAppServerResourceEvent({ type: "skills-changed", forceReload: true }); - - expect(listSkills).toHaveBeenCalledWith({ cwds: ["/vault"], forceReload: true }); - expect(stateStore.getState().connection.availableSkills).toEqual(previousSkills); - expect(stateStore.getState().connection.serverDiagnostics.probes.skills).toMatchObject({ status: "failed" }); - }); - - it("publishes refreshed rate limits from sparse update notifications", async () => { - const state = chatStateFixture(); - const stateStore = createChatStateStore(state); - const rateLimit = rateLimitFixture({ primary: { usedPercent: 64, windowDurationMins: 300, resetsAt: null } }); - const cachedMetadata = { current: serverMetadataFixture() as SharedServerMetadata | null }; - const client = requestClient({ - "account/rateLimits/read": vi.fn().mockResolvedValue({ rateLimits: rateLimit, rateLimitsByLimitId: null }), - }); - const actions = createChatServerMetadataActions({ - stateStore, - vaultPath: "/vault", - currentClient: () => client, - ...metadataCacheHost(cachedMetadata), - refreshAppServerMetadata: async () => null, - }); - - await actions.applyAppServerResourceEvent({ type: "rate-limits-updated", preserveExistingOnFailure: true }); - - expect(stateStore.getState().connection.rateLimit).toMatchObject({ primary: { usedPercent: 64 } }); - expect(cachedMetadata.current?.rateLimit).toStrictEqual(rateLimit); - }); - - it("keeps the previous rate limit snapshot when sparse update refresh fails", async () => { - let state = chatStateFixture(); - const previousRateLimit = rateLimitFixture({ - limitName: "Codex", - primary: { usedPercent: 42, windowDurationMins: 300, resetsAt: null }, - }); - state = chatStateWith(state, { connection: { rateLimit: previousRateLimit } }); - const stateStore = createChatStateStore(state); - const client = requestClient({ - "account/rateLimits/read": vi.fn().mockRejectedValue(new Error("offline")), - }); - const actions = createChatServerMetadataActions({ - stateStore, - vaultPath: "/vault", - currentClient: () => client, - ...metadataCacheHost(), - refreshAppServerMetadata: async () => null, - }); - - await actions.applyAppServerResourceEvent({ type: "rate-limits-updated", preserveExistingOnFailure: true }); - - expect(stateStore.getState().connection.rateLimit).toBe(previousRateLimit); - expect(stateStore.getState().connection.serverDiagnostics.probes.rateLimits).toMatchObject({ status: "failed" }); - }); - - it("does not apply or publish sparse rate limit refreshes after the client changes", async () => { - const stateStore = createChatStateStore(chatStateFixture()); - const rateLimitRefresh = deferred<{ rateLimits: RateLimitSnapshot; rateLimitsByLimitId: null }>(); - const firstClient = requestClient({ - "account/rateLimits/read": vi.fn().mockReturnValue(rateLimitRefresh.promise), - }); - const secondClient = {} as unknown as AppServerClient; - let currentClient = firstClient; - const updateAppServerMetadata = vi.fn(() => null); - const actions = createChatServerMetadataActions({ - stateStore, - vaultPath: "/vault", - currentClient: () => currentClient, - appServerMetadataSnapshot: () => null, - updateAppServerMetadata, - refreshAppServerMetadata: async () => null, - }); - - const refreshing = actions.applyAppServerResourceEvent({ type: "rate-limits-updated", preserveExistingOnFailure: true }); - currentClient = secondClient; - rateLimitRefresh.resolve({ - rateLimits: rateLimitFixture({ primary: { usedPercent: 88, windowDurationMins: 300, resetsAt: null } }), - rateLimitsByLimitId: null, - }); - - await refreshing; - expect(stateStore.getState().connection.rateLimit).toBeNull(); - expect(stateStore.getState().connection.serverDiagnostics.probes.rateLimits.status).toBe("unknown"); - expect(updateAppServerMetadata).not.toHaveBeenCalled(); - }); - - it("refreshes tool provider snapshots with cached MCP startup diagnostics", async () => { - let state = chatStateFixture(); - state = chatStateWith(state, { activeThread: { id: "thread-1" } }); - const stateStore = createChatStateStore(state); - const listMcpServerStatus = vi.fn().mockResolvedValue({ data: [mcpServerStatus()] }); - const client = requestClient({ - "app/list": vi.fn().mockResolvedValue({ data: [], nextCursor: null }), - "plugin/installed": vi.fn().mockResolvedValue({ marketplaces: [], marketplaceLoadErrors: [] }), - "mcpServerStatus/list": listMcpServerStatus, - "skills/list": vi.fn().mockResolvedValue({ data: [{ cwd: "/vault", skills: [] }] }), - }); - const metadataCache = metadataCacheHost({ current: serverMetadataFixture() }); - const metadata = createChatServerMetadataActions({ - stateStore, - vaultPath: "/vault", - currentClient: () => client, - ...metadataCache, - refreshAppServerMetadata: async () => null, - }); - const diagnostics = createChatServerDiagnosticsActions({ - stateStore, - vaultPath: "/vault", - currentClient: () => client, - ...metadataCache, - }); - - await metadata.applyAppServerResourceEvent({ - type: "mcp-startup-status-updated", - name: "github", - status: "ready", - message: null, - }); - - await diagnostics.refreshServerDiagnostics({ appServerMetadataSnapshot: true }); - - const sections = toolInventoryDiagnosticSections(stateStore.getState().connection.serverDiagnostics); - const toolProviderRows = sections.find((section) => section.title === "Tool providers")?.rows ?? []; - - expect(sections.map((section) => section.title)).toEqual(["Plugins", "Tool providers", "Skills"]); - expect(toolProviderRows.map((row) => `${row.label}: ${row.value}`)).toEqual([ - "github: MCP server, ready, auth oAuth, 1 tool, 0 resources", - ]); - expect(listMcpServerStatus).toHaveBeenCalledWith({ - detail: "toolsAndAuthOnly", - limit: 100, - threadId: "thread-1", - }); - }); -}); - -function requestClient(handlers: Record> = {}): AppServerClient { - const methodHandlers: Record Promise> = { - "skills/list": async (params) => ({ data: [{ cwd: (params as { cwds: string[] }).cwds[0] ?? "", skills: [] }] }), - "app/list": async () => ({ data: [], nextCursor: null }), - "plugin/installed": async () => ({ marketplaces: [], marketplaceLoadErrors: [] }), - "plugin/read": async () => ({ plugin: { skills: [], hooks: [], apps: [], appTemplates: [], mcpServers: [] } }), - ...handlers, - }; - return { - request: vi.fn((method: string, params: unknown) => { - const handler = methodHandlers[method]; - if (!handler) throw new Error(`Unexpected app-server request: ${method}`); - return handler(params); - }), - } as unknown as AppServerClient; -} - -function startThreadClient(startThread: ReturnType): AppServerClient { - return { - request: vi.fn((method: string, params: ClientRequestParams<"thread/start">) => { - if (method !== "thread/start") throw new Error(`Unexpected app-server request: ${method}`); - if (!params.cwd) throw new Error("Expected thread/start cwd."); - return (startThread as unknown as (params: ClientRequestParams<"thread/start">) => Promise)(params); - }), - } as unknown as AppServerClient; -} - -function threadFixture(id: string, overrides: Partial = {}): ThreadStartResponse["thread"] { - return { - id, - extra: null, - sessionId: id, - forkedFromId: null, - parentThreadId: null, - preview: "", - ephemeral: false, - historyMode: "paginated", - modelProvider: "openai", - createdAt: 0, - updatedAt: 0, - recencyAt: null, - status: { type: "idle" }, - path: null, - cwd: "/vault", - cliVersion: "test", - source: "unknown", - threadSource: null, - agentNickname: null, - agentRole: null, - gitInfo: null, - name: null, - turns: [], - ...overrides, - }; -} - -function modelFixture(model: string): CatalogModel { - return { - id: model, - model, - upgrade: null, - upgradeInfo: null, - availabilityNux: null, - displayName: model, - description: "", - hidden: false, - supportedReasoningEfforts: [], - defaultReasoningEffort: "medium", - inputModalities: ["text"], - supportsPersonality: false, - additionalSpeedTiers: [], - serviceTiers: [], - defaultServiceTier: null, - isDefault: false, - }; -} - -function skillFixture(name: string): CatalogSkillMetadata { - return { - name, - description: "", - path: `/skills/${name}`, - scope: "repo", - enabled: true, - }; -} - -function rateLimitFixture(overrides: Partial = {}): RateLimitSnapshot { - return { - limitId: "codex", - limitName: "Codex", - primary: null, - secondary: null, - individualLimit: null, - rateLimitReachedType: null, - ...overrides, - }; -} - -function serverMetadataFixture(overrides: Partial = {}): SharedServerMetadata { - return { - runtimeConfig: emptyRuntimeConfigSnapshot(), - availableModels: [], - availableSkills: [], - availablePermissionProfiles: [], - rateLimit: null, - serverDiagnostics: createServerDiagnostics(), - ...overrides, - }; -} - -function metadataCacheHost(cache: { current: SharedServerMetadata | null } = { current: null }): { - appServerMetadataSnapshot: () => SharedServerMetadata | null; - updateAppServerMetadata: (updater: (metadata: SharedServerMetadata | null) => SharedServerMetadata | null) => SharedServerMetadata | null; -} { - return { - appServerMetadataSnapshot: () => cache.current, - updateAppServerMetadata: (updater) => { - const next = updater(cache.current); - cache.current = next; - return next; - }, - }; -} - -function mcpServerStatus(): McpServerStatus { - return { - name: "github", - serverInfo: null, - tools: { - search_issues: { name: "search_issues", inputSchema: {} }, - }, - resources: [], - resourceTemplates: [], - authStatus: "oAuth", - } as McpServerStatus; -} - -interface Deferred { - promise: Promise; - resolve: (value: T) => void; -} - -function deferred(): Deferred { - let resolve: (value: T) => void = () => undefined; - const promise = new Promise((settle) => { - resolve = settle; - }); - return { promise, resolve }; -} diff --git a/tests/features/chat/app-server/transports.test.ts b/tests/features/chat/app-server/transports.test.ts index 8a741a19..7dca0c5b 100644 --- a/tests/features/chat/app-server/transports.test.ts +++ b/tests/features/chat/app-server/transports.test.ts @@ -5,6 +5,7 @@ import * as shortLivedClient from "../../../../src/app-server/connection/short-l 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 { createServerDiagnostics, diagnosticProbeOk } from "../../../../src/domain/server/diagnostics"; import { createChatAppServerGateway } from "../../../../src/features/chat/app-server/session-gateway"; import { createThreadReferenceResolver } from "../../../../src/features/chat/app-server/thread-reference-resolver"; import { preparedUserInputWithWikiLinkMentionsSkillsAndContext } from "../../../../src/features/chat/application/composer/wikilink-context"; @@ -13,6 +14,43 @@ import { deferred } from "../../../support/async"; const textInput = (text: string): CodexInput => [{ type: "text", text }]; describe("chat app-server transports", () => { + it("starts threads with the session vault path and projects activation snapshots", async () => { + const request = vi.fn().mockResolvedValue(threadStartResponse("thread")); + const client = { request } as unknown as AppServerClient; + const transport = createTestGateway({ + currentClient: () => client, + connectedClient: vi.fn().mockResolvedValue(client), + }).threadStart; + + const snapshot = await transport.startThread({ serviceTier: "priority", permissions: ":workspace" }); + + expect(request).toHaveBeenCalledWith("thread/start", { + cwd: "/vault", + serviceName: "codex-panel", + serviceTier: "priority", + permissions: ":workspace", + }); + expect(snapshot?.thread.id).toBe("thread"); + expect(snapshot?.cwd).toBe("/vault"); + }); + + it("drops stale thread start responses after the current client changes", async () => { + const start = deferred(); + const firstClient = { request: vi.fn().mockReturnValue(start.promise) } as unknown as AppServerClient; + const secondClient = {} as unknown as AppServerClient; + let currentClient = firstClient; + const transport = createTestGateway({ + currentClient: () => currentClient, + connectedClient: vi.fn().mockResolvedValue(firstClient), + }).threadStart; + + const starting = transport.startThread({}); + currentClient = secondClient; + start.resolve(threadStartResponse("thread")); + + await expect(starting).resolves.toBeNull(); + }); + 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; @@ -305,6 +343,91 @@ describe("chat app-server transports", () => { expect(request).toHaveBeenCalledWith("thread/settings/update", { threadId: "thread", model: "gpt-5.5" }); }); + it("reads sparse skill metadata through the current app-server client", async () => { + const request = vi.fn().mockResolvedValue({ data: [{ cwd: "/vault", skills: [] }] }); + const client = { request } as unknown as AppServerClient; + const transport = createTestGateway({ + currentClient: () => client, + }).metadataResource; + + await expect(transport.readSkillMetadata(true)).resolves.toMatchObject({ + value: [], + probe: { status: "ok" }, + }); + + expect(request).toHaveBeenCalledWith("skills/list", { cwds: ["/vault"], forceReload: true }); + }); + + it("reads diagnostics probes and tool inventory at the app-server boundary", async () => { + const request = vi.fn((method: string) => { + switch (method) { + case "model/list": + return Promise.resolve({ data: [] }); + case "account/rateLimits/read": + return Promise.resolve({ rateLimits: null, rateLimitsByLimitId: null }); + case "plugin/installed": + return Promise.resolve({ marketplaces: [], marketplaceLoadErrors: [] }); + case "mcpServerStatus/list": + return Promise.resolve({ data: [] }); + case "skills/list": + return Promise.resolve({ data: [{ cwd: "/vault", skills: [] }] }); + default: + throw new Error(`Unexpected request: ${method}`); + } + }); + const client = { request } as unknown as AppServerClient; + const transport = createTestGateway({ + currentClient: () => client, + }).serverDiagnostics; + + const snapshot = await transport.readServerDiagnostics({ + threadId: "thread", + initialDiagnostics: createServerDiagnostics(), + cachedSkills: [], + cachedSkillsProbe: diagnosticProbeOk("skills", "0 skills", 1), + forceResourceProbes: true, + appServerMetadataSnapshot: false, + }); + + expect(snapshot?.resourceProbes.map((probe) => probe.id)).toEqual(["models", "rateLimits"]); + expect(request).toHaveBeenCalledWith("model/list", { includeHidden: false, limit: 100 }); + expect(request).toHaveBeenCalledWith("account/rateLimits/read", undefined); + expect(request).toHaveBeenCalledWith("mcpServerStatus/list", { + detail: "toolsAndAuthOnly", + limit: 100, + threadId: "thread", + }); + expect(request).not.toHaveBeenCalledWith("skills/list", expect.anything()); + }); + + it("reads skills for diagnostics tool inventory when no cached skills are provided", async () => { + const request = vi.fn((method: string) => { + switch (method) { + case "plugin/installed": + return Promise.resolve({ marketplaces: [], marketplaceLoadErrors: [] }); + case "mcpServerStatus/list": + return Promise.resolve({ data: [] }); + case "skills/list": + return Promise.resolve({ data: [{ cwd: "/vault", skills: [] }] }); + default: + throw new Error(`Unexpected request: ${method}`); + } + }); + const client = { request } as unknown as AppServerClient; + const transport = createTestGateway({ + currentClient: () => client, + }).serverDiagnostics; + + await transport.readServerDiagnostics({ + threadId: null, + initialDiagnostics: createServerDiagnostics(), + forceResourceProbes: false, + appServerMetadataSnapshot: true, + }); + + expect(request).toHaveBeenCalledWith("skills/list", { cwds: ["/vault"], forceReload: false }); + }); + it("resolves referenced thread input at the app-server boundary", async () => { const request = vi.fn().mockResolvedValue({ data: [turn([userMessage("u1", "元の依頼"), agentMessage("a1", "回答")])], @@ -394,6 +517,7 @@ describe("chat app-server transports", () => { }); }); +type AppServerThreadStartResponse = ClientResponseByMethod["thread/start"]; type AppServerThreadResumeResponse = ClientResponseByMethod["thread/resume"]; function createTestGateway(options: { @@ -437,6 +561,25 @@ function threadRecord(id: string, turns: readonly TurnRecord[] = [], overrides: }; } +function threadStartResponse(threadId: string, overrides: Partial = {}): AppServerThreadStartResponse { + return { + thread: threadRecord(threadId) as AppServerThreadStartResponse["thread"], + cwd: "/vault", + model: "gpt-test", + modelProvider: "openai", + serviceTier: null, + runtimeWorkspaceRoots: [], + instructionSources: [], + approvalPolicy: "never", + approvalsReviewer: "user", + sandbox: { type: "readOnly", networkAccess: false }, + activePermissionProfile: null, + reasoningEffort: null, + multiAgentMode: "explicitRequestOnly", + ...overrides, + }; +} + function threadResumeResponse(threadId: string, overrides: Partial = {}): AppServerThreadResumeResponse { return { thread: threadRecord(threadId) as AppServerThreadResumeResponse["thread"], diff --git a/tests/features/chat/application/connection/server-actions.test.ts b/tests/features/chat/application/connection/server-actions.test.ts new file mode 100644 index 00000000..b4f27ffb --- /dev/null +++ b/tests/features/chat/application/connection/server-actions.test.ts @@ -0,0 +1,412 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { ModelMetadata, SkillMetadata } from "../../../../../src/domain/catalog/metadata"; +import { emptyRuntimeConfigSnapshot } from "../../../../../src/domain/runtime/config"; +import type { RateLimitSnapshot } from "../../../../../src/domain/runtime/metrics"; +import { + createServerDiagnostics, + type DiagnosticProbeResult, + diagnosticProbeError, + diagnosticProbeOk, + diagnosticsWithProbe, +} from "../../../../../src/domain/server/diagnostics"; +import type { McpServerStatusSummary } from "../../../../../src/domain/server/mcp-status"; +import type { SharedServerMetadata } from "../../../../../src/domain/server/metadata"; +import type { ToolInventorySnapshot } from "../../../../../src/domain/server/tool-inventory"; +import type { MetadataResourceTransport } from "../../../../../src/features/chat/application/connection/metadata-transport"; +import { createServerDiagnosticsActions } from "../../../../../src/features/chat/application/connection/server-diagnostics-actions"; +import { createServerMetadataActions } from "../../../../../src/features/chat/application/connection/server-metadata-actions"; +import { createChatStateStore } from "../../../../../src/features/chat/application/state/store"; +import { toolInventoryDiagnosticSections } from "../../../../../src/features/chat/presentation/runtime/tool-inventory-diagnostic-sections"; +import { chatStateFixture, chatStateWith } from "../../support/state"; + +describe("server metadata actions", () => { + it("applies refreshed app-server metadata", async () => { + const stateStore = createChatStateStore(chatStateFixture()); + const metadata = serverMetadataFixture({ availableModels: [modelFixture("gpt-5.1")] }); + const actions = createServerMetadataActions({ + stateStore, + metadataResourceTransport: metadataResourceTransport(), + ...metadataCacheHost(), + refreshAppServerMetadata: vi.fn().mockResolvedValue(metadata), + isStaleSharedQueryError: () => false, + }); + + await actions.refreshAppServerMetadata(); + + expect(stateStore.getState().connection.availableModels.map((model) => model.model)).toEqual(["gpt-5.1"]); + }); + + it("ignores stale shared app-server metadata refreshes without applying state", async () => { + const stateStore = createChatStateStore(chatStateFixture()); + const stale = new Error("stale"); + const actions = createServerMetadataActions({ + stateStore, + metadataResourceTransport: metadataResourceTransport(), + ...metadataCacheHost(), + refreshAppServerMetadata: vi.fn().mockRejectedValue(stale), + isStaleSharedQueryError: (error) => error === stale, + }); + + await expect(actions.refreshAppServerMetadata()).resolves.toBeNull(); + + expect(stateStore.getState().connection.availableModels).toEqual([]); + expect(stateStore.getState().connection.runtimeConfig).toBeNull(); + }); + + it("does not apply stale sparse skill refreshes", async () => { + const stateStore = createChatStateStore(chatStateFixture()); + const updateAppServerMetadata = vi.fn(() => null); + const actions = createServerMetadataActions({ + stateStore, + metadataResourceTransport: metadataResourceTransport({ readSkillMetadata: vi.fn().mockResolvedValue(null) }), + appServerMetadataSnapshot: () => null, + updateAppServerMetadata, + refreshAppServerMetadata: vi.fn().mockResolvedValue(null), + isStaleSharedQueryError: () => false, + }); + + await actions.applyAppServerResourceEvent({ type: "skills-changed", forceReload: true }); + + expect(stateStore.getState().connection.availableSkills).toEqual([]); + expect(updateAppServerMetadata).not.toHaveBeenCalled(); + }); + + it("keeps previous skills when sparse skill refresh fails", async () => { + let state = chatStateFixture(); + const previousSkills = [skillFixture("writer")]; + state = chatStateWith(state, { connection: { availableSkills: previousSkills } }); + const stateStore = createChatStateStore(state); + const actions = createServerMetadataActions({ + stateStore, + metadataResourceTransport: metadataResourceTransport({ + readSkillMetadata: vi.fn().mockResolvedValue({ + value: [], + probe: diagnosticProbeError("skills", new Error("offline"), 1), + }), + }), + ...metadataCacheHost(), + refreshAppServerMetadata: vi.fn().mockResolvedValue(null), + isStaleSharedQueryError: () => false, + }); + + await actions.applyAppServerResourceEvent({ type: "skills-changed", forceReload: true }); + + expect(stateStore.getState().connection.availableSkills).toEqual(previousSkills); + expect(stateStore.getState().connection.serverDiagnostics.probes.skills).toMatchObject({ status: "failed" }); + }); + + it("publishes refreshed rate limits from sparse update notifications", async () => { + const stateStore = createChatStateStore(chatStateFixture()); + const rateLimit = rateLimitFixture({ primary: { usedPercent: 64, windowDurationMins: 300, resetsAt: null } }); + const cachedMetadata = { current: serverMetadataFixture() as SharedServerMetadata | null }; + const actions = createServerMetadataActions({ + stateStore, + metadataResourceTransport: metadataResourceTransport({ + readRateLimitMetadata: vi.fn().mockResolvedValue({ + value: rateLimit, + probe: diagnosticProbeOk("rateLimits", "available", 1), + }), + }), + ...metadataCacheHost(cachedMetadata), + refreshAppServerMetadata: vi.fn().mockResolvedValue(null), + isStaleSharedQueryError: () => false, + }); + + await actions.applyAppServerResourceEvent({ type: "rate-limits-updated", preserveExistingOnFailure: true }); + + expect(stateStore.getState().connection.rateLimit).toMatchObject({ primary: { usedPercent: 64 } }); + expect(cachedMetadata.current?.rateLimit).toStrictEqual(rateLimit); + }); + + it("keeps the previous rate limit snapshot when sparse update refresh fails", async () => { + let state = chatStateFixture(); + const previousRateLimit = rateLimitFixture({ + limitName: "Codex", + primary: { usedPercent: 42, windowDurationMins: 300, resetsAt: null }, + }); + state = chatStateWith(state, { connection: { rateLimit: previousRateLimit } }); + const stateStore = createChatStateStore(state); + const actions = createServerMetadataActions({ + stateStore, + metadataResourceTransport: metadataResourceTransport({ + readRateLimitMetadata: vi.fn().mockResolvedValue({ + value: null, + probe: diagnosticProbeError("rateLimits", new Error("offline"), 1), + }), + }), + ...metadataCacheHost(), + refreshAppServerMetadata: vi.fn().mockResolvedValue(null), + isStaleSharedQueryError: () => false, + }); + + await actions.applyAppServerResourceEvent({ type: "rate-limits-updated", preserveExistingOnFailure: true }); + + expect(stateStore.getState().connection.rateLimit).toBe(previousRateLimit); + expect(stateStore.getState().connection.serverDiagnostics.probes.rateLimits).toMatchObject({ status: "failed" }); + }); +}); + +describe("server diagnostics actions", () => { + it("reuses refreshed app-server metadata for deferred diagnostics", async () => { + const stateStore = createChatStateStore(chatStateFixture()); + const refreshedMetadata = serverMetadataFixture({ + availableModels: [modelFixture("gpt-5.1")], + availableSkills: [skillFixture("writer")], + serverDiagnostics: diagnosticsWithProbe( + diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeOk("models", "1 models", 1)), + diagnosticProbeOk("skills", "1 skills", 1), + ), + }); + const metadataCache = metadataCacheHost({ current: null }); + const metadata = createServerMetadataActions({ + stateStore, + metadataResourceTransport: metadataResourceTransport(), + ...metadataCache, + refreshAppServerMetadata: vi.fn().mockImplementation(async () => { + metadataCache.updateAppServerMetadata(() => refreshedMetadata); + return refreshedMetadata; + }), + isStaleSharedQueryError: () => false, + }); + const readServerDiagnostics = vi.fn().mockResolvedValue(serverDiagnosticsSnapshot()); + const diagnostics = createServerDiagnosticsActions({ + stateStore, + diagnosticsTransport: { readServerDiagnostics }, + ...metadataCache, + }); + + await metadata.refreshAppServerMetadata(); + await diagnostics.refreshServerDiagnostics({ appServerMetadataSnapshot: true }); + + expect(readServerDiagnostics).toHaveBeenCalledWith( + expect.objectContaining({ + appServerMetadataSnapshot: true, + cachedSkills: [skillFixture("writer")], + cachedSkillsProbe: expect.objectContaining({ status: "ok", summary: "1 skills" }), + }), + ); + expect(stateStore.getState().connection.serverDiagnostics.probes.models).toMatchObject({ + status: "ok", + summary: "1 models", + }); + }); + + it("uses metadata diagnostics as the default resource probe source", async () => { + const stateStore = createChatStateStore(chatStateFixture()); + const metadataCache = metadataCacheHost({ + current: serverMetadataFixture({ + serverDiagnostics: diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeOk("models", "cached models", 1)), + }), + }); + const readServerDiagnostics = vi.fn().mockResolvedValue(serverDiagnosticsSnapshot()); + const diagnostics = createServerDiagnosticsActions({ + stateStore, + diagnosticsTransport: { readServerDiagnostics }, + ...metadataCache, + }); + + await diagnostics.refreshServerDiagnostics(); + + expect(readServerDiagnostics).toHaveBeenCalledWith( + expect.objectContaining({ + appServerMetadataSnapshot: false, + forceResourceProbes: false, + }), + ); + expect(stateStore.getState().connection.serverDiagnostics.probes.models).toMatchObject({ + status: "ok", + summary: "cached models", + }); + }); + + it("can force resource probes for explicit health checks", async () => { + const stateStore = createChatStateStore(chatStateFixture()); + const readServerDiagnostics = vi.fn().mockResolvedValue( + serverDiagnosticsSnapshot({ + resourceProbes: [diagnosticProbeOk("models", "1 models", 1), diagnosticProbeOk("rateLimits", "available", 1)], + }), + ); + const diagnostics = createServerDiagnosticsActions({ + stateStore, + diagnosticsTransport: { readServerDiagnostics }, + ...metadataCacheHost(), + }); + + await diagnostics.refreshServerDiagnostics({ forceResourceProbes: true }); + + expect(readServerDiagnostics).toHaveBeenCalledWith(expect.objectContaining({ forceResourceProbes: true })); + expect(stateStore.getState().connection.serverDiagnostics.probes.models).toMatchObject({ + status: "ok", + summary: "1 models", + }); + }); + + it("does not apply diagnostic probes when the transport returns no snapshot", async () => { + const stateStore = createChatStateStore(chatStateFixture()); + const updateAppServerMetadata = vi.fn(() => null); + const diagnostics = createServerDiagnosticsActions({ + stateStore, + diagnosticsTransport: { readServerDiagnostics: vi.fn().mockResolvedValue(null) }, + appServerMetadataSnapshot: () => null, + updateAppServerMetadata, + }); + + await diagnostics.refreshServerDiagnostics({ appServerMetadataSnapshot: true }); + + expect(stateStore.getState().connection.serverDiagnostics.probes.mcpServers.status).toBe("unknown"); + expect(updateAppServerMetadata).not.toHaveBeenCalled(); + }); + + it("refreshes tool provider snapshots with cached MCP startup diagnostics", async () => { + let state = chatStateFixture(); + state = chatStateWith(state, { activeThread: { id: "thread-1" } }); + const stateStore = createChatStateStore(state); + const metadataCache = metadataCacheHost({ current: serverMetadataFixture() }); + const metadata = createServerMetadataActions({ + stateStore, + metadataResourceTransport: metadataResourceTransport(), + ...metadataCache, + refreshAppServerMetadata: vi.fn().mockResolvedValue(null), + isStaleSharedQueryError: () => false, + }); + const diagnostics = createServerDiagnosticsActions({ + stateStore, + diagnosticsTransport: { + readServerDiagnostics: vi.fn().mockResolvedValue( + serverDiagnosticsSnapshot({ + mcpServerStatuses: [mcpServerStatus()], + }), + ), + }, + ...metadataCache, + }); + + await metadata.applyAppServerResourceEvent({ + type: "mcp-startup-status-updated", + name: "github", + status: "ready", + message: null, + }); + await diagnostics.refreshServerDiagnostics({ appServerMetadataSnapshot: true }); + + const sections = toolInventoryDiagnosticSections(stateStore.getState().connection.serverDiagnostics); + const toolProviderRows = sections.find((section) => section.title === "Tool providers")?.rows ?? []; + + expect(sections.map((section) => section.title)).toEqual(["Plugins", "Tool providers", "Skills"]); + expect(toolProviderRows.map((row) => `${row.label}: ${row.value}`)).toEqual(["github: MCP server, ready, auth oAuth, 1 tool"]); + }); +}); + +function metadataResourceTransport(overrides: Partial = {}): MetadataResourceTransport { + return { + readSkillMetadata: vi.fn().mockResolvedValue({ value: [], probe: diagnosticProbeOk("skills", "0 skills", 1) }), + readRateLimitMetadata: vi.fn().mockResolvedValue({ value: null, probe: diagnosticProbeOk("rateLimits", "unavailable", 1) }), + ...overrides, + }; +} + +function serverDiagnosticsSnapshot( + overrides: { resourceProbes?: DiagnosticProbeResult[]; mcpServerStatuses?: McpServerStatusSummary[] | null } = {}, +) { + return { + resourceProbes: overrides.resourceProbes ?? [], + toolInventory: { + inventory: toolInventory(), + probes: [ + diagnosticProbeOk("plugins", "0 plugins", 1), + diagnosticProbeOk("mcpServers", "0 servers", 1), + diagnosticProbeOk("skills", "0 skills", 1), + ], + mcpServerStatuses: overrides.mcpServerStatuses ?? [], + }, + }; +} + +function toolInventory(): ToolInventorySnapshot { + return { + checkedAt: 1, + plugins: [], + pluginMarketplaceErrors: [], + pluginsError: null, + mcpServers: [], + mcpDiagnostics: [], + mcpError: null, + skills: [], + skillsError: null, + }; +} + +function modelFixture(model: string): ModelMetadata { + return { + id: model, + model, + displayName: model, + description: "", + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: "medium", + inputModalities: ["text"], + additionalSpeedTiers: [], + serviceTiers: [], + defaultServiceTier: null, + isDefault: false, + }; +} + +function skillFixture(name: string): SkillMetadata { + return { + name, + description: "", + path: `/skills/${name}`, + enabled: true, + }; +} + +function rateLimitFixture(overrides: Partial = {}): RateLimitSnapshot { + return { + limitId: "codex", + limitName: "Codex", + primary: null, + secondary: null, + individualLimit: null, + rateLimitReachedType: null, + ...overrides, + }; +} + +function serverMetadataFixture(overrides: Partial = {}): SharedServerMetadata { + return { + runtimeConfig: emptyRuntimeConfigSnapshot(), + availableModels: [], + availableSkills: [], + availablePermissionProfiles: [], + rateLimit: null, + serverDiagnostics: createServerDiagnostics(), + ...overrides, + }; +} + +function metadataCacheHost(cache: { current: SharedServerMetadata | null } = { current: null }): { + appServerMetadataSnapshot: () => SharedServerMetadata | null; + updateAppServerMetadata: (updater: (metadata: SharedServerMetadata | null) => SharedServerMetadata | null) => SharedServerMetadata | null; +} { + return { + appServerMetadataSnapshot: () => cache.current, + updateAppServerMetadata: (updater) => { + const next = updater(cache.current); + cache.current = next; + return next; + }, + }; +} + +function mcpServerStatus(): McpServerStatusSummary { + return { + name: "github", + authStatus: "oAuth", + toolCount: 1, + resourceCount: 0, + resourceTemplateCount: 0, + }; +} diff --git a/tests/features/chat/application/threads/thread-start-actions.test.ts b/tests/features/chat/application/threads/thread-start-actions.test.ts new file mode 100644 index 00000000..02dbecee --- /dev/null +++ b/tests/features/chat/application/threads/thread-start-actions.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it, vi } from "vitest"; + +import { emptyRuntimeConfigSnapshot } from "../../../../../src/domain/runtime/config"; +import type { ThreadActivationSnapshot } from "../../../../../src/domain/threads/activation"; +import type { Thread } from "../../../../../src/domain/threads/model"; +import { runtimeSnapshotForChatState } from "../../../../../src/features/chat/application/runtime/snapshot"; +import { createChatStateStore } from "../../../../../src/features/chat/application/state/store"; +import { createThreadStartActions } from "../../../../../src/features/chat/application/threads/thread-start-actions"; +import { setCollaborationModeIntent } from "../../../../../src/features/chat/domain/runtime/intent"; +import { chatStateFixture, chatStateWith } from "../../support/state"; + +describe("thread start actions", () => { + it("publishes newly started threads before the first turn completes", async () => { + let state = chatStateFixture(); + const existing = threadFixture("existing"); + state = chatStateWith(state, { threadList: { listedThreads: [existing] } }); + const stateStore = createChatStateStore(state); + const started = threadFixture("started"); + const optimistic = { ...started, preview: "first prompt" }; + const recordStartedThread = vi.fn(); + const syncThreadGoal = vi.fn(); + + const actions = createThreadStartActions({ + stateStore, + threadStartTransport: { startThread: vi.fn().mockResolvedValue(activationFixture(started)) }, + runtimeSnapshotForState: runtimeSnapshotForChatState, + recordStartedThread, + syncThreadGoal, + }); + + await actions.startThread("first prompt"); + + expect(stateStore.getState().threadList.listedThreads).toEqual([optimistic, existing]); + expect(recordStartedThread).toHaveBeenCalledWith(optimistic); + expect(syncThreadGoal).toHaveBeenCalledWith("started"); + }); + + it("keeps empty-panel runtime reservations when starting the first thread", async () => { + const stateStore = createChatStateStore(chatStateFixture()); + stateStore.dispatch({ type: "runtime/model-requested", model: "gpt-5.5" }); + stateStore.dispatch({ type: "runtime/permission-profile-requested", permissionProfile: ":workspace" }); + stateStore.dispatch({ type: "runtime/reasoning-effort-requested", effort: "high" }); + stateStore.dispatch({ type: "runtime/fast-mode-requested", fastMode: "enabled" }); + stateStore.dispatch({ type: "runtime/approvals-reviewer-requested", approvalsReviewer: "auto_review" }); + stateStore.dispatch({ type: "runtime/requested-collaboration-mode-set", collaborationMode: "plan" }); + const startThread = vi.fn().mockResolvedValue( + activationFixture(threadFixture("started"), { + model: "gpt-5", + serviceTier: "fast", + }), + ); + + const actions = createThreadStartActions({ + stateStore, + threadStartTransport: { startThread }, + runtimeSnapshotForState: runtimeSnapshotForChatState, + recordStartedThread: vi.fn(), + syncThreadGoal: vi.fn(), + }); + + await actions.startThread("first prompt"); + + expect(startThread).toHaveBeenCalledWith({ + permissions: ":workspace", + serviceTier: "fast", + }); + expect(stateStore.getState().runtime.active.model).toBe("gpt-5"); + expect(stateStore.getState().runtime.pending.model).toEqual({ kind: "set", value: "gpt-5.5" }); + expect(stateStore.getState().runtime.pending.permissionProfile).toEqual({ kind: "set", value: ":workspace" }); + expect(stateStore.getState().runtime.pending.reasoningEffort).toEqual({ kind: "set", value: "high" }); + expect(stateStore.getState().runtime.pending.fastMode).toEqual({ kind: "set", value: "enabled" }); + expect(stateStore.getState().runtime.pending.approvalsReviewer).toEqual({ kind: "set", value: "auto_review" }); + expect(stateStore.getState().runtime.pending.collaborationMode).toEqual(setCollaborationModeIntent("plan")); + }); + + it("can skip newly started thread goal sync when the caller sets the first goal", async () => { + const stateStore = createChatStateStore(chatStateFixture()); + const syncThreadGoal = vi.fn(); + const actions = createThreadStartActions({ + stateStore, + threadStartTransport: { startThread: vi.fn().mockResolvedValue(activationFixture(threadFixture("started"))) }, + runtimeSnapshotForState: runtimeSnapshotForChatState, + recordStartedThread: vi.fn(), + syncThreadGoal, + }); + + await actions.startThread("first goal", { syncGoal: false }); + + expect(syncThreadGoal).not.toHaveBeenCalled(); + }); + + it("starts threads with service tier from explicit effective config", async () => { + let state = chatStateFixture(); + state = chatStateWith(state, { connection: { runtimeConfig: { ...emptyRuntimeConfigSnapshot(), serviceTier: "flex" } } }); + const stateStore = createChatStateStore(state); + const startThread = vi.fn().mockResolvedValue(activationFixture(threadFixture("started"), { serviceTier: "flex" })); + const actions = createThreadStartActions({ + stateStore, + threadStartTransport: { startThread }, + runtimeSnapshotForState: runtimeSnapshotForChatState, + recordStartedThread: vi.fn(), + syncThreadGoal: vi.fn(), + }); + + await actions.startThread(); + + expect(startThread).toHaveBeenCalledWith({ serviceTier: "flex" }); + }); + + it("starts threads with permission profile from explicit config", async () => { + let state = chatStateFixture(); + state = chatStateWith(state, { + connection: { + runtimeConfig: { + ...emptyRuntimeConfigSnapshot(), + startupPermissions: { + ...emptyRuntimeConfigSnapshot().startupPermissions, + activePermissionProfile: { id: ":workspace", extends: null }, + }, + }, + }, + }); + const stateStore = createChatStateStore(state); + const startThread = vi.fn().mockResolvedValue(activationFixture(threadFixture("started"))); + const actions = createThreadStartActions({ + stateStore, + threadStartTransport: { startThread }, + runtimeSnapshotForState: runtimeSnapshotForChatState, + recordStartedThread: vi.fn(), + syncThreadGoal: vi.fn(), + }); + + await actions.startThread(); + + expect(startThread).toHaveBeenCalledWith({ permissions: ":workspace" }); + }); + + it("keeps app-server preview when newly started threads already have one", async () => { + const stateStore = createChatStateStore(chatStateFixture()); + const started = threadFixture("started", { preview: "server preview" }); + const recordStartedThread = vi.fn(); + const actions = createThreadStartActions({ + stateStore, + threadStartTransport: { startThread: vi.fn().mockResolvedValue(activationFixture(started)) }, + runtimeSnapshotForState: runtimeSnapshotForChatState, + recordStartedThread, + syncThreadGoal: vi.fn(), + }); + + await actions.startThread("local preview"); + + expect(recordStartedThread).toHaveBeenCalledWith(started); + }); + + it("does not apply newly started threads after the transport returns no activation", async () => { + const stateStore = createChatStateStore(chatStateFixture()); + const recordStartedThread = vi.fn(); + const syncThreadGoal = vi.fn(); + const actions = createThreadStartActions({ + stateStore, + threadStartTransport: { startThread: vi.fn().mockResolvedValue(null) }, + runtimeSnapshotForState: runtimeSnapshotForChatState, + recordStartedThread, + syncThreadGoal, + }); + + await expect(actions.startThread("local preview")).resolves.toBeNull(); + expect(stateStore.getState().activeThread.id).toBeNull(); + expect(stateStore.getState().threadList.listedThreads).toEqual([]); + expect(recordStartedThread).not.toHaveBeenCalled(); + expect(syncThreadGoal).not.toHaveBeenCalled(); + }); +}); + +function threadFixture(id: string, overrides: Partial = {}): Thread { + return { + id, + preview: "", + name: null, + archived: false, + createdAt: 0, + updatedAt: 0, + recencyAt: null, + ...overrides, + }; +} + +function activationFixture(thread: Thread, overrides: Partial = {}): ThreadActivationSnapshot { + return { + thread, + cwd: "/vault", + model: "gpt-5", + serviceTier: null, + approvalsReviewer: null, + reasoningEffort: null, + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, + approvalPolicyKnown: false, + sandboxPolicyKnown: false, + permissionProfileKnown: false, + ...overrides, + }; +} diff --git a/tests/features/chat/host/turn-bundle.test.ts b/tests/features/chat/host/turn-bundle.test.ts index d11f40a6..14857387 100644 --- a/tests/features/chat/host/turn-bundle.test.ts +++ b/tests/features/chat/host/turn-bundle.test.ts @@ -85,7 +85,7 @@ function turnBundleFixture(options: { stateStore?: ReturnType