Move chat app-server actions to owned workflows

This commit is contained in:
murashit 2026-07-08 21:00:48 +09:00
parent 3f2c4fd970
commit d81044c17b
24 changed files with 1177 additions and 1177 deletions

View file

@ -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<void>;
}
export function createChatServerDiagnosticsActions(host: ChatServerDiagnosticsActionsHost): ChatServerDiagnosticsActions {
return {
refreshServerDiagnostics: async (options) => {
await refreshServerDiagnostics(host, options);
},
};
}
async function refreshServerDiagnostics(
host: ChatServerDiagnosticsActionsHost,
options: RefreshServerDiagnosticsOptions = {},
): Promise<boolean> {
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<DiagnosticProbeSnapshot>[] = [];
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<DiagnosticProbeSnapshot> {
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<T>(
id: DiagnosticProbeId,
request: () => Promise<T>,
summarize: (response: T) => string | null,
): Promise<DiagnosticProbeSnapshot> {
try {
const response = await request();
return {
probe: diagnosticProbeOk(id, summarize(response), Date.now()),
};
} catch (error) {
return { probe: diagnosticProbeError(id, error, Date.now()) };
}
}

View file

@ -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,
};
}

View file

@ -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<StartedThreadSummary | null>;
}
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<StartedThreadSummary | null> {
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 };
}

View file

@ -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";

View file

@ -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";

View file

@ -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<string>;
@ -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) =>

View file

@ -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<ServerDiagnosticsSnapshot | null> => {
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<DiagnosticProbeSnapshot>[] = [];
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<T>(
host: CurrentChatAppServerClientHost,
operation: (client: AppServerClient | null) => Promise<T>,
): Promise<T | null> {
const client = host.currentClient();
const result = await operation(client);
return client !== null && host.currentClient() !== client ? null : result;
}
async function readRateLimitDiagnosticProbe(client: AppServerRequestClient): Promise<DiagnosticProbeSnapshot> {
const result = await readRateLimitMetadataProbe(client);
return { probe: result.probe };
}
async function probeDiagnostic<T>(
id: DiagnosticProbeId,
request: () => Promise<T>,
summarize: (response: T) => string | null,
): Promise<DiagnosticProbeSnapshot> {
try {
const response = await request();
return {
probe: diagnosticProbeOk(id, summarize(response), Date.now()),
};
} catch (error) {
return { probe: diagnosticProbeError(id, error, Date.now()) };
}
}

View file

@ -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,

View file

@ -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<T, K extends keyof Diagnostics["probes"]> {
value: T;
probe: Diagnostics["probes"][K];
}
type SkillMetadataProbeResult = MetadataProbeResult<readonly SkillMetadata[], "skills">;
export type RateLimitMetadataProbeResult = MetadataProbeResult<RateLimitSnapshot | null, "rateLimits">;
export interface MetadataResourceTransport {
readSkillMetadata(forceReload?: boolean): Promise<SkillMetadataProbeResult | null>;
readRateLimitMetadata(): Promise<RateLimitMetadataProbeResult | null>;
}
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<ServerDiagnosticsSnapshot | null>;
}

View file

@ -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<void>;
}
export function createServerDiagnosticsActions(host: ServerDiagnosticsActionsHost): ServerDiagnosticsActions {
return {
refreshServerDiagnostics: async (options) => {
await refreshServerDiagnostics(host, options);
},
};
}
async function refreshServerDiagnostics(
host: ServerDiagnosticsActionsHost,
options: RefreshServerDiagnosticsOptions = {},
): Promise<boolean> {
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)
);
}

View file

@ -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<SharedServerMetadata | null>;
isStaleSharedQueryError: (error: unknown) => boolean;
}
export interface ChatServerMetadataActions {
export interface ServerMetadataActions {
applyAppServerMetadata: (metadata: SharedServerMetadata) => void;
refreshAppServerMetadata: () => Promise<SharedServerMetadata | null>;
applyAppServerResourceEvent: (event: AppServerResourceEvent) => Promise<void>;
}
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<void> {
async function applyAppServerResourceEvent(host: ServerMetadataActionsHost, event: AppServerResourceEvent): Promise<void> {
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<SharedServerMetadata | null> {
async function refreshAppServerMetadata(host: ServerMetadataActionsHost): Promise<SharedServerMetadata | null> {
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<SharedServerMetadata | null> {
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<SharedServerMetadata | null> {
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<void> {
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)
);

View file

@ -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<StartedThreadSummary | null>;
}
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<StartedThreadSummary | null> {
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 };
}

View file

@ -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<ThreadActivationSnapshot | null>;
}

View file

@ -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<AppServerClient["respondToServerRequest"]>[0];
type RejectRequestId = Parameters<AppServerClient["rejectServerRequest"]>[0];
type ChatPanelGoalSyncActions = ReturnType<typeof createThreadGoalSyncActions>;
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<void>;
}
@ -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<void> => {
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,
};

View file

@ -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<void>;
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);
},

View file

@ -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<void>;
@ -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,

View file

@ -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,
});

View file

@ -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<readonly Thread[]>) => void;
type MetadataObserver = (result: ObservedResult<SharedServerMetadata>) => 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<readonly Thread[]>): 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<SharedServerMetadata>): 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);
};

View file

