Refine chat session composition ownership

This commit is contained in:
murashit 2026-06-15 08:33:06 +09:00
parent 39cabcd99d
commit 6fe1a837c8
26 changed files with 539 additions and 690 deletions

View file

@ -0,0 +1,21 @@
import type { RuntimeOverride, RuntimeOverrideSettings } from "../../domain/runtime/overrides";
import { runtimeOverride, validatedRuntimeOverrideForModelMetadata } from "../../domain/runtime/overrides";
import type { ModelListResponse } from "../../generated/app-server/v2/ModelListResponse";
import { listModelMetadata } from "./catalog";
interface RuntimeOverrideModelClient {
listModels(includeHidden: boolean): Promise<ModelListResponse>;
}
export async function resolvedRuntimeOverrideForClient(
client: RuntimeOverrideModelClient,
settings: RuntimeOverrideSettings,
): Promise<RuntimeOverride> {
const runtime = runtimeOverride(settings);
if (!runtime.model || !runtime.effort) return runtime;
try {
return validatedRuntimeOverrideForModelMetadata(settings, await listModelMetadata(client));
} catch {
return runtime;
}
}

View file

@ -8,20 +8,12 @@ export type { SharedServerMetadata } from "../../domain/server/metadata";
export interface SharedAppServerCacheContext {
codexPath: string;
vaultPath: string;
appServerUserAgent: string | null;
}
type SharedThreadListSnapshotStatus = "ready" | "stale" | "unavailable";
interface SharedThreadListSnapshot {
status: SharedThreadListSnapshotStatus;
threads: readonly Thread[];
}
type SharedCache<T> = { kind: "unloaded" } | { kind: "loaded"; context: SharedAppServerCacheContext; data: T };
export interface SharedAppServerState {
threads: SharedCache<SharedThreadListSnapshot>;
threads: SharedCache<readonly Thread[]>;
appServerMetadata: SharedCache<SharedServerMetadata>;
availableModels: SharedCache<readonly ModelMetadata[]>;
}
@ -45,7 +37,7 @@ export function applySharedThreadList(
threads: {
kind: "loaded",
context: cloneSharedAppServerCacheContext(context),
data: cloneSharedThreadListSnapshot({ status: "ready", threads }),
data: cloneThreads(threads),
},
};
}
@ -99,8 +91,7 @@ export function applySharedModels(
export function cachedSharedThreadList(state: SharedAppServerState, context: SharedAppServerCacheContext): readonly Thread[] | null {
if (!sharedAppServerCacheContextIsComplete(context)) return null;
if (state.threads.kind !== "loaded" || !sharedAppServerCacheContextMatches(state.threads.context, context)) return null;
if (state.threads.data.status !== "ready") return null;
return cloneThreads(state.threads.data.threads);
return cloneThreads(state.threads.data);
}
export function cachedSharedServerMetadata(state: SharedAppServerState, context: SharedAppServerCacheContext): SharedServerMetadata | null {
@ -175,10 +166,6 @@ function mergeServerDiagnostics(previous: SharedServerMetadata, next: SharedServ
};
}
function cloneSharedThreadListSnapshot(snapshot: SharedThreadListSnapshot): SharedThreadListSnapshot {
return { status: snapshot.status, threads: cloneThreads(snapshot.threads) };
}
function cloneRateLimitSnapshot(snapshot: RateLimitSnapshot): RateLimitSnapshot {
return {
...snapshot,
@ -215,18 +202,12 @@ export function sharedAppServerCacheContextMatches(left: SharedAppServerCacheCon
sharedAppServerCacheContextIsComplete(left) &&
sharedAppServerCacheContextIsComplete(right) &&
left.codexPath === right.codexPath &&
left.vaultPath === right.vaultPath &&
left.appServerUserAgent === right.appServerUserAgent
left.vaultPath === right.vaultPath
);
}
export function sharedAppServerCacheContextIsComplete(context: SharedAppServerCacheContext): boolean {
return (
nonEmptyString(context.codexPath) &&
nonEmptyString(context.vaultPath) &&
context.appServerUserAgent !== null &&
nonEmptyString(context.appServerUserAgent)
);
return nonEmptyString(context.codexPath) && nonEmptyString(context.vaultPath);
}
function nonEmptyString(value: string): boolean {

View file

@ -5,15 +5,11 @@ import {
type EphemeralStructuredTurnRuntimeClient,
type StructuredTurnOutputSchema,
} from "./ephemeral-structured-turn";
import { listModelMetadata } from "./catalog";
import { resolvedRuntimeOverrideForClient } from "./runtime-overrides";
import { conversationAssistantTextFromTurnRecord, type TurnRecord } from "../protocol/turn";
import type { ModelMetadata, ReasoningEffort } from "../../domain/catalog/metadata";
import { runtimeOverride, validatedRuntimeOverrideForModelMetadata } from "../../domain/runtime/overrides";
import {
threadTitleFromGeneratedText,
threadTitlePrompt,
type ThreadTitleContext,
} from "../../domain/threads/title-generation-model";
import type { ReasoningEffort } from "../../domain/catalog/metadata";
import type { RuntimeOverride } from "../../domain/runtime/overrides";
import { threadTitleFromGeneratedText, threadTitlePrompt, type ThreadTitleContext } from "../../domain/threads/title-generation-model";
const THREAD_TITLE_SERVICE_NAME = "codex-panel-naming";
const THREAD_TITLE_TIMEOUT_MS = 60_000;
@ -71,36 +67,16 @@ export async function generateThreadTitleWithCodex(
return threadTitleFromGenerationTurn(turn);
}
interface ThreadTitleRuntimeOverride {
model?: string;
effort?: ReasoningEffort;
}
type ThreadTitleRuntimeOverride = RuntimeOverride;
function threadTitleFromGenerationTurn(turn: TurnRecord): string | null {
const response = conversationAssistantTextFromTurnRecord(turn);
return response ? threadTitleFromGeneratedText(response) : null;
}
function threadTitleRuntimeOverride(settings: ThreadTitleRuntimeSettings): ThreadTitleRuntimeOverride {
return runtimeOverride({ model: settings.threadNamingModel, effort: settings.threadNamingEffort });
}
function validatedThreadTitleRuntimeOverride(
settings: ThreadTitleRuntimeSettings,
models: readonly ModelMetadata[],
): ThreadTitleRuntimeOverride {
return validatedRuntimeOverrideForModelMetadata({ model: settings.threadNamingModel, effort: settings.threadNamingEffort }, models);
}
async function threadTitleRuntimeOverrideForClient(
client: EphemeralStructuredTurnRuntimeClient,
settings: ThreadTitleRuntimeSettings,
): Promise<ThreadTitleRuntimeOverride> {
const runtime = threadTitleRuntimeOverride(settings);
if (!runtime.model || !runtime.effort) return runtime;
try {
return validatedThreadTitleRuntimeOverride(settings, await listModelMetadata(client));
} catch {
return runtime;
}
return resolvedRuntimeOverrideForClient(client, { model: settings.threadNamingModel, effort: settings.threadNamingEffort });
}

View file

@ -41,7 +41,6 @@ export interface ChatConnectionControllerHost {
resetThreadTurnPresence: (hadTurns: boolean) => void;
setStatus: (statusText: string, phase?: ChatConnectionPhase) => void;
addSystemMessage: (text: string) => void;
publishAppServerIdentity: (userAgent: string | null) => void;
configuredCommand: () => string;
refreshLiveState: () => void;
notifyConnectionFailed: () => void;
@ -75,7 +74,6 @@ export class ChatConnectionController {
handleExit(): void {
this.invalidate();
this.host.invalidateResumeWork();
this.host.publishAppServerIdentity(null);
this.host.setStatus(STATUS_CONNECTION_STOPPED, { kind: "disconnected", message: STATUS_CONNECTION_STOPPED });
this.host.stateStore.dispatch({ type: "connection/scoped-cleared" });
this.host.resetThreadTurnPresence(false);
@ -116,12 +114,10 @@ export class ChatConnectionController {
}
private async initializeConnection(connection: ActiveChatConnection): Promise<void> {
this.host.publishAppServerIdentity(null);
this.host.setStatus(STATUS_CONNECTION_STARTING, { kind: "connecting" });
try {
const initialization = await this.host.connection.connect();
if (this.host.connectionWork.isStale(connection)) return;
this.host.publishAppServerIdentity(initialization.userAgent);
this.host.stateStore.dispatch({ type: "connection/initialized", initializeResponse: initialization });
const client = this.host.connection.currentClient();
if (!client) throw new Error("Codex app-server connection did not initialize.");

View file

@ -8,7 +8,6 @@ export interface CodexChatHost {
readonly workspace: WorkspacePanels;
readonly sharedCache: SharedAppServerCacheFacade;
readonly threadSurfaces: ThreadSurfaceBroadcaster;
readonly appServerIdentity: AppServerIdentityPublisher;
}
export interface PluginSettingsRef {
@ -36,7 +35,3 @@ interface SharedAppServerCacheFacade {
cachedThreadList(): readonly Thread[] | null;
cachedAppServerMetadata(): SharedServerMetadata | null;
}
interface AppServerIdentityPublisher {
publishAppServerIdentity(userAgent: string | null): void;
}

View file

@ -31,8 +31,6 @@ interface ThreadPartsContext {
getClosing: () => boolean;
};
thread: {
selectThread: (threadId: string) => Promise<void>;
resumeRestoredThread: (threadId: string) => Promise<void>;
refreshThreads: () => Promise<void>;
notifyIdentityChanged: () => void;
refreshTabHeader: () => void;
@ -96,34 +94,6 @@ export function createThreadParts(context: ThreadPartsContext) {
},
});
const threadManagementHost: ThreadManagementActionsHost = {
stateStore,
vaultPath: settingsRef.vaultPath,
settings: () => settingsRef.settings,
archiveAdapter: obsidian.archiveAdapter,
ensureConnected: client.ensureConnected,
currentClient,
addSystemMessage: status.addSystemMessage,
showNotice: notify.showNotice,
setStatus: status.set,
setComposerText: composer.setText,
openThreadInNewView: (threadId) => workspace.openThreadInNewView(threadId),
openThreadInCurrentPanel: (threadId) => thread.selectThread(threadId),
notifyThreadArchived: (threadId) => {
threadSurfaces.notifyThreadArchived(threadId);
},
notifyThreadRenamed: (threadId, name) => {
threadSurfaces.notifyThreadRenamed(threadId, name);
},
notifyActiveThreadIdentityChanged: () => {
thread.notifyIdentityChanged();
},
refreshThreads: () => thread.refreshThreads(),
refreshSharedThreadListFromOpenSurface: () => {
threadSurfaces.refreshSharedThreadListFromOpenSurface();
},
};
const managementActions = createThreadManagementActions(threadManagementHost);
const goals = createGoalActions({
stateStore,
currentClient,
@ -143,7 +113,6 @@ export function createThreadParts(context: ThreadPartsContext) {
},
lifecycle,
thread: {
resumeRestoredThread: thread.resumeRestoredThread,
notifyIdentityChanged: thread.notifyIdentityChanged,
refreshTabHeader: thread.refreshTabHeader,
},
@ -156,6 +125,34 @@ export function createThreadParts(context: ThreadPartsContext) {
},
});
const { history, restoration, resume, identity } = threadLifecycle;
const threadManagementHost: ThreadManagementActionsHost = {
stateStore,
vaultPath: settingsRef.vaultPath,
settings: () => settingsRef.settings,
archiveAdapter: obsidian.archiveAdapter,
ensureConnected: client.ensureConnected,
currentClient,
addSystemMessage: status.addSystemMessage,
showNotice: notify.showNotice,
setStatus: status.set,
setComposerText: composer.setText,
openThreadInNewView: (threadId) => workspace.openThreadInNewView(threadId),
openThreadInCurrentPanel: (threadId) => resume.resumeThread(threadId),
notifyThreadArchived: (threadId) => {
threadSurfaces.notifyThreadArchived(threadId);
},
notifyThreadRenamed: (threadId, name) => {
threadSurfaces.notifyThreadRenamed(threadId, name);
},
notifyActiveThreadIdentityChanged: () => {
thread.notifyIdentityChanged();
},
refreshThreads: () => thread.refreshThreads(),
refreshSharedThreadListFromOpenSurface: () => {
threadSurfaces.refreshSharedThreadListFromOpenSurface();
},
};
const managementActions = createThreadManagementActions(threadManagementHost);
return {
history,

View file

@ -23,7 +23,6 @@ export interface ThreadLifecyclePartsContext {
getClosing: () => boolean;
};
thread: {
resumeRestoredThread: (threadId: string) => Promise<void>;
notifyIdentityChanged: () => void;
refreshTabHeader: () => void;
};
@ -64,16 +63,17 @@ export function createThreadLifecycleParts(context: ThreadLifecyclePartsContext)
resumeWork.invalidate();
history.invalidate();
};
let resume: ResumeController;
const restoration = new RestorationController({
deferredTasks,
opened: lifecycle.getOpened,
resumeThread: thread.resumeRestoredThread,
resumeThread: (threadId) => resume.resumeThread(threadId),
invalidateResumeWork,
stateStore,
setStatus: status.set,
refreshTabHeader: thread.refreshTabHeader,
});
const resume = new ResumeController({
resume = new ResumeController({
stateStore,
vaultPath: settingsRef.vaultPath,
resumeWork,

View file

@ -0,0 +1,206 @@
import { Notice } from "obsidian";
import type { AppServerClient } from "../../../app-server/connection/client";
import type { ConnectionManager, ConnectionManagerHandlers } from "../../../app-server/connection/connection-manager";
import type { ThreadSurfaceBroadcaster } from "../application/ports/chat-host";
import type { ChatConnectionWorkTracker, ChatViewDeferredTasks } from "../application/lifecycle";
import { ChatConnectionController } from "../application/connection/connection-controller";
import { createChatServerDiagnosticsActions, type ChatServerDiagnosticsActions } from "../app-server/actions/diagnostics";
import { createChatServerMetadataActions, type ChatServerMetadataActions } from "../app-server/actions/metadata";
import { createChatServerThreadActions, type ChatServerThreadActions } from "../app-server/actions/threads";
import { ChatInboundController } from "../app-server/inbound/controller";
import { rejectServerRequest, respondToServerRequest } from "../app-server/requests/responder";
import { runtimeSnapshotForChatState } from "../application/runtime/snapshot";
import type { ChatConnectionPhase } from "../application/state/root-reducer";
import type { ChatStateStore } from "../application/state/store";
import type { GoalActions } from "../application/threads/goal-actions";
import type { AutoTitleController } from "../application/threads/auto-title-controller";
import type { MessageStreamNoticeSection } from "../domain/message-stream/items";
export interface ChatConnectionBundle {
connectionController: ChatConnectionController;
inboundController: ChatInboundController;
serverActions: {
threads: ChatServerThreadActions;
metadata: ChatServerMetadataActions;
diagnostics: ChatServerDiagnosticsActions;
};
}
interface ChatConnectionClientPorts {
currentClient: () => AppServerClient | null;
}
interface ChatConnectionRefreshPorts {
refreshThreads: () => Promise<void>;
refreshSkills: (forceReload?: boolean) => Promise<void>;
}
interface ChatConnectionBundleStatus {
set: (statusText: string, phase?: ChatConnectionPhase) => void;
addSystemMessage: (text: string) => void;
addStructuredSystemMessage: (text: string, details: MessageStreamNoticeSection[]) => void;
}
export interface ChatConnectionBundleContext {
stateStore: ChatStateStore;
vaultPath: string;
connection: ConnectionManager;
connectionWork: ChatConnectionWorkTracker;
deferredTasks: ChatViewDeferredTasks;
threadSurfaces: ThreadSurfaceBroadcaster;
client: ChatConnectionClientPorts;
refresh: ChatConnectionRefreshPorts;
goals: GoalActions;
autoTitle: AutoTitleController;
status: ChatConnectionBundleStatus;
invalidateResumeWork: () => void;
loadSharedThreadList: () => Promise<void>;
refreshDeferredDiagnostics: () => Promise<void>;
refreshTabHeader: () => void;
refreshLiveState: () => void;
configuredCommand: () => string;
}
export interface ChatConnectionEventTargets {
inbound: ChatInboundController;
connectionController: ChatConnectionController;
}
export function createChatConnectionBundle(context: ChatConnectionBundleContext): ChatConnectionBundle {
const { stateStore, vaultPath, connection, connectionWork, deferredTasks, threadSurfaces, client, refresh, goals, autoTitle, status } =
context;
const serverMetadata = createChatServerMetadataActions({
stateStore,
vaultPath,
currentClient: client.currentClient,
publishAppServerMetadata: (metadata) => {
threadSurfaces.publishAppServerMetadata(metadata);
},
});
const serverDiagnostics = createChatServerDiagnosticsActions({
stateStore,
vaultPath,
currentClient: client.currentClient,
publishAppServerMetadata: (metadata) => {
threadSurfaces.publishAppServerMetadata(metadata);
},
serverMetadataSnapshot: () => serverMetadata.serverMetadataSnapshot(),
});
const serverThreads = createChatServerThreadActions({
stateStore,
vaultPath,
currentClient: client.currentClient,
runtimeSnapshotForState: runtimeSnapshotForChatState,
publishThreadList: (threads) => {
threadSurfaces.applyThreadListSnapshot(threads);
},
syncThreadGoal: (threadId) => {
void goals.syncThreadGoal(threadId);
},
});
const serverRequestHost = {
currentClient: client.currentClient,
};
const inboundController = new ChatInboundController(stateStore, {
refreshThreads: () => {
void refresh.refreshThreads();
},
refreshRateLimits: () => {
void serverMetadata.refreshPublishedRateLimits();
},
refreshSkills: (forceReload) => void refresh.refreshSkills(forceReload),
publishAppServerMetadata: () => {
serverMetadata.publishAppServerMetadataSnapshot();
},
maybeNameThread: (threadId, turnId, completedSummary) => {
autoTitle.maybeAutoTitleThread(threadId, turnId, completedSummary);
},
notifyThreadArchived: (threadId) => {
threadSurfaces.notifyThreadArchived(threadId);
},
notifyThreadRenamed: (threadId, name) => {
threadSurfaces.notifyThreadRenamed(threadId, name);
},
recordMcpStartupStatus: (name, mcpStatus, message) => {
serverDiagnostics.recordMcpStartupStatus(name, mcpStatus, message);
},
respondToServerRequest: (requestId, result) => respondToServerRequest(serverRequestHost, requestId, result),
rejectServerRequest: (requestId, code, message) => rejectServerRequest(serverRequestHost, requestId, code, message),
});
const connectionController = new ChatConnectionController({
stateStore,
connection,
connectionWork,
metadata: {
refreshPublishedAppServerMetadata: () => serverMetadata.refreshPublishedAppServerMetadata(),
refreshPublishedSkills: (forceReload) => serverMetadata.refreshPublishedSkills(forceReload),
},
diagnostics: {
refreshPublishedDiagnosticProbes: () => serverDiagnostics.refreshPublishedDiagnosticProbes(),
},
invalidateResumeWork: context.invalidateResumeWork,
loadSharedThreadList: context.loadSharedThreadList,
scheduleDeferredDiagnostics: () => {
deferredTasks.scheduleDiagnostics(() => {
void context.refreshDeferredDiagnostics();
});
},
clearDeferredDiagnostics: () => {
deferredTasks.clearDiagnostics();
},
refreshTabHeader: context.refreshTabHeader,
resetThreadTurnPresence: (hadTurns) => {
autoTitle.resetThreadTurnPresence(hadTurns);
},
setStatus: status.set,
addSystemMessage: status.addSystemMessage,
configuredCommand: context.configuredCommand,
refreshLiveState: context.refreshLiveState,
notifyConnectionFailed: () => {
new Notice("Codex app-server connection failed.");
},
});
return {
connectionController,
inboundController,
serverActions: {
threads: serverThreads,
metadata: serverMetadata,
diagnostics: serverDiagnostics,
},
};
}
export function createChatConnectionEventRouter(callbacks: { deferLiveStateRefresh: () => void }): {
handlers: ConnectionManagerHandlers;
attach: (targets: ChatConnectionEventTargets) => void;
} {
let targets: ChatConnectionEventTargets | null = null;
const currentTargets = () => {
if (!targets) throw new Error("Codex app-server connection event received before chat session parts were initialized.");
return targets;
};
return {
handlers: {
onNotification: (notification) => {
currentTargets().inbound.handleNotification(notification);
callbacks.deferLiveStateRefresh();
},
onServerRequest: (request) => {
currentTargets().inbound.handleServerRequest(request);
callbacks.deferLiveStateRefresh();
},
onLog: (message) => {
currentTargets().inbound.handleAppServerLog(message);
},
onExit: () => {
currentTargets().connectionController.handleExit();
},
},
attach: (nextTargets) => {
targets = nextTargets;
},
};
}

View file

@ -1,7 +1,6 @@
import { Notice, type App, type Component, type EventRef } from "obsidian";
import { ConnectionManager, type ConnectionManagerHandlers } from "../../../app-server/connection/connection-manager";
import type { AppServerClient } from "../../../app-server/connection/client";
import { ConnectionManager } from "../../../app-server/connection/connection-manager";
import type { ModelMetadata } from "../../../domain/catalog/metadata";
import type { Thread } from "../../../domain/threads/model";
import { getThreadTitle } from "../../../domain/threads/model";
@ -10,11 +9,8 @@ import { shortThreadId } from "../../../utils";
import type { OpenCodexPanelSnapshot } from "../../../workspace/open-panel-snapshot";
import type { ArchiveExportAdapter } from "../../../domain/threads/archive-markdown";
import type { CodexChatHost } from "../application/ports/chat-host";
import { ChatConnectionController } from "../application/connection/connection-controller";
import type { ChatConnectionController } from "../application/connection/connection-controller";
import { reconnectPanel, type ChatReconnectActionsHost } from "../application/connection/reconnect-actions";
import { createChatServerDiagnosticsActions, type ChatServerDiagnosticsActions } from "../app-server/actions/diagnostics";
import { createChatServerMetadataActions, type ChatServerMetadataActions } from "../app-server/actions/metadata";
import { createChatServerThreadActions, type ChatServerThreadActions } from "../app-server/actions/threads";
import type { ChatComposerController } from "../panel/composer-controller";
import { createConversationParts } from "./conversation";
import type { ComposerSubmitActions } from "../application/conversation/composer-submit-actions";
@ -31,8 +27,6 @@ import { ChatConnectionWorkTracker, ChatResumeWorkTracker, type ChatViewDeferred
import { createChatPanelToolbarActions, createToolbarPanelActions, type ToolbarPanelActions } from "../panel/toolbar-actions";
import { connectionDiagnosticsModel } from "../panel/surface/toolbar-projection";
import { openPanelTurnLifecycle, parseRestoredThreadState } from "../panel/snapshot";
import { ChatInboundController } from "../app-server/inbound/controller";
import { rejectServerRequest, respondToServerRequest } from "../app-server/requests/responder";
import { collaborationModeLabel as formatCollaborationModeLabel } from "../presentation/runtime/messages";
import { createChatRuntimeSettingsActions } from "../application/runtime/settings-actions";
import { runtimeSnapshotForChatState, type RuntimeSnapshot } from "../application/runtime/snapshot";
@ -41,19 +35,17 @@ import { createChatMessageScrollIntentState, type ChatMessageScrollIntentState }
import { renderChatPanelShell, unmountChatPanelShell } from "../panel/shell";
import { chatTurnBusy, type ChatAction, type ChatConnectionPhase, type ChatState } from "../application/state/root-reducer";
import { createChatStateStore, type ChatStateStore } from "../application/state/store";
import type { GoalActions } from "../application/threads/goal-actions";
import type { AutoTitleController } from "../application/threads/auto-title-controller";
import type { HistoryController } from "../application/threads/history-controller";
import type { IdentitySync } from "../application/threads/identity-sync";
import type { ThreadRenameEditorController } from "../application/threads/rename-editor-controller";
import type { RestorationController } from "../application/threads/restoration-controller";
import type { ResumeController } from "../application/threads/resume-controller";
import type { SelectionActions } from "../application/threads/selection-actions";
import { createThreadParts, createThreadSelectionActions } from "../application/threads/composition";
import { createChatConnectionBundle, createChatConnectionEventRouter, type ChatConnectionBundle } from "./connection-bundle";
import type { MessageStreamPresenter } from "../panel/surface/message-stream-presenter";
import { pendingRequestsSignature } from "../domain/pending-requests/signatures";
import { createChatPanelSurface } from "../panel/surface/create-surface";
import type { ChatPanelSurface } from "../panel/surface/model";
import { createChatPanelGoalSurface } from "../panel/surface/goal-surface";
import type { ChatPanelComposerSurface, ChatPanelGoalSurface, ChatPanelToolbarSurface } from "../panel/surface/model";
import type { ToolbarActions } from "../ui/toolbar";
export interface ChatPanelEnvironment {
@ -81,9 +73,9 @@ interface ChatPanelSessionParts {
controller: ChatConnectionController;
};
serverActions: {
threads: ChatServerThreadActions;
metadata: ChatServerMetadataActions;
diagnostics: ChatServerDiagnosticsActions;
threads: ChatConnectionBundle["serverActions"]["threads"];
metadata: ChatConnectionBundle["serverActions"]["metadata"];
diagnostics: ChatConnectionBundle["serverActions"]["diagnostics"];
};
thread: {
history: HistoryController;
@ -103,32 +95,11 @@ interface ChatPanelSessionParts {
render: {
messageStreamPresenter: MessageStreamPresenter;
};
surface: ChatPanelSurface;
}
interface ChatPanelSessionDeferredRef<T> {
get: () => T;
set: (value: T) => void;
}
interface ChatPanelSessionPorts {
currentClient: () => AppServerClient | null;
ensureConnected: () => Promise<void>;
refreshThreads: () => Promise<void>;
refreshSkills: (forceReload?: boolean) => Promise<void>;
selectThread: (threadId: string) => Promise<void>;
resumeRestoredThread: (threadId: string) => Promise<void>;
}
interface ChatPanelSessionServerParts {
connectionController: ChatConnectionController;
inboundController: ChatInboundController;
serverActions: ChatPanelSessionParts["serverActions"];
}
interface ChatPanelSessionConnectionEventTargets {
inbound: ChatInboundController;
connectionController: ChatConnectionController;
surface: {
toolbar: ChatPanelToolbarSurface;
goal: ChatPanelGoalSurface;
composer: ChatPanelComposerSurface;
};
}
interface ChatSessionSideEffects {
@ -142,19 +113,6 @@ interface ChatSessionSideEffects {
};
}
function createChatPanelSessionDeferredRef<T>(name: string): ChatPanelSessionDeferredRef<T> {
let value: T | null = null;
return {
get: () => {
if (value === null) throw new Error(`${name} was used before chat session parts were initialized.`);
return value;
},
set: (nextValue) => {
value = nextValue;
},
};
}
interface ChatPanelWarmupHost {
deferredTasks: ChatViewDeferredTasks;
opened: () => boolean;
@ -342,32 +300,25 @@ export class ChatPanelSession {
}
private createSessionParts(): ChatPanelSessionParts {
const connectionHandlers = this.createConnectionHandlers();
const connectionHandlers = createChatConnectionEventRouter({
deferLiveStateRefresh: () => {
this.deferLiveStateRefresh();
},
});
const connection = new ConnectionManager(
() => this.environment.plugin.settingsRef.settings.codexPath,
this.environment.plugin.settingsRef.vaultPath,
connectionHandlers.handlers,
);
const connectionControllerRef = createChatPanelSessionDeferredRef<ChatConnectionController>("chat connection controller");
const resumeRef = createChatPanelSessionDeferredRef<ResumeController>("chat thread resume controller");
const restorationRef = createChatPanelSessionDeferredRef<RestorationController>("chat thread restoration controller");
const selectionRef = createChatPanelSessionDeferredRef<SelectionActions>("chat thread selection actions");
const composerControllerRef = createChatPanelSessionDeferredRef<ChatComposerController>("chat composer controller");
const sideEffects = this.createSideEffects({
composerController: composerControllerRef.get,
});
const sessionPorts: ChatPanelSessionPorts = {
currentClient: () => connection.currentClient(),
ensureConnected: () => connectionControllerRef.get().ensureConnected(),
refreshThreads: () => connectionControllerRef.get().refreshThreads(),
refreshSkills: (forceReload) => connectionControllerRef.get().refreshSkills(forceReload),
selectThread: (threadId) => selectionRef.get().selectThread(threadId),
resumeRestoredThread: (threadId) => resumeRef.get().resumeThread(threadId),
};
const sideEffects = this.createSideEffects();
const currentClient = () => connection.currentClient();
const ensureConnected = () => this.parts.connection.controller.ensureConnected();
const refreshThreads = () => this.parts.connection.controller.refreshThreads();
const refreshSkills = (forceReload?: boolean) => this.parts.connection.controller.refreshSkills(forceReload);
const runtimeSettings = createChatRuntimeSettingsActions({
stateStore: this.stateStore,
currentClient: sessionPorts.currentClient,
currentClient,
runtimeSnapshotForState: runtimeSnapshotForChatState,
collaborationModeLabel: () => this.collaborationModeLabel(),
addSystemMessage: sideEffects.status.addSystemMessage,
@ -389,8 +340,8 @@ export class ChatPanelSession {
getClosing: () => this.closing,
},
client: {
getClient: sessionPorts.currentClient,
ensureConnected: sessionPorts.ensureConnected,
getClient: currentClient,
ensureConnected,
},
status: sideEffects.status,
notify: {
@ -399,9 +350,7 @@ export class ChatPanelSession {
},
},
thread: {
selectThread: sessionPorts.selectThread,
resumeRestoredThread: sessionPorts.resumeRestoredThread,
refreshThreads: sessionPorts.refreshThreads,
refreshThreads,
notifyIdentityChanged: () => {
this.notifyActiveThreadIdentityChanged();
},
@ -425,8 +374,6 @@ export class ChatPanelSession {
composer: sideEffects.composer,
});
const { history, managementActions: threadActions, goals, identity, restoration, resume, rename, autoTitle } = threadParts;
resumeRef.set(resume);
restorationRef.set(restoration);
const toolbarPanels = createToolbarPanelActions({
stateStore: this.stateStore,
threadActions,
@ -448,7 +395,6 @@ export class ChatPanelSession {
},
},
);
selectionRef.set(selection);
const reconnectHost: ChatReconnectActionsHost = {
stateStore: this.stateStore,
@ -465,20 +411,42 @@ export class ChatPanelSession {
connection.reconnect();
},
setStatus: sideEffects.status.set,
ensureConnected: sessionPorts.ensureConnected,
ensureConnected,
resumeThread: (threadId) => resume.resumeThread(threadId),
addSystemMessage: sideEffects.status.addSystemMessage,
};
const reconnect = () => reconnectPanel(reconnectHost);
const serverParts = this.createServerParts({
const serverParts = createChatConnectionBundle({
stateStore: this.stateStore,
vaultPath: this.environment.plugin.settingsRef.vaultPath,
connection,
sideEffects,
sessionPorts,
connectionWork: this.connectionWork,
deferredTasks: this.deferredTasks,
threadSurfaces: this.environment.plugin.threadSurfaces,
client: {
currentClient,
},
refresh: {
refreshThreads,
refreshSkills,
},
goals,
autoTitle,
status: sideEffects.status,
invalidateResumeWork: () => {
this.invalidateResumeWork();
},
loadSharedThreadList: () => this.loadSharedThreadList(),
refreshDeferredDiagnostics: () => this.refreshDeferredDiagnostics(),
refreshTabHeader: () => {
this.refreshTabHeader();
},
refreshLiveState: () => {
this.refreshLiveState();
},
configuredCommand: () => this.environment.plugin.settingsRef.settings.codexPath,
});
const { connectionController, inboundController } = serverParts;
connectionControllerRef.set(connectionController);
connectionHandlers.attach({ inbound: inboundController, connectionController });
const { threads: serverThreads, diagnostics: serverDiagnostics } = serverParts.serverActions;
@ -497,22 +465,38 @@ export class ChatPanelSession {
selection,
},
);
const surface = createChatPanelSurface(
const toolbarSurface: ChatPanelToolbarSurface = {
state: {
connected: () => connection.isConnected(),
nowMs: () => Date.now(),
},
settings: {
vaultPath: () => this.environment.plugin.settingsRef.vaultPath,
configuredCommand: () => this.environment.plugin.settingsRef.settings.codexPath,
archiveExportEnabled: () => this.environment.plugin.settingsRef.settings.archiveExportEnabled,
},
};
const goalSurface = createChatPanelGoalSurface(
{
settings: this.environment.plugin.settingsRef.settings,
vaultPath: this.environment.plugin.settingsRef.vaultPath,
stateStore: this.stateStore,
restoredThreadPlaceholder: () => restoration.placeholder(),
},
{
connection,
connectionController,
inboundController,
threadStarter: serverThreads,
runtimeSettings,
goals,
},
);
const composerSurface: ChatPanelComposerSurface = {
thread: {
restoredPlaceholder: () => restoration.placeholder(),
},
runtime: {
requestModel: (model) => runtimeSettings.requestModelFromUi(model),
requestReasoningEffort: (effort) => runtimeSettings.requestReasoningEffortFromUi(effort),
},
};
const conversationParts = createConversationParts(
{
obsidian: {
@ -535,7 +519,7 @@ export class ChatPanelSession {
this.state.requests.pendingUserInputs,
this.state.requests.userInputDrafts,
),
composerProjection: (state) => chatPanelComposerProjection(surface.composer, state),
composerProjection: (state) => chatPanelComposerProjection(composerSurface, state),
},
runtime: {
connectionDiagnosticDetails: () => this.connectionDiagnosticDetails(),
@ -550,14 +534,14 @@ export class ChatPanelSession {
},
},
client: {
getClient: sessionPorts.currentClient,
ensureConnected: sessionPorts.ensureConnected,
getClient: currentClient,
ensureConnected,
},
status: sideEffects.status,
thread: {
ensureRestoredThreadLoaded: () => restorationRef.get().ensureLoaded(),
ensureRestoredThreadLoaded: () => restoration.ensureLoaded(),
startNewThread: () => this.startNewThread(),
selectThread: sessionPorts.selectThread,
selectThread: (threadId) => selection.selectThread(threadId),
notifyIdentityChanged: () => {
this.notifyActiveThreadIdentityChanged();
},
@ -586,7 +570,6 @@ export class ChatPanelSession {
);
const { composerSubmit, messageStreamPresenter } = conversationParts;
const composerController = conversationParts.composerController;
composerControllerRef.set(composerController);
return {
connection: {
@ -612,168 +595,15 @@ export class ChatPanelSession {
render: {
messageStreamPresenter,
},
surface,
};
}
private createServerParts({
connection,
sideEffects,
sessionPorts,
goals,
autoTitle,
}: {
connection: ConnectionManager;
sideEffects: ChatSessionSideEffects;
sessionPorts: ChatPanelSessionPorts;
goals: GoalActions;
autoTitle: AutoTitleController;
}): ChatPanelSessionServerParts {
const serverMetadata = createChatServerMetadataActions({
stateStore: this.stateStore,
vaultPath: this.environment.plugin.settingsRef.vaultPath,
currentClient: sessionPorts.currentClient,
publishAppServerMetadata: (metadata) => {
this.environment.plugin.threadSurfaces.publishAppServerMetadata(metadata);
},
});
const serverDiagnostics = createChatServerDiagnosticsActions({
stateStore: this.stateStore,
vaultPath: this.environment.plugin.settingsRef.vaultPath,
currentClient: sessionPorts.currentClient,
publishAppServerMetadata: (metadata) => {
this.environment.plugin.threadSurfaces.publishAppServerMetadata(metadata);
},
serverMetadataSnapshot: () => serverMetadata.serverMetadataSnapshot(),
});
const serverThreads = createChatServerThreadActions({
stateStore: this.stateStore,
vaultPath: this.environment.plugin.settingsRef.vaultPath,
currentClient: sessionPorts.currentClient,
runtimeSnapshotForState: runtimeSnapshotForChatState,
publishThreadList: (threads) => {
this.environment.plugin.threadSurfaces.applyThreadListSnapshot(threads);
},
syncThreadGoal: (threadId) => {
void goals.syncThreadGoal(threadId);
},
});
const serverRequestHost = {
currentClient: sessionPorts.currentClient,
};
const inboundController = new ChatInboundController(this.stateStore, {
refreshThreads: () => {
void sessionPorts.refreshThreads();
},
refreshRateLimits: () => {
void serverMetadata.refreshPublishedRateLimits();
},
refreshSkills: (forceReload) => void sessionPorts.refreshSkills(forceReload),
publishAppServerMetadata: () => {
serverMetadata.publishAppServerMetadataSnapshot();
},
maybeNameThread: (threadId, turnId, completedSummary) => {
autoTitle.maybeAutoTitleThread(threadId, turnId, completedSummary);
},
notifyThreadArchived: (threadId) => {
this.environment.plugin.threadSurfaces.notifyThreadArchived(threadId);
},
notifyThreadRenamed: (threadId, name) => {
this.environment.plugin.threadSurfaces.notifyThreadRenamed(threadId, name);
},
recordMcpStartupStatus: (name, status, message) => {
serverDiagnostics.recordMcpStartupStatus(name, status, message);
},
respondToServerRequest: (requestId, result) => respondToServerRequest(serverRequestHost, requestId, result),
rejectServerRequest: (requestId, code, message) => rejectServerRequest(serverRequestHost, requestId, code, message),
});
const connectionController = new ChatConnectionController({
stateStore: this.stateStore,
connection,
connectionWork: this.connectionWork,
metadata: {
refreshPublishedAppServerMetadata: () => serverMetadata.refreshPublishedAppServerMetadata(),
refreshPublishedSkills: (forceReload) => serverMetadata.refreshPublishedSkills(forceReload),
},
diagnostics: {
refreshPublishedDiagnosticProbes: () => serverDiagnostics.refreshPublishedDiagnosticProbes(),
},
invalidateResumeWork: () => {
this.invalidateResumeWork();
},
loadSharedThreadList: () => this.loadSharedThreadList(),
scheduleDeferredDiagnostics: () => {
this.deferredTasks.scheduleDiagnostics(() => {
void this.refreshDeferredDiagnostics();
});
},
clearDeferredDiagnostics: () => {
this.deferredTasks.clearDiagnostics();
},
refreshTabHeader: () => {
this.refreshTabHeader();
},
resetThreadTurnPresence: (hadTurns) => {
autoTitle.resetThreadTurnPresence(hadTurns);
},
setStatus: sideEffects.status.set,
addSystemMessage: sideEffects.status.addSystemMessage,
publishAppServerIdentity: (userAgent) => {
this.environment.plugin.appServerIdentity.publishAppServerIdentity(userAgent);
},
configuredCommand: () => this.environment.plugin.settingsRef.settings.codexPath,
refreshLiveState: () => {
this.refreshLiveState();
},
notifyConnectionFailed: () => {
new Notice("Codex app-server connection failed.");
},
});
return {
connectionController,
inboundController,
serverActions: {
threads: serverThreads,
metadata: serverMetadata,
diagnostics: serverDiagnostics,
surface: {
toolbar: toolbarSurface,
goal: goalSurface,
composer: composerSurface,
},
};
}
private createConnectionHandlers(): {
handlers: ConnectionManagerHandlers;
attach: (targets: ChatPanelSessionConnectionEventTargets) => void;
} {
let targets: ChatPanelSessionConnectionEventTargets | null = null;
const currentTargets = () => {
if (!targets) throw new Error("Codex app-server connection event received before chat session parts were initialized.");
return targets;
};
return {
handlers: {
onNotification: (notification) => {
currentTargets().inbound.handleNotification(notification);
this.deferLiveStateRefresh();
},
onServerRequest: (request) => {
currentTargets().inbound.handleServerRequest(request);
this.deferLiveStateRefresh();
},
onLog: (message) => {
currentTargets().inbound.handleAppServerLog(message);
},
onExit: () => {
currentTargets().connectionController.handleExit();
},
},
attach: (nextTargets) => {
targets = nextTargets;
},
};
}
private createSideEffects(refs: { composerController: () => ChatComposerController }): ChatSessionSideEffects {
private createSideEffects(): ChatSessionSideEffects {
return {
status: {
set: (statusText, phase) => {
@ -788,7 +618,7 @@ export class ChatPanelSession {
},
composer: {
setText: (text) => {
refs.composerController().setDraft(text, { focus: true });
this.parts.composer.controller.setDraft(text, { focus: true });
},
},
};

View file

@ -1,66 +0,0 @@
import type { CodexPanelSettings } from "../../../../settings/model";
import type { ConnectionManager } from "../../../../app-server/connection/connection-manager";
import type { ChatStateStore } from "../../application/state/store";
import type { ChatConnectionController } from "../../application/connection/connection-controller";
import type { ChatInboundController } from "../../app-server/inbound/controller";
import type { ChatRuntimeSettingsActions } from "../../application/runtime/settings-actions";
import type { GoalActions } from "../../application/threads/goal-actions";
import type { RestoredThreadTitleSnapshot, ChatPanelSurface } from "./model";
import { createChatPanelGoalSurface } from "./goal-surface";
export interface ChatPanelSurfaceHost {
settings: CodexPanelSettings;
vaultPath: string;
stateStore: ChatStateStore;
restoredThreadPlaceholder: () => RestoredThreadTitleSnapshot | null;
}
export interface ChatPanelSurfaceDependencies {
connection: ConnectionManager;
connectionController: ChatConnectionController;
inboundController: ChatInboundController;
threadStarter: ChatPanelThreadStarter;
runtimeSettings: ChatRuntimeSettingsActions;
goals: GoalActions;
}
interface ChatPanelThreadStarter {
startThread: (preview?: string, options?: { syncGoal?: boolean }) => Promise<{ threadId: string } | null>;
}
export function createChatPanelSurface(host: ChatPanelSurfaceHost, deps: ChatPanelSurfaceDependencies): ChatPanelSurface {
return {
toolbar: {
state: {
connected: () => deps.connection.isConnected(),
nowMs: () => Date.now(),
},
settings: {
vaultPath: () => host.vaultPath,
configuredCommand: () => host.settings.codexPath,
archiveExportEnabled: () => host.settings.archiveExportEnabled,
},
},
goal: createChatPanelGoalSurface(
{
settings: host.settings,
stateStore: host.stateStore,
},
{
connectionController: deps.connectionController,
inboundController: deps.inboundController,
threadStarter: deps.threadStarter,
goals: deps.goals,
},
),
composer: {
thread: {
restoredPlaceholder: host.restoredThreadPlaceholder,
},
runtime: {
requestModel: (model) => deps.runtimeSettings.requestModelFromUi(model),
requestReasoningEffort: (effort) => deps.runtimeSettings.requestReasoningEffortFromUi(effort),
},
},
};
}

View file

@ -83,9 +83,3 @@ export interface ChatPanelComposerSurface {
requestReasoningEffort: (effort: ReasoningEffort) => Promise<void>;
};
}
export interface ChatPanelSurface {
toolbar: ChatPanelToolbarSurface;
goal: ChatPanelGoalSurface;
composer: ChatPanelComposerSurface;
}

View file

@ -1,141 +0,0 @@
import { activeAgentRunSummary } from "./agent-summary";
import type { AgentRunSummary, MessageStreamItem, TaskProgressMessageStreamItem } from "../../domain/message-stream/items";
import {
messageStreamIsCoordinationProgress,
messageStreamIsTaskProgress,
messageStreamSemanticClassifications,
} from "../../domain/message-stream/semantics";
import type { MessageStreamSemanticClassification } from "../../domain/message-stream/semantics";
import { messageStreamLayoutBlocks, type MessageStreamLayoutBlock } from "./layout";
export interface MessageStreamPresentationBlockInput {
activeThreadId: string | null;
activeTurnId: string | null;
historyCursor: string | null;
loadingHistory: boolean;
items: readonly MessageStreamItem[];
stableItems?: readonly MessageStreamItem[] | undefined;
activeItems?: readonly MessageStreamItem[] | undefined;
workspaceRoot?: string | null | undefined;
turnDiffs?: ReadonlyMap<string, string> | undefined;
}
export type MessageStreamPresentationBlock =
| {
kind: "historyBar";
key: "history-bar";
loadingHistory: boolean;
}
| {
kind: "empty";
key: "empty";
}
| {
kind: "item";
key: string;
block: Extract<MessageStreamLayoutBlock, { type: "item" }>;
}
| {
kind: "activityGroup";
key: string;
block: Extract<MessageStreamLayoutBlock, { type: "activityGroup" }>;
}
| {
kind: "liveTask";
key: string;
item: TaskProgressMessageStreamItem;
}
| {
kind: "liveAgentSummary";
key: string;
summary: AgentRunSummary;
};
export function messageStreamPresentationBlocks(input: MessageStreamPresentationBlockInput): MessageStreamPresentationBlock[] {
const blocks: MessageStreamPresentationBlock[] = [];
if (input.activeThreadId && input.historyCursor) {
blocks.push({ kind: "historyBar", key: "history-bar", loadingHistory: input.loadingHistory });
}
if (messageStreamBlockItemsEmpty(input)) {
blocks.push({ kind: "empty", key: "empty" });
return blocks;
}
for (const block of layoutBlocksForInput(input)) {
if (block.type === "item") {
blocks.push({ kind: "item", key: `item:${block.item.id}`, block });
} else {
blocks.push({ kind: "activityGroup", key: `activity:${block.id}`, block });
}
}
if (input.activeTurnId) blocks.push(...activeTurnLiveBlocks(input, input.activeTurnId));
return blocks;
}
function messageStreamBlockItemsEmpty(input: MessageStreamPresentationBlockInput): boolean {
if (!input.stableItems && !input.activeItems) return input.items.length === 0;
return (input.stableItems?.length ?? 0) === 0 && (input.activeItems?.length ?? 0) === 0;
}
function layoutBlocksForInput(input: MessageStreamPresentationBlockInput): MessageStreamLayoutBlock[] {
const { activeTurnId } = input;
if (!activeTurnId || !input.stableItems || !input.activeItems) {
const streamItems = activeTurnId ? withoutActiveTaskProgress(input.items, activeTurnId) : input.items;
return messageStreamLayoutBlocks(streamItems, activeTurnId, input.workspaceRoot, input.turnDiffs);
}
const stableBlocks = messageStreamLayoutBlocks(input.stableItems, activeTurnId, input.workspaceRoot, input.turnDiffs);
const activeBlocks = messageStreamLayoutBlocks(
withoutActiveTaskProgress(input.activeItems, activeTurnId),
activeTurnId,
input.workspaceRoot,
input.turnDiffs,
);
return [...stableBlocks, ...activeBlocks];
}
function activeTurnLiveBlocks(
input: Pick<MessageStreamPresentationBlockInput, "items" | "activeItems">,
activeTurnId: string,
): MessageStreamPresentationBlock[] {
const items = input.activeItems ?? input.items;
const semanticItems = messageStreamSemanticClassifications(items);
const agentSummaryAnchorId = activeAgentRunSummaryAnchorId(semanticItems, activeTurnId);
const agentSummary = agentSummaryAnchorId ? activeAgentRunSummary(items, activeTurnId) : null;
return semanticItems.flatMap((classification): MessageStreamPresentationBlock[] => {
const { item } = classification;
if (messageStreamIsTaskProgress(classification) && item.turnId === activeTurnId) {
return [
{
kind: "liveTask",
key: `live-task:${item.id}`,
item: taskProgressStreamItem(item),
},
];
}
if (item.id === agentSummaryAnchorId) {
return agentSummary ? [{ kind: "liveAgentSummary", key: `live-agents:${activeTurnId}`, summary: agentSummary }] : [];
}
return [];
});
}
function activeAgentRunSummaryAnchorId(items: readonly MessageStreamSemanticClassification[], activeTurnId: string): string | null {
const firstActiveAgent = items.find(
(classification) => messageStreamIsCoordinationProgress(classification) && classification.item.turnId === activeTurnId,
);
return firstActiveAgent?.item.id ?? null;
}
function taskProgressStreamItem(item: MessageStreamItem): TaskProgressMessageStreamItem {
if (item.kind !== "taskProgress") throw new Error(`Expected task progress presentation item for ${item.id}`);
return item;
}
function withoutActiveTaskProgress(items: readonly MessageStreamItem[], activeTurnId: string): MessageStreamItem[] {
return items.filter((item) => item.kind !== "taskProgress" || item.turnId !== activeTurnId);
}

View file

@ -1,7 +1,13 @@
import { activeAgentRunSummary } from "./agent-summary";
import { messageStreamRenderFamily } from "../../domain/message-stream/semantics";
import type { MessageStreamSemanticClassification } from "../../domain/message-stream/semantics";
import { messageStreamPresentationBlocks, type MessageStreamPresentationBlock, type MessageStreamPresentationBlockInput } from "./blocks";
import type { MessageStreamItemAnnotations, MessageStreamLayoutBlock } from "./layout";
import {
messageStreamIsCoordinationProgress,
messageStreamIsTaskProgress,
messageStreamSemanticClassifications,
type MessageStreamSemanticClassification,
} from "../../domain/message-stream/semantics";
import type { AgentRunSummary, MessageStreamItem, TaskProgressMessageStreamItem } from "../../domain/message-stream/items";
import { messageStreamLayoutBlocks, type MessageStreamItemAnnotations, type MessageStreamLayoutBlock } from "./layout";
import { messageStreamTextView, type MessageStreamTextView, type TextMessageStreamItem } from "./text-view";
import { toolResultView, type ToolResultMessageStreamItem, type ToolResultView } from "./tool-result-view";
import {
@ -12,6 +18,49 @@ import {
type WorkMessageStreamItem,
} from "./work-view";
export interface MessageStreamPresentationBlockInput {
activeThreadId: string | null;
activeTurnId: string | null;
historyCursor: string | null;
loadingHistory: boolean;
items: readonly MessageStreamItem[];
stableItems?: readonly MessageStreamItem[] | undefined;
activeItems?: readonly MessageStreamItem[] | undefined;
workspaceRoot?: string | null | undefined;
turnDiffs?: ReadonlyMap<string, string> | undefined;
}
export type MessageStreamPresentationBlock =
| {
kind: "historyBar";
key: "history-bar";
loadingHistory: boolean;
}
| {
kind: "empty";
key: "empty";
}
| {
kind: "item";
key: string;
block: Extract<MessageStreamLayoutBlock, { type: "item" }>;
}
| {
kind: "activityGroup";
key: string;
block: Extract<MessageStreamLayoutBlock, { type: "activityGroup" }>;
}
| {
kind: "liveTask";
key: string;
item: TaskProgressMessageStreamItem;
}
| {
kind: "liveAgentSummary";
key: string;
summary: AgentRunSummary;
};
export type MessageStreamRenderedItemView =
| {
kind: "text";
@ -74,6 +123,95 @@ export function messageStreamViewHasEmptyBlock(blocks: readonly MessageStreamVie
return blocks.some((block) => block.kind === "empty");
}
export function messageStreamPresentationBlocks(input: MessageStreamPresentationBlockInput): MessageStreamPresentationBlock[] {
const blocks: MessageStreamPresentationBlock[] = [];
if (input.activeThreadId && input.historyCursor) {
blocks.push({ kind: "historyBar", key: "history-bar", loadingHistory: input.loadingHistory });
}
if (messageStreamBlockItemsEmpty(input)) {
blocks.push({ kind: "empty", key: "empty" });
return blocks;
}
for (const block of layoutBlocksForInput(input)) {
if (block.type === "item") {
blocks.push({ kind: "item", key: `item:${block.item.id}`, block });
} else {
blocks.push({ kind: "activityGroup", key: `activity:${block.id}`, block });
}
}
if (input.activeTurnId) blocks.push(...activeTurnLiveBlocks(input, input.activeTurnId));
return blocks;
}
function messageStreamBlockItemsEmpty(input: MessageStreamPresentationBlockInput): boolean {
if (!input.stableItems && !input.activeItems) return input.items.length === 0;
return (input.stableItems?.length ?? 0) === 0 && (input.activeItems?.length ?? 0) === 0;
}
function layoutBlocksForInput(input: MessageStreamPresentationBlockInput): MessageStreamLayoutBlock[] {
const { activeTurnId } = input;
if (!activeTurnId || !input.stableItems || !input.activeItems) {
const streamItems = activeTurnId ? withoutActiveTaskProgress(input.items, activeTurnId) : input.items;
return messageStreamLayoutBlocks(streamItems, activeTurnId, input.workspaceRoot, input.turnDiffs);
}
const stableBlocks = messageStreamLayoutBlocks(input.stableItems, activeTurnId, input.workspaceRoot, input.turnDiffs);
const activeBlocks = messageStreamLayoutBlocks(
withoutActiveTaskProgress(input.activeItems, activeTurnId),
activeTurnId,
input.workspaceRoot,
input.turnDiffs,
);
return [...stableBlocks, ...activeBlocks];
}
function activeTurnLiveBlocks(
input: Pick<MessageStreamPresentationBlockInput, "items" | "activeItems">,
activeTurnId: string,
): MessageStreamPresentationBlock[] {
const items = input.activeItems ?? input.items;
const semanticItems = messageStreamSemanticClassifications(items);
const agentSummaryAnchorId = activeAgentRunSummaryAnchorId(semanticItems, activeTurnId);
const agentSummary = agentSummaryAnchorId ? activeAgentRunSummary(items, activeTurnId) : null;
return semanticItems.flatMap((classification): MessageStreamPresentationBlock[] => {
const { item } = classification;
if (messageStreamIsTaskProgress(classification) && item.turnId === activeTurnId) {
return [
{
kind: "liveTask",
key: `live-task:${item.id}`,
item: taskProgressStreamItem(item),
},
];
}
if (item.id === agentSummaryAnchorId) {
return agentSummary ? [{ kind: "liveAgentSummary", key: `live-agents:${activeTurnId}`, summary: agentSummary }] : [];
}
return [];
});
}
function activeAgentRunSummaryAnchorId(items: readonly MessageStreamSemanticClassification[], activeTurnId: string): string | null {
const firstActiveAgent = items.find(
(classification) => messageStreamIsCoordinationProgress(classification) && classification.item.turnId === activeTurnId,
);
return firstActiveAgent?.item.id ?? null;
}
function taskProgressStreamItem(item: MessageStreamItem): TaskProgressMessageStreamItem {
if (item.kind !== "taskProgress") throw new Error(`Expected task progress presentation item for ${item.id}`);
return item;
}
function withoutActiveTaskProgress(items: readonly MessageStreamItem[], activeTurnId: string): MessageStreamItem[] {
return items.filter((item) => item.kind !== "taskProgress" || item.turnId !== activeTurnId);
}
function messageStreamViewBlockFromPresentationBlock(
block: MessageStreamPresentationBlock,
input: MessageStreamPresentationBlockInput,

View file

@ -5,10 +5,8 @@ import {
runEphemeralStructuredTurnForLastAgentText,
type StructuredTurnOutputSchema,
} from "../../app-server/services/ephemeral-structured-turn";
import type { ReasoningEffort } from "../../domain/catalog/metadata";
import { listModelMetadata } from "../../app-server/services/catalog";
import type { ModelMetadata } from "../../domain/catalog/metadata";
import { runtimeOverride, validatedRuntimeOverrideForModelMetadata } from "../../domain/runtime/overrides";
import { resolvedRuntimeOverrideForClient } from "../../app-server/services/runtime-overrides";
import type { RuntimeOverride } from "../../domain/runtime/overrides";
import type { SelectionRewriteRuntimeSettings } from "./model";
import { SELECTION_REWRITE_DEVELOPER_INSTRUCTIONS, SELECTION_REWRITE_SERVICE_NAME } from "./prompt";
import { SelectionRewriteOutputError, selectionRewriteOutputParseResultFromText, type SelectionRewriteOutput } from "./output";
@ -75,34 +73,11 @@ export async function runSelectionRewrite(options: RunSelectionRewriteOptions):
return output;
}
interface SelectionRewriteRuntimeOverride {
model?: string;
effort?: ReasoningEffort;
}
function selectionRewriteRuntimeOverride(settings: SelectionRewriteRuntimeSettings): SelectionRewriteRuntimeOverride {
return runtimeOverride({ model: settings.rewriteSelectionModel, effort: settings.rewriteSelectionEffort });
}
function validatedSelectionRewriteRuntimeOverride(
settings: SelectionRewriteRuntimeSettings,
models: readonly ModelMetadata[],
): SelectionRewriteRuntimeOverride {
return validatedRuntimeOverrideForModelMetadata(
{ model: settings.rewriteSelectionModel, effort: settings.rewriteSelectionEffort },
models,
);
}
type SelectionRewriteRuntimeOverride = RuntimeOverride;
async function selectionRewriteRuntimeOverrideForClient(
client: EphemeralStructuredTurnRuntimeClient,
settings: SelectionRewriteRuntimeSettings,
): Promise<SelectionRewriteRuntimeOverride> {
const runtime = selectionRewriteRuntimeOverride(settings);
if (!runtime.model || !runtime.effort) return runtime;
try {
return validatedSelectionRewriteRuntimeOverride(settings, await listModelMetadata(client));
} catch {
return runtime;
}
return resolvedRuntimeOverrideForClient(client, { model: settings.rewriteSelectionModel, effort: settings.rewriteSelectionEffort });
}

View file

@ -41,7 +41,6 @@ export interface CodexThreadsHost {
getOpenPanelSnapshots(): OpenCodexPanelSnapshot[];
notifyThreadArchived(threadId: string, options?: { closeOpenPanels?: boolean }): void;
notifyThreadRenamed(threadId: string, name: string | null): void;
publishAppServerIdentity(userAgent: string | null): void;
refreshThreadList(fetchThreads: () => Promise<readonly Thread[]>): Promise<readonly Thread[]>;
cachedThreadList(): readonly Thread[] | null;
}
@ -111,7 +110,6 @@ export class CodexThreadsSession {
this.invalidateConnectionWork();
this.refreshLifecycle = transitionThreadsViewRefreshLifecycle(this.refreshLifecycle, { type: "invalidated" });
this.deferredTasks.clearAll();
this.host.publishAppServerIdentity(null);
this.connection.disconnect();
this.client = null;
unmountThreadsView(this.environment.root);
@ -179,12 +177,10 @@ export class CodexThreadsSession {
}
const connection = this.beginConnectionWork();
this.host.publishAppServerIdentity(null);
const promise = this.connection
.connect()
.then((initialization) => {
.then(() => {
if (this.isStaleConnectionWork(connection)) throw new StaleConnectionError();
this.host.publishAppServerIdentity(initialization.userAgent);
this.client = this.connection.currentClient();
})
.finally(() => {

View file

@ -47,15 +47,11 @@ interface PluginHostServices {
cachedAppServerMetadata: () => SharedServerMetadata | null;
cachedModels: () => ReturnType<CodexPanelSettingTabHost["cachedModels"]>;
};
readonly appServerIdentity: {
publishAppServerIdentity: (userAgent: string | null) => void;
};
}
export default class CodexPanelPlugin extends Plugin {
settings: CodexPanelSettings = DEFAULT_SETTINGS;
vaultPath = "";
private appServerUserAgent: string | null = null;
private readonly sharedAppServerCache = new SharedAppServerCache();
private readonly panels = new WorkspacePanelCoordinator({
app: this.app,
@ -167,14 +163,9 @@ export default class CodexPanelPlugin extends Plugin {
return {
codexPath: this.settings.codexPath,
vaultPath: this.vaultPath,
appServerUserAgent: this.appServerUserAgent,
};
}
private publishAppServerIdentity(userAgent: string | null): void {
this.appServerUserAgent = userAgent;
}
private applyThreadListSnapshot(threads: readonly Thread[]): void {
this.sharedAppServerCache.applyThreadListSnapshot(this.sharedAppServerCacheContext(), threads);
this.threadSurfaces.applyThreadListSnapshot(threads);
@ -247,11 +238,6 @@ export default class CodexPanelPlugin extends Plugin {
cachedAppServerMetadata: () => this.sharedAppServerCache.cachedAppServerMetadata(this.sharedAppServerCacheContext()),
cachedModels: () => this.sharedAppServerCache.cachedModels(this.sharedAppServerCacheContext()),
},
appServerIdentity: {
publishAppServerIdentity: (userAgent) => {
this.publishAppServerIdentity(userAgent);
},
},
};
}
@ -266,7 +252,6 @@ export default class CodexPanelPlugin extends Plugin {
},
threadSurfaces: services.threadSurfaces,
sharedCache: services.sharedCache,
appServerIdentity: services.appServerIdentity,
};
}
@ -280,7 +265,6 @@ export default class CodexPanelPlugin extends Plugin {
getOpenPanelSnapshots: services.workspace.getOpenPanelSnapshots,
notifyThreadArchived: services.threadSurfaces.notifyThreadArchived,
notifyThreadRenamed: services.threadSurfaces.notifyThreadRenamed,
publishAppServerIdentity: services.appServerIdentity.publishAppServerIdentity,
refreshThreadList: services.sharedCache.refreshThreadList,
cachedThreadList: services.sharedCache.cachedThreadList,
};

View file

@ -67,16 +67,10 @@ describe("shared app-server cache state", () => {
expect(cachedSharedThreadList(state, cacheContext({ vaultPath: "/other-vault" }))).toBeNull();
expect(cachedSharedModels(state, cacheContext({ codexPath: "/opt/codex" }))).toBeNull();
expect(cachedSharedThreadList(state, cacheContext({ appServerUserAgent: "codex-cli/9.9.9" }))).toBeNull();
expect(cachedSharedModels(state, cacheContext({ appServerUserAgent: "codex-cli/9.9.9" }))).toBeNull();
});
it("does not load or return snapshots before the app-server identity is known", () => {
const incompleteContexts = [
cacheContext({ codexPath: "" }),
cacheContext({ vaultPath: " " }),
cacheContext({ appServerUserAgent: null }),
];
it("does not load or return snapshots before the cache context is complete", () => {
const incompleteContexts = [cacheContext({ codexPath: "" }), cacheContext({ vaultPath: " " })];
expect(sharedAppServerCacheContextIsComplete(cacheContext())).toBe(true);
@ -155,7 +149,6 @@ function cacheContext(overrides: Partial<SharedAppServerCacheContext> = {}): Sha
return {
codexPath: "codex",
vaultPath: "/vault",
appServerUserAgent: "codex-cli/1.2.3",
...overrides,
};
}

View file

@ -65,9 +65,9 @@ describe("SharedAppServerCache", () => {
expect(cache.cachedModels(context)).toBeNull();
});
it("does not share or store snapshots before the app-server identity is known", async () => {
it("does not share or store snapshots before the cache context is complete", async () => {
const cache = new SharedAppServerCache();
const context = cacheContext({ appServerUserAgent: null });
const context = cacheContext({ codexPath: "" });
const firstRefresh = deferred<readonly ReturnType<typeof thread>[]>();
const firstFetch = vi.fn(() => firstRefresh.promise);
const secondFetch = vi.fn().mockResolvedValue([thread("second")]);
@ -108,10 +108,8 @@ describe("SharedAppServerCache", () => {
expect(cache.cachedAppServerMetadata(cacheContext({ vaultPath: "/other-vault" }))).toBeNull();
expect(cache.cachedAppServerMetadata(cacheContext({ codexPath: "/opt/codex" }))).toBeNull();
expect(cache.cachedAppServerMetadata(cacheContext({ appServerUserAgent: "codex-cli/9.9.9" }))).toBeNull();
expect(cache.cachedModels(cacheContext({ vaultPath: "/other-vault" }))).toBeNull();
expect(cache.cachedModels(cacheContext({ codexPath: "/opt/codex" }))).toBeNull();
expect(cache.cachedModels(cacheContext({ appServerUserAgent: "codex-cli/9.9.9" }))).toBeNull();
});
it("stores successful empty thread list snapshots as shared cache truth", async () => {
@ -131,8 +129,8 @@ describe("SharedAppServerCache", () => {
it("ignores stale thread list refresh snapshots after the app-server cache context changes", async () => {
const cache = new SharedAppServerCache();
const oldContext = cacheContext({ appServerUserAgent: "codex-cli/1.2.3" });
const newContext = cacheContext({ appServerUserAgent: "codex-cli/1.2.4" });
const oldContext = cacheContext({ codexPath: "codex-old" });
const newContext = cacheContext({ codexPath: "codex-new" });
const oldRefresh = deferred<readonly ReturnType<typeof thread>[]>();
const newRefresh = deferred<readonly ReturnType<typeof thread>[]>();
const oldSnapshot = vi.fn();
@ -155,13 +153,13 @@ describe("SharedAppServerCache", () => {
it("keys in-flight thread list refreshes by the captured app-server cache context", async () => {
const cache = new SharedAppServerCache();
const context = cacheContext({ appServerUserAgent: "codex-cli/1.2.3" });
const context = cacheContext({ codexPath: "codex-captured" });
const capturedContext = { ...context };
const refresh = deferred<readonly ReturnType<typeof thread>[]>();
const onSnapshot = vi.fn();
const promise = cache.refreshThreadList(context, () => refresh.promise, onSnapshot);
context.appServerUserAgent = "codex-cli/9.9.9";
context.codexPath = "codex-mutated";
refresh.resolve([thread("captured")]);
await expect(promise).resolves.toEqual([thread("captured")]);
@ -176,7 +174,6 @@ function cacheContext(overrides: Partial<SharedAppServerCacheContext> = {}): Sha
return {
codexPath: "codex",
vaultPath: "/vault",
appServerUserAgent: "codex-cli/1.2.3",
...overrides,
};
}

View file

@ -2,11 +2,7 @@
import { describe, expect, it } from "vitest";
import type {
AppServerClient,
AppServerClientHandlers,
AppServerStartStructuredTurnOptions,
} from "../../src/app-server/connection/client";
import type { AppServerClient, AppServerClientHandlers, AppServerStartStructuredTurnOptions } from "../../src/app-server/connection/client";
import type { TurnItem, TurnRecord } from "../../src/app-server/protocol/turn";
import type { RequestId, ServerNotification } from "../../src/app-server/connection/rpc-messages";
import type { ServerInitialization } from "../../src/domain/server/initialization";

View file

@ -48,7 +48,6 @@ function createController({ connected = false, client = {} as AppServerClient }
resetThreadTurnPresence: vi.fn(),
setStatus: vi.fn(),
addSystemMessage: vi.fn(),
publishAppServerIdentity: vi.fn(),
configuredCommand: () => "codex",
refreshLiveState: vi.fn(),
notifyConnectionFailed: vi.fn(),
@ -76,8 +75,6 @@ describe("ChatConnectionController", () => {
platformOs: "macos",
userAgent: "test",
});
expect(host.publishAppServerIdentity).toHaveBeenNthCalledWith(1, null);
expect(host.publishAppServerIdentity).toHaveBeenNthCalledWith(2, "test");
expect(refreshPublishedAppServerMetadata).toHaveBeenCalledOnce();
expect(host.loadSharedThreadList).toHaveBeenCalledOnce();
expect(host.scheduleDeferredDiagnostics).toHaveBeenCalledOnce();
@ -113,7 +110,6 @@ describe("ChatConnectionController", () => {
controller.handleExit();
expect(host.invalidateResumeWork).toHaveBeenCalledOnce();
expect(host.publishAppServerIdentity).toHaveBeenCalledWith(null);
expect(host.setStatus).toHaveBeenCalledWith("Codex app-server stopped.", {
kind: "disconnected",
message: "Codex app-server stopped.",

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { messageStreamPresentationBlocks } from "../../../../../src/features/chat/presentation/message-stream/blocks";
import { messageStreamPresentationBlocks } from "../../../../../src/features/chat/presentation/message-stream/view-model";
import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items";
describe("message stream presentation blocks", () => {

View file

@ -7,7 +7,11 @@ import { createChatStateStore } from "../../../../src/features/chat/application/
import { messageStreamItems } from "../../../../src/features/chat/application/state/message-stream";
import { renderChatPanelShell, unmountChatPanelShell, type ChatPanelShellParts } from "../../../../src/features/chat/panel/shell";
import type { ChatPanelComposerShellState, ChatPanelMessageStreamShellState } from "../../../../src/features/chat/panel/shell-state";
import type { ChatPanelSurface } from "../../../../src/features/chat/panel/surface/model";
import type {
ChatPanelComposerSurface,
ChatPanelGoalSurface,
ChatPanelToolbarSurface,
} from "../../../../src/features/chat/panel/surface/model";
import { installObsidianDomShims } from "../../../support/dom";
installObsidianDomShims();
@ -418,7 +422,11 @@ function trackedShellParts(): TestShellParts {
};
}
function surfaceFixture(options: { toolbarConnected?: () => boolean } = {}): ChatPanelSurface {
function surfaceFixture(options: { toolbarConnected?: () => boolean } = {}): {
toolbar: ChatPanelToolbarSurface;
goal: ChatPanelGoalSurface;
composer: ChatPanelComposerSurface;
} {
return {
toolbar: {
state: {

View file

@ -6,7 +6,11 @@ import { act } from "preact/test-utils";
import type { Thread } from "../../../../src/domain/threads/model";
import { createChatStateStore } from "../../../../src/features/chat/application/state/store";
import { createToolbarPanelActions, type ToolbarPanelActions } from "../../../../src/features/chat/panel/toolbar-actions";
import type { ChatPanelSurface, ChatPanelToolbarSurface } from "../../../../src/features/chat/panel/surface/model";
import type {
ChatPanelComposerSurface,
ChatPanelGoalSurface,
ChatPanelToolbarSurface,
} from "../../../../src/features/chat/panel/surface/model";
import type { ThreadManagementActions } from "../../../../src/features/chat/application/threads/thread-management-actions";
import { renderChatPanelShell, unmountChatPanelShell, type ChatPanelShellParts } from "../../../../src/features/chat/panel/shell";
import { installObsidianDomShims } from "../../../support/dom";
@ -146,7 +150,14 @@ function shellParts(store: ReturnType<typeof createChatStateStore>, toolbarActio
};
}
function surfaceFixture(store: ReturnType<typeof createChatStateStore>, toolbarActions: ToolbarPanelActions): ChatPanelSurface {
function surfaceFixture(
store: ReturnType<typeof createChatStateStore>,
toolbarActions: ToolbarPanelActions,
): {
toolbar: ChatPanelToolbarSurface;
goal: ChatPanelGoalSurface;
composer: ChatPanelComposerSurface;
} {
return {
toolbar: toolbarSurface(store, toolbarActions),
goal: {

View file

@ -176,16 +176,13 @@ describe("CodexChatView connection lifecycle", () => {
it("publishes app-server metadata after connecting", async () => {
const publishAppServerMetadata = vi.fn();
const publishAppServerIdentity = vi.fn();
connectionMock.state.client = connectedClient();
const view = await chatView({
host: chatHost({ publishAppServerMetadata, publishAppServerIdentity }),
host: chatHost({ publishAppServerMetadata }),
});
await view.connect();
expect(publishAppServerIdentity).toHaveBeenNthCalledWith(1, null);
expect(publishAppServerIdentity).toHaveBeenNthCalledWith(2, "codex-test");
expect(publishAppServerMetadata).toHaveBeenCalledWith(
expect.objectContaining({
runtimeConfig: expect.any(Object),
@ -1285,7 +1282,6 @@ interface ChatHostFixtureOverrides {
refreshThreadList?: CodexChatHost["sharedCache"]["refreshThreadList"];
cachedThreadList?: CodexChatHost["sharedCache"]["cachedThreadList"];
cachedAppServerMetadata?: CodexChatHost["sharedCache"]["cachedAppServerMetadata"];
publishAppServerIdentity?: CodexChatHost["appServerIdentity"]["publishAppServerIdentity"];
}
function chatHost(overrides: ChatHostFixtureOverrides = {}): CodexChatHost {
@ -1322,9 +1318,6 @@ function chatHost(overrides: ChatHostFixtureOverrides = {}): CodexChatHost {
cachedThreadList: overrides.cachedThreadList ?? vi.fn(() => null),
cachedAppServerMetadata: overrides.cachedAppServerMetadata ?? vi.fn(() => null),
},
appServerIdentity: {
publishAppServerIdentity: overrides.publishAppServerIdentity ?? vi.fn(),
},
};
}

View file

@ -99,8 +99,6 @@ describe("CodexThreadsView", () => {
await view.refresh();
expect(connectionMock.state.connectCalls).toBe(1);
expect(host.publishAppServerIdentity).toHaveBeenNthCalledWith(1, null);
expect(host.publishAppServerIdentity).toHaveBeenNthCalledWith(2, "codex-test");
expect(view.containerEl.textContent).toContain("Thread preview");
});
@ -421,7 +419,6 @@ function threadsHost(overrides: Record<string, unknown> = {}) {
getOpenPanelSnapshots: vi.fn(() => []),
notifyThreadArchived: vi.fn(),
notifyThreadRenamed: vi.fn(),
publishAppServerIdentity: vi.fn(),
refreshThreadList: vi.fn((fetchThreads: () => Promise<unknown>) => fetchThreads() as Promise<never[]>),
cachedThreadList: vi.fn(() => null),
...overrides,

View file

@ -29,10 +29,6 @@ function sharedAppServerCache(plugin: CodexPanelPlugin): SharedAppServerCache {
return (plugin as unknown as { sharedAppServerCache: SharedAppServerCache }).sharedAppServerCache;
}
function chatHost(plugin: CodexPanelPlugin): CodexChatHost {
return (plugin as unknown as { chatHost(): CodexChatHost }).chatHost();
}
function sharedAppServerCacheContext(
plugin: CodexPanelPlugin,
overrides: Partial<SharedAppServerCacheContext> = {},
@ -40,7 +36,6 @@ function sharedAppServerCacheContext(
return {
codexPath: plugin.settings.codexPath,
vaultPath: plugin.vaultPath,
appServerUserAgent: null,
...overrides,
};
}
@ -441,7 +436,7 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
);
const secondFetch = vi.fn().mockResolvedValue([thread("second")]);
const context = sharedAppServerCacheContext(plugin, { appServerUserAgent: "codex-cli/1.2.3" });
const context = sharedAppServerCacheContext(plugin);
const first = sharedAppServerCache(plugin).refreshThreadList(context, fetchThreads);
const second = sharedAppServerCache(plugin).refreshThreadList(context, secondFetch);
@ -456,8 +451,8 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
it("keeps shared thread list refreshes separate across app-server cache contexts", async () => {
const plugin = await pluginWithLeaves([]);
const firstContext = sharedAppServerCacheContext(plugin, { codexPath: "codex-a", appServerUserAgent: "codex-cli/1.2.3" });
const secondContext = sharedAppServerCacheContext(plugin, { codexPath: "codex-b", appServerUserAgent: "codex-cli/1.2.3" });
const firstContext = sharedAppServerCacheContext(plugin, { codexPath: "codex-a" });
const secondContext = sharedAppServerCacheContext(plugin, { codexPath: "codex-b" });
let resolveFirst!: (threads: Thread[]) => void;
const firstFetch = vi.fn(
() =>
@ -481,25 +476,9 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
expect(sharedAppServerCache(plugin).cachedThreadList(firstContext)).toBeNull();
});
it("keys shared thread list snapshots by published app-server identity", async () => {
const plugin = await pluginWithLeaves([]);
const host = chatHost(plugin);
host.appServerIdentity.publishAppServerIdentity("codex-cli/1.2.3");
await host.sharedCache.refreshThreadList(() => Promise.resolve([thread("versioned")]));
expect(
sharedAppServerCache(plugin).cachedThreadList(sharedAppServerCacheContext(plugin, { appServerUserAgent: "codex-cli/1.2.3" })),
).toEqual([thread("versioned")]);
host.appServerIdentity.publishAppServerIdentity("codex-cli/9.9.9");
expect(host.sharedCache.cachedThreadList()).toBeNull();
});
it("keeps the previous shared thread list when refresh fails", async () => {
const plugin = await pluginWithLeaves([]);
const context = sharedAppServerCacheContext(plugin, { appServerUserAgent: "codex-cli/1.2.3" });
const context = sharedAppServerCacheContext(plugin);
await sharedAppServerCache(plugin).refreshThreadList(context, () => Promise.resolve([thread("cached")]));
await expect(sharedAppServerCache(plugin).refreshThreadList(context, () => Promise.reject(new Error("boom")))).rejects.toThrow("boom");
@ -651,9 +630,6 @@ function chatHostFixture(): CodexChatHost {
cachedThreadList: vi.fn(() => null),
cachedAppServerMetadata: vi.fn(() => null),
},
appServerIdentity: {
publishAppServerIdentity: vi.fn(),
},
};
}