@ -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<ThreadStartResponse>();
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<SharedServerMetadata | null>>().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<typeof mcpServerStatus>[] }>();
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<string, ReturnType<typeof vi.fn>> = {}): AppServerClient {
const methodHandlers: Record<string, (params: unknown) => Promise<unknown>> = {
"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<typeof vi.fn>): 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<ThreadStartResponse>)(params);
}),
} as unknown as AppServerClient;
}
function threadFixture(id: string, overrides: Partial<ThreadStartResponse["thread"]> = {}): 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> = {}): RateLimitSnapshot {
return {
limitId: "codex",
limitName: "Codex",
primary: null,
secondary: null,
individualLimit: null,
rateLimitReachedType: null,
...overrides,
};
}
function serverMetadataFixture(overrides: Partial<SharedServerMetadata> = {}): 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<T> {
promise: Promise<T>;
resolve: (value: T) => void;
}
function deferred<T>(): Deferred<T> {
let resolve: (value: T) => void = () => undefined;
const promise = new Promise<T>((settle) => {
resolve = settle;
});
return { promise, resolve };
}

View file

@ -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<AppServerThreadStartResponse>();
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> = {}): 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> = {}): AppServerThreadResumeResponse {
return {
thread: threadRecord(threadId) as AppServerThreadResumeResponse["thread"],

View file

@ -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> = {}): 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> = {}): RateLimitSnapshot {
return {
limitId: "codex",
limitName: "Codex",
primary: null,
secondary: null,
individualLimit: null,
rateLimitReachedType: null,
...overrides,
};
}
function serverMetadataFixture(overrides: Partial<SharedServerMetadata> = {}): 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,
};
}

View file

@ -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> = {}): Thread {
return {
id,
preview: "",
name: null,
archived: false,
createdAt: 0,
updatedAt: 0,
recencyAt: null,
...overrides,
};
}
function activationFixture(thread: Thread, overrides: Partial<ThreadActivationSnapshot> = {}): 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,
};
}

View file

@ -85,7 +85,7 @@ function turnBundleFixture(options: { stateStore?: ReturnType<typeof createChatS
focusComposer: vi.fn(),
},
runtimeSettings: {},
serverThreads: {},
threadStart: {},
goals: {},
autoTitleCoordinator: { resetThreadTurnPresence: vi.fn() },
reconnect: vi.fn(),

View file

@ -663,12 +663,12 @@ export const allowed = true;
await writeFile(
path.join(cwd, "src/features/chat/panel/outer.tsx"),
`
import type { AppServerClient } from "../../../app-server/connection/client";
import type { ChatServerActionsHost } from "../app-server/actions/host";
import type { Host } from "../host/contracts";
export type Escape = AppServerClient | ChatServerActionsHost | Host;
`.trimStart(),
import type { AppServerClient } from "../../../app-server/connection/client";
import type { ChatAppServerGateway } from "../app-server/session-gateway";
import type { Host } from "../host/contracts";
export type Escape = AppServerClient | ChatAppServerGateway | Host;
`.trimStart(),
);
await writeFile(
path.join(cwd, "src/features/chat/panel/allowed.tsx"),
@ -683,16 +683,16 @@ export const toolbar = Toolbar;
await writeFile(
path.join(cwd, "src/features/chat/presentation/outer.ts"),
`
import type { AppServerClient } from "../../../app-server/connection/client";
import type { ChatStateStore } from "../application/state/store";
import type { ChatServerActionsHost } from "../app-server/actions/host";
import type { Host } from "../host/contracts";
import type { ToolbarPanelActions } from "../panel/toolbar-actions";
import { Toolbar } from "../ui/toolbar";
export type Escape = AppServerClient | ChatStateStore | ChatServerActionsHost | Host | ToolbarPanelActions;
export const toolbar = Toolbar;
`.trimStart(),
import type { AppServerClient } from "../../../app-server/connection/client";
import type { ChatStateStore } from "../application/state/store";
import type { ChatAppServerGateway } from "../app-server/session-gateway";
import type { Host } from "../host/contracts";
import type { ToolbarPanelActions } from "../panel/toolbar-actions";
import { Toolbar } from "../ui/toolbar";
export type Escape = AppServerClient | ChatStateStore | ChatAppServerGateway | Host | ToolbarPanelActions;
export const toolbar = Toolbar;
`.trimStart(),
);
await writeFile(
path.join(cwd, "src/features/chat/presentation/allowed.ts"),
@ -706,14 +706,14 @@ export type Allowed = Thread | MessageStreamItem;
await writeFile(
path.join(cwd, "src/features/chat/ui/outer.tsx"),
`
import type { AppServerClient } from "../../../app-server/connection/client";
import type { ChatStateStore } from "../application/state/store";
import type { ChatServerActionsHost } from "../app-server/actions/host";
import type { Host } from "../host/contracts";
import type { ToolbarPanelActions } from "../panel/toolbar-actions";
export type Escape = AppServerClient | ChatStateStore | ChatServerActionsHost | Host | ToolbarPanelActions;
`.trimStart(),
import type { AppServerClient } from "../../../app-server/connection/client";
import type { ChatStateStore } from "../application/state/store";
import type { ChatAppServerGateway } from "../app-server/session-gateway";
import type { Host } from "../host/contracts";
import type { ToolbarPanelActions } from "../panel/toolbar-actions";
export type Escape = AppServerClient | ChatStateStore | ChatAppServerGateway | Host | ToolbarPanelActions;
`.trimStart(),
);
await writeFile(
path.join(cwd, "src/features/chat/ui/allowed.tsx"),