refactor(runtime): remove obsolete lifetime and query abstractions

This commit is contained in:
murashit 2026-07-20 23:36:23 +09:00
parent 2d9f98f434
commit 193b680f06
21 changed files with 232 additions and 221 deletions

View file

@ -0,0 +1,4 @@
export interface AppServerExecutionContext {
readonly codexPath: string;
readonly vaultPath: string;
}

View file

@ -20,8 +20,10 @@ import {
} from "../../domain/server/diagnostics";
import type { SharedServerMetadata, SharedServerMetadataResource } from "../../domain/server/metadata";
import type { Thread } from "../../domain/threads/model";
import { StaleExecutionRuntimeError } from "../../shared/runtime/execution-runtime-lifetime";
import type { AppServerClient } from "../connection/client";
import type { AppServerClientAccess, AppServerClientAccessOptions } from "../connection/client-access";
import type { AppServerExecutionContext } from "../connection/execution-context";
import { runtimeConfigSnapshotFromAppServerConfig } from "../protocol/runtime-config";
import { listModelMetadata } from "../services/catalog";
import { readEffectiveConfig } from "../services/runtime-metadata";
@ -34,34 +36,20 @@ import {
applyActiveThreadMutation,
recentActiveThreadsFromData,
} from "./active-thread-inventory";
import {
type AppServerQueryContext,
activeThreadSearchInventoryQueryKey,
activeThreadsQueryKey,
appServerModelsQueryKey,
appServerPermissionProfilesQueryKey,
appServerRateLimitsQueryKey,
appServerRuntimeConfigQueryKey,
appServerSkillsQueryKey,
archivedThreadsQueryKey,
} from "./keys";
import { readPermissionProfileMetadataProbe, readRateLimitMetadataProbe, readSkillMetadataProbe } from "./metadata-probes";
import type { ObservedPaginatedResult, ObservedPaginatedResultListener, ObservedResult, ObservedResultListener } from "./observed-result";
import { cloneModelMetadata, cloneSharedServerMetadata, cloneSharedServerMetadataResource, cloneThreads } from "./snapshots";
import { applyThreadListMutation, type ThreadListKind, type ThreadListMutation } from "./thread-list-mutation";
const MODELS_STALE_TIME_MS = 60_000;
export class StaleAppServerResourceContextError extends Error {
constructor() {
super("Codex app-server resource context changed while loading.");
this.name = "StaleAppServerResourceContextError";
}
}
export function isStaleAppServerResourceContextError(error: unknown): error is StaleAppServerResourceContextError {
return error instanceof StaleAppServerResourceContextError;
}
const ACTIVE_THREADS_QUERY_KEY = ["threads", "active"] as const;
const ACTIVE_THREAD_SEARCH_INVENTORY_QUERY_KEY = ["threads", "active-search-inventory"] as const;
const ARCHIVED_THREADS_QUERY_KEY = ["threads", "archived"] as const;
const MODELS_QUERY_KEY = ["models"] as const;
const RUNTIME_CONFIG_QUERY_KEY = ["runtime-config"] as const;
const SKILLS_QUERY_KEY = ["skills"] as const;
const PERMISSION_PROFILES_QUERY_KEY = ["permission-profiles"] as const;
const RATE_LIMITS_QUERY_KEY = ["rate-limits"] as const;
interface AppServerQueryOptions<T> {
readonly queryKey: readonly unknown[];
@ -69,7 +57,7 @@ interface AppServerQueryOptions<T> {
readonly staleTime?: number;
}
type ActiveThreadsQueryKey = ReturnType<typeof activeThreadsQueryKey>;
type ActiveThreadsQueryKey = typeof ACTIVE_THREADS_QUERY_KEY;
interface MetadataResourceSnapshot<T> {
readonly value: T;
@ -80,16 +68,16 @@ type MetadataResourceKind = "skills" | "permissionProfiles" | "rateLimits";
type MetadataResourceValue = readonly SkillMetadata[] | readonly RuntimePermissionProfileSummary[] | RateLimitSnapshot | null;
export class AppServerQueryCache {
private readonly context: Readonly<AppServerQueryContext>;
private readonly context: Readonly<AppServerExecutionContext>;
private readonly client: QueryClient;
private readonly clientAccess: AppServerClientAccess;
private readonly observerUnsubscribes = new Set<() => void>();
private disposed = false;
constructor(context: AppServerQueryContext, options: { client?: QueryClient; clientAccess: AppServerClientAccess }) {
constructor(context: AppServerExecutionContext, clientAccess: AppServerClientAccess) {
this.context = Object.freeze({ ...context });
this.client = options.client ?? createAppServerQueryClient();
this.clientAccess = options.clientAccess;
this.client = createAppServerQueryClient();
this.clientAccess = clientAccess;
}
dispose(): void {
@ -102,14 +90,14 @@ export class AppServerQueryCache {
activeThreadsSnapshot(): readonly Thread[] | null {
if (this.disposed) return null;
const data = this.client.getQueryData<ActiveThreadData>(activeThreadsQueryKey());
const data = this.client.getQueryData<ActiveThreadData>(ACTIVE_THREADS_QUERY_KEY);
const threads = activeThreadsFromData(data);
return threads ? cloneThreads(threads) : null;
}
recentActiveThreadsSnapshot(): readonly Thread[] | null {
if (this.disposed) return null;
const data = this.client.getQueryData<ActiveThreadData>(activeThreadsQueryKey());
const data = this.client.getQueryData<ActiveThreadData>(ACTIVE_THREADS_QUERY_KEY);
const threads = recentActiveThreadsFromData(data);
return threads ? cloneThreads(threads) : null;
}
@ -146,7 +134,7 @@ export class AppServerQueryCache {
fetchActiveThreads(options: { force?: boolean } = {}): Promise<readonly Thread[]> {
return this.runWhileActive(async () => {
const key = activeThreadsQueryKey();
const key = ACTIVE_THREADS_QUERY_KEY;
if (options.force) {
if (this.client.getQueryState(key)?.fetchMeta?.fetchMore?.direction === "forward") {
await this.client.cancelQueries({ queryKey: key, exact: true });
@ -170,7 +158,7 @@ export class AppServerQueryCache {
fetchActiveThreadSearchInventory(): Promise<readonly Thread[]> {
return this.runWhileActive(async () => {
const key = activeThreadSearchInventoryQueryKey();
const key = ACTIVE_THREAD_SEARCH_INVENTORY_QUERY_KEY;
const options = {
queryKey: key,
queryFn: ({ signal }: { signal: AbortSignal }) =>
@ -182,7 +170,7 @@ export class AppServerQueryCache {
hasMoreActiveThreads(): boolean {
if (this.disposed) return false;
return activeThreadDataHasMore(this.client.getQueryData<ActiveThreadData>(activeThreadsQueryKey()));
return activeThreadDataHasMore(this.client.getQueryData<ActiveThreadData>(ACTIVE_THREADS_QUERY_KEY));
}
loadMoreActiveThreads(): Promise<readonly Thread[]> {
@ -212,7 +200,7 @@ export class AppServerQueryCache {
fetchArchivedThreads(options: { force?: boolean } = {}): Promise<readonly Thread[]> {
return this.runWhileActive(async () => {
const key = archivedThreadsQueryKey();
const key = ARCHIVED_THREADS_QUERY_KEY;
if (options.force) {
await this.client.invalidateQueries({ queryKey: key, refetchType: "none" });
this.assertUsable();
@ -228,7 +216,7 @@ export class AppServerQueryCache {
if (mutations.length === 0) return;
const activeMutations = mutations.filter((mutation) => mutation.list === "active");
if (activeMutations.length > 0) {
const key = activeThreadsQueryKey();
const key = ACTIVE_THREADS_QUERY_KEY;
const wasFetching = this.client.getQueryState(key)?.fetchStatus === "fetching";
const fetchIsNextPage = this.client.getQueryState(key)?.fetchMeta?.fetchMore?.direction === "forward";
void this.client.cancelQueries({ queryKey: key, exact: true });
@ -236,7 +224,7 @@ export class AppServerQueryCache {
const after = activeMutations.reduce<ActiveThreadData | undefined>(applyActiveThreadMutation, before);
if (after !== before) this.client.setQueryData(key, after);
void this.client.invalidateQueries({ queryKey: key, refetchType: "none" });
const searchKey = activeThreadSearchInventoryQueryKey();
const searchKey = ACTIVE_THREAD_SEARCH_INVENTORY_QUERY_KEY;
void this.client.cancelQueries({ queryKey: searchKey, exact: true });
void this.client.invalidateQueries({ queryKey: searchKey, refetchType: "none" });
@ -250,7 +238,7 @@ export class AppServerQueryCache {
const archivedMutations = mutations.filter((mutation) => mutation.list === "archived");
if (archivedMutations.length > 0) {
const key = archivedThreadsQueryKey();
const key = ARCHIVED_THREADS_QUERY_KEY;
const wasFetching = this.client.getQueryState(key)?.fetchStatus === "fetching";
void this.client.cancelQueries({ queryKey: key, exact: true });
const before = this.archivedThreadsSnapshot();
@ -269,13 +257,13 @@ export class AppServerQueryCache {
private threadListSnapshot(kind: ThreadListKind): readonly Thread[] | null {
if (kind === "active") return this.activeThreadsSnapshot();
const threads = this.client.getQueryData<readonly Thread[]>(archivedThreadsQueryKey());
const threads = this.client.getQueryData<readonly Thread[]>(ARCHIVED_THREADS_QUERY_KEY);
return threads ? cloneThreads(threads) : null;
}
appServerMetadataSnapshot(): SharedServerMetadata | null {
if (this.disposed) return null;
const runtimeConfig = this.client.getQueryData<RuntimeConfigSnapshot>(appServerRuntimeConfigQueryKey());
const runtimeConfig = this.client.getQueryData<RuntimeConfigSnapshot>(RUNTIME_CONFIG_QUERY_KEY);
if (!runtimeConfig) return null;
const skills = this.metadataResourceState("skills");
const permissionProfiles = this.metadataResourceState("permissionProfiles");
@ -398,7 +386,7 @@ export class AppServerQueryCache {
modelsSnapshot(): readonly ModelMetadata[] | null {
if (this.disposed) return null;
const models = this.client.getQueryData<readonly ModelMetadata[]>(appServerModelsQueryKey());
const models = this.client.getQueryData<readonly ModelMetadata[]>(MODELS_QUERY_KEY);
return models ? cloneModelMetadata(models) : null;
}
@ -409,7 +397,7 @@ export class AppServerQueryCache {
fetchModels(options: { force?: boolean } = {}): Promise<readonly ModelMetadata[]> {
return this.runWhileActive(async () => {
const key = appServerModelsQueryKey();
const key = MODELS_QUERY_KEY;
if (options.force) {
await this.client.invalidateQueries({ queryKey: key, refetchType: "none" });
this.assertUsable();
@ -432,7 +420,7 @@ export class AppServerQueryCache {
ActiveThreadCursor
> {
return {
queryKey: activeThreadsQueryKey(),
queryKey: ACTIVE_THREADS_QUERY_KEY,
queryFn: async ({ pageParam, signal }) => {
signal.throwIfAborted();
const page = await this.runWithClient((client) =>
@ -449,7 +437,7 @@ export class AppServerQueryCache {
private archivedThreadsQueryOptions(): AppServerQueryOptions<readonly Thread[]> {
return {
queryKey: archivedThreadsQueryKey(),
queryKey: ARCHIVED_THREADS_QUERY_KEY,
queryFn: ({ signal }: { signal: AbortSignal }) =>
this.runWithClient((client) => listThreads(client, this.context.vaultPath, { archived: true, signal })).then(cloneThreads),
staleTime: Number.POSITIVE_INFINITY,
@ -458,7 +446,7 @@ export class AppServerQueryCache {
private runtimeConfigQueryOptions(): AppServerQueryOptions<RuntimeConfigSnapshot> {
return {
queryKey: appServerRuntimeConfigQueryKey(),
queryKey: RUNTIME_CONFIG_QUERY_KEY,
queryFn: async (): Promise<RuntimeConfigSnapshot> =>
this.runWithClient(async (client) =>
runtimeConfigSnapshotFromAppServerConfig(await readEffectiveConfig(client, this.context.vaultPath)),
@ -468,7 +456,7 @@ export class AppServerQueryCache {
private skillsQueryOptions(): AppServerQueryOptions<MetadataResourceSnapshot<readonly SkillMetadata[]>> {
return {
queryKey: appServerSkillsQueryKey(),
queryKey: SKILLS_QUERY_KEY,
queryFn: async () =>
this.runWithClient(async (client) => successfulMetadataResource(await readSkillMetadataProbe(client, this.context.vaultPath))),
};
@ -476,7 +464,7 @@ export class AppServerQueryCache {
private permissionProfilesQueryOptions(): AppServerQueryOptions<MetadataResourceSnapshot<readonly RuntimePermissionProfileSummary[]>> {
return {
queryKey: appServerPermissionProfilesQueryKey(),
queryKey: PERMISSION_PROFILES_QUERY_KEY,
queryFn: async () =>
this.runWithClient(async (client) =>
successfulMetadataResource(await readPermissionProfileMetadataProbe(client, this.context.vaultPath)),
@ -486,7 +474,7 @@ export class AppServerQueryCache {
private rateLimitsQueryOptions(): AppServerQueryOptions<MetadataResourceSnapshot<RateLimitSnapshot | null>> {
return {
queryKey: appServerRateLimitsQueryKey(),
queryKey: RATE_LIMITS_QUERY_KEY,
queryFn: async () => this.runWithClient(async (client) => successfulMetadataResource(await readRateLimitMetadataProbe(client))),
};
}
@ -518,7 +506,7 @@ export class AppServerQueryCache {
}
private async refreshNotifiedMetadataResource(resource: "skills" | "rateLimits"): Promise<void> {
const queryKey = resource === "skills" ? appServerSkillsQueryKey() : appServerRateLimitsQueryKey();
const queryKey = resource === "skills" ? SKILLS_QUERY_KEY : RATE_LIMITS_QUERY_KEY;
await this.client.cancelQueries({ queryKey, exact: true });
this.assertUsable();
try {
@ -536,11 +524,7 @@ export class AppServerQueryCache {
private metadataResourceState(resource: "rateLimits"): { value: RateLimitSnapshot | null; probe: DiagnosticProbeResult };
private metadataResourceState(resource: MetadataResourceKind): { value: MetadataResourceValue; probe: DiagnosticProbeResult } {
const key =
resource === "skills"
? appServerSkillsQueryKey()
: resource === "permissionProfiles"
? appServerPermissionProfilesQueryKey()
: appServerRateLimitsQueryKey();
resource === "skills" ? SKILLS_QUERY_KEY : resource === "permissionProfiles" ? PERMISSION_PROFILES_QUERY_KEY : RATE_LIMITS_QUERY_KEY;
const state = this.client.getQueryState<MetadataResourceSnapshot<MetadataResourceValue>>(key);
const failedProbe = diagnosticProbeFromError(state?.error);
return {
@ -550,7 +534,7 @@ export class AppServerQueryCache {
}
private modelsProbe(): DiagnosticProbeResult {
const state = this.client.getQueryState<readonly ModelMetadata[]>(appServerModelsQueryKey());
const state = this.client.getQueryState<readonly ModelMetadata[]>(MODELS_QUERY_KEY);
return (
diagnosticProbeFromError(state?.error) ??
(state?.data
@ -561,7 +545,7 @@ export class AppServerQueryCache {
private modelsQueryOptions(): AppServerQueryOptions<readonly ModelMetadata[]> {
return {
queryKey: appServerModelsQueryKey(),
queryKey: MODELS_QUERY_KEY,
queryFn: async (): Promise<readonly ModelMetadata[]> => {
try {
return cloneModelMetadata(
@ -651,7 +635,7 @@ export class AppServerQueryCache {
}
private assertUsable(): void {
if (this.disposed) throw new StaleAppServerResourceContextError();
if (this.disposed) throw new StaleExecutionRuntimeError();
}
private runWhileActive<T>(operation: () => Promise<T>): Promise<T> {
@ -662,7 +646,7 @@ export class AppServerQueryCache {
this.assertUsable();
return result;
} catch (error) {
if (this.disposed) throw new StaleAppServerResourceContextError();
if (this.disposed) throw new StaleExecutionRuntimeError();
throw error;
}
})();

View file

@ -1,48 +0,0 @@
export interface AppServerQueryContext {
readonly codexPath: string;
readonly vaultPath: string;
}
type AppServerQueryScope = readonly ["app-server"];
export type AppServerActiveThreadsQueryKey = readonly [...AppServerQueryScope, "threads", "active"];
export type AppServerActiveThreadSearchInventoryQueryKey = readonly [...AppServerQueryScope, "threads", "active-search-inventory"];
export type AppServerArchivedThreadsQueryKey = readonly [...AppServerQueryScope, "threads", "archived"];
export type AppServerModelsQueryKey = readonly [...AppServerQueryScope, "models"];
export type AppServerRuntimeConfigQueryKey = readonly [...AppServerQueryScope, "runtime-config"];
export type AppServerSkillsQueryKey = readonly [...AppServerQueryScope, "skills"];
export type AppServerPermissionProfilesQueryKey = readonly [...AppServerQueryScope, "permission-profiles"];
export type AppServerRateLimitsQueryKey = readonly [...AppServerQueryScope, "rate-limits"];
const APP_SERVER_QUERY_SCOPE: AppServerQueryScope = ["app-server"];
export function activeThreadsQueryKey(): AppServerActiveThreadsQueryKey {
return [...APP_SERVER_QUERY_SCOPE, "threads", "active"];
}
export function activeThreadSearchInventoryQueryKey(): AppServerActiveThreadSearchInventoryQueryKey {
return [...APP_SERVER_QUERY_SCOPE, "threads", "active-search-inventory"];
}
export function archivedThreadsQueryKey(): AppServerArchivedThreadsQueryKey {
return [...APP_SERVER_QUERY_SCOPE, "threads", "archived"];
}
export function appServerModelsQueryKey(): AppServerModelsQueryKey {
return [...APP_SERVER_QUERY_SCOPE, "models"];
}
export function appServerRuntimeConfigQueryKey(): AppServerRuntimeConfigQueryKey {
return [...APP_SERVER_QUERY_SCOPE, "runtime-config"];
}
export function appServerSkillsQueryKey(): AppServerSkillsQueryKey {
return [...APP_SERVER_QUERY_SCOPE, "skills"];
}
export function appServerPermissionProfilesQueryKey(): AppServerPermissionProfilesQueryKey {
return [...APP_SERVER_QUERY_SCOPE, "permission-profiles"];
}
export function appServerRateLimitsQueryKey(): AppServerRateLimitsQueryKey {
return [...APP_SERVER_QUERY_SCOPE, "rate-limits"];
}

View file

@ -2,9 +2,9 @@ import type { App } from "obsidian";
import type { AppServerClient } from "./app-server/connection/client";
import type { AppServerClientAccess, AppServerClientAccessOptions } from "./app-server/connection/client-access";
import type { AppServerExecutionContext } from "./app-server/connection/execution-context";
import { withShortLivedAppServerClient } from "./app-server/connection/short-lived-client";
import { AppServerQueryCache, StaleAppServerResourceContextError } from "./app-server/query/cache";
import type { AppServerQueryContext } from "./app-server/query/keys";
import { AppServerQueryCache } from "./app-server/query/cache";
import {
type EphemeralStructuredTurnClient,
type EphemeralStructuredTurnRunner,
@ -24,11 +24,12 @@ import type { ThreadsRuntimeView } from "./features/threads-view/view.obsidian";
import { createSettingsAppServerDynamicData } from "./settings/app-server-dynamic-data";
import type { SettingsDynamicDataAccess } from "./settings/dynamic-data";
import type { CodexPanelSettings } from "./settings/model";
import { StaleExecutionRuntimeError } from "./shared/runtime/execution-runtime-lifetime";
import { createKeyedOperationQueue } from "./shared/runtime/keyed-operation-queue";
export interface CodexExecutionRuntimeOptions {
app: App;
context: AppServerQueryContext;
context: AppServerExecutionContext;
settings: () => CodexPanelSettings;
workspace: WorkspacePanels;
onThreadCatalogEvent(event: ThreadCatalogEvent): void;
@ -44,9 +45,9 @@ export interface ExecutionRuntimeViews {
}
export class CodexExecutionRuntime implements AppServerClientAccess {
readonly context: Readonly<AppServerQueryContext>;
readonly appServerQueries: AppServerQueryCache;
readonly threadCatalog: ThreadCatalog;
private readonly context: Readonly<AppServerExecutionContext>;
private readonly appServerQueries: AppServerQueryCache;
private readonly threadCatalog: ThreadCatalog;
readonly settingsDynamicData: SettingsDynamicDataAccess;
private readonly threadNameMutations = createThreadNameMutationCoordinator();
private readonly threadGoalOperations = createThreadGoalOperationCoordinator();
@ -61,7 +62,7 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
constructor(private readonly options: CodexExecutionRuntimeOptions) {
this.context = Object.freeze({ ...options.context });
this.appServerQueries = new AppServerQueryCache(this.context, { clientAccess: this });
this.appServerQueries = new AppServerQueryCache(this.context, this);
this.threadCatalog = createThreadCatalog({
store: this.appServerQueries,
onEventApplied: (event) => {
@ -269,20 +270,25 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
this.assertActive();
return operation(client);
};
const result = await withShortLivedAppServerClient(this.context.codexPath, this.context.vaultPath, guardedOperation, options, {
created: (client) => {
if (this.disposed) {
client.disconnect();
throw new StaleAppServerResourceContextError();
}
this.shortLivedClients.add(client);
},
disposed: (client) => {
this.shortLivedClients.delete(client);
},
});
this.assertActive();
return result;
try {
const result = await withShortLivedAppServerClient(this.context.codexPath, this.context.vaultPath, guardedOperation, options, {
created: (client) => {
if (this.disposed) {
client.disconnect();
throw new StaleExecutionRuntimeError();
}
this.shortLivedClients.add(client);
},
disposed: (client) => {
this.shortLivedClients.delete(client);
},
});
this.assertActive();
return result;
} catch (error) {
if (this.disposed) throw new StaleExecutionRuntimeError();
throw error;
}
}
private structuredTurnRunner(): EphemeralStructuredTurnRunner {
@ -303,7 +309,7 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
created: (client) => {
if (this.disposed) {
client.disconnect();
throw new StaleAppServerResourceContextError();
throw new StaleExecutionRuntimeError();
}
this.structuredTurnClients.add(client);
},
@ -332,7 +338,7 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
}
private assertActive(): void {
if (this.disposed) throw new StaleAppServerResourceContextError();
if (this.disposed) throw new StaleExecutionRuntimeError();
}
private tryCleanup(operation: () => void): boolean {

View file

@ -36,7 +36,7 @@ export interface ChatConnectionActionsHost {
addSystemMessage: (text: string) => void;
configuredCommand: () => string;
isStaleConnectionError: (error: unknown) => boolean;
isStaleResourceContextError: (error: unknown) => boolean;
isStaleRuntimeError: (error: unknown) => boolean;
notifyConnectionFailed: () => void;
}
@ -105,7 +105,7 @@ async function refreshActiveThreads(host: ChatConnectionActionsHost): Promise<vo
await host.refreshSharedThreads();
host.refreshTabHeader();
} catch (error) {
if (host.isStaleResourceContextError(error)) return;
if (host.isStaleRuntimeError(error)) return;
host.addSystemMessage(error instanceof Error ? error.message : String(error));
}
}
@ -143,7 +143,7 @@ async function initializeConnection(host: ChatConnectionActionsHost, isStale: ()
} catch (error) {
if (isStale()) return;
if (host.isStaleConnectionError(error)) return;
if (host.isStaleResourceContextError(error)) return;
if (host.isStaleRuntimeError(error)) return;
const message = connectionErrorMessage(error, host.configuredCommand());
host.setStatus(STATUS_CONNECTION_FAILED, { kind: "failed", message });
host.addSystemMessage(message);
@ -158,7 +158,7 @@ async function hydrateConnectedResources(host: ChatConnectionActionsHost, isStal
try {
await host.metadata.refreshAppServerMetadata();
} catch (error) {
if (isStale() || host.isStaleResourceContextError(error)) return;
if (isStale() || host.isStaleRuntimeError(error)) return;
host.addSystemMessage(`Could not refresh Codex metadata: ${errorMessage(error)}`);
}
if (isStale()) return;
@ -166,7 +166,7 @@ async function hydrateConnectedResources(host: ChatConnectionActionsHost, isStal
try {
await host.refreshSharedThreads();
} catch (error) {
if (isStale() || host.isStaleResourceContextError(error)) return;
if (isStale() || host.isStaleRuntimeError(error)) return;
host.addSystemMessage(`Could not refresh Codex threads: ${errorMessage(error)}`);
}
if (isStale()) return;

View file

@ -19,7 +19,7 @@ export interface ServerMetadataActionsHost {
refreshAppServerMetadata: () => Promise<void>;
refreshSkills: () => Promise<void>;
refreshRateLimits: () => Promise<void>;
isStaleResourceContextError: (error: unknown) => boolean;
isStaleRuntimeError: (error: unknown) => boolean;
}
export interface ServerMetadataActions {
@ -106,7 +106,7 @@ async function refreshAppServerMetadata(host: ServerMetadataActionsHost): Promis
try {
await host.refreshAppServerMetadata();
} catch (error) {
if (host.isStaleResourceContextError(error)) return;
if (host.isStaleRuntimeError(error)) return;
throw error;
}
}
@ -115,7 +115,7 @@ async function refreshMetadataResource(host: ServerMetadataActionsHost, refresh:
try {
await refresh();
} catch (error) {
if (!host.isStaleResourceContextError(error)) throw error;
if (!host.isStaleRuntimeError(error)) throw error;
}
}

View file

@ -2,8 +2,8 @@ import { Notice } from "obsidian";
import type { AppServerClient, AppServerServerRequestResponder } from "../../../../app-server/connection/client";
import { type ConnectionManager, StaleConnectionError } from "../../../../app-server/connection/connection-manager";
import { isStaleAppServerResourceContextError } from "../../../../app-server/query/cache";
import type { SharedServerMetadataResource } from "../../../../domain/server/metadata";
import { isStaleExecutionRuntimeError } from "../../../../shared/runtime/execution-runtime-lifetime";
import { type ChatInboundHandler, createChatInboundHandler } from "../../app-server/inbound/handler";
import { type ChatConnectionActions, createChatConnectionActions } from "../../application/connection/connection-actions";
import type { ServerDiagnosticsTransport } from "../../application/connection/metadata-transport";
@ -127,7 +127,7 @@ export function createConnectionBundle(
refreshAppServerMetadata: () => environment.plugin.appServerQueries.refreshAppServerMetadata(),
refreshSkills: () => environment.plugin.appServerQueries.refreshSkills(),
refreshRateLimits: () => environment.plugin.appServerQueries.refreshRateLimits(),
isStaleResourceContextError: isStaleAppServerResourceContextError,
isStaleRuntimeError: isStaleExecutionRuntimeError,
});
const serverDiagnostics = createServerDiagnosticsActions({
stateStore,
@ -224,7 +224,7 @@ export function createConnectionBundle(
addSystemMessage: status.addSystemMessage,
configuredCommand: () => environment.plugin.appServerContext.codexPath,
isStaleConnectionError: (error) => error instanceof StaleConnectionError,
isStaleResourceContextError: isStaleAppServerResourceContextError,
isStaleRuntimeError: isStaleExecutionRuntimeError,
notifyConnectionFailed: () => {
new Notice("Codex app-server connection failed.");
},

View file

@ -1,7 +1,7 @@
import type { App, Component, EventRef } from "obsidian";
import type { AppServerClientAccess } from "../../../app-server/connection/client-access";
import type { AppServerQueryContext } from "../../../app-server/query/keys";
import type { AppServerExecutionContext } from "../../../app-server/connection/execution-context";
import type { ObservedResultListener } from "../../../app-server/query/observed-result";
import type { ModelMetadata } from "../../../domain/catalog/metadata";
import type { SendShortcut } from "../../../domain/input/send-shortcut";
@ -17,7 +17,7 @@ import type { ThreadGoalOperationCoordinator } from "../application/threads/goal
export interface CodexChatHost {
readonly appServerClientAccess: AppServerClientAccess;
readonly appServerContext: Readonly<AppServerQueryContext>;
readonly appServerContext: Readonly<AppServerExecutionContext>;
readonly settings: ChatPanelSettingsAccess;
readonly workspace: WorkspacePanels;
readonly appServerQueries: ChatAppServerQueries;

View file

@ -1,6 +1,6 @@
import { codexPanelAppServerInitializeParams } from "../../../app-server/connection/client-profile";
import { ConnectionManager } from "../../../app-server/connection/connection-manager";
import { isStaleAppServerResourceContextError } from "../../../app-server/query/cache";
import { isStaleExecutionRuntimeError } from "../../../shared/runtime/execution-runtime-lifetime";
import { createChatAppServerGateway, createChatCurrentAppServerGateway } from "../app-server/session-gateway";
import { createReconnectPanelAction } from "../application/connection/reconnect-actions";
import { createLocalIdSource, type LocalIdSource } from "../application/local-id-source";
@ -282,7 +282,7 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat
try {
await connectionBundle.refreshSharedThreads();
} catch (error) {
if (isStaleAppServerResourceContextError(error)) return;
if (isStaleExecutionRuntimeError(error)) return;
throw error;
}
};

View file

@ -130,10 +130,7 @@ function ChatPanelToolbarRegion({
actions: ToolbarActions;
}): UiNode {
const model = useChatSelector(stateStore, selectChatPanelToolbar);
return useMemo(
() => <ChatPanelToolbar model={model} stateStore={stateStore} surface={surface} actions={actions} />,
[model, stateStore, surface, actions],
);
return <ChatPanelToolbar model={model} stateStore={stateStore} surface={surface} actions={actions} />;
}
function ChatPanelGoalRegion({ stateStore, surface }: { stateStore: ChatStateStore; surface: ChatPanelGoalSurface }): UiNode {

View file

@ -1,12 +1,12 @@
import { Notice } from "obsidian";
import { isStaleAppServerResourceContextError } from "../../app-server/query/cache";
import type { ObservedPaginatedResult } from "../../app-server/query/observed-result";
import { observedInitialError, observedInitialLoading } from "../../app-server/query/observed-result";
import type { ArchiveExportSettings } from "../../domain/threads/archive-markdown";
import type { Thread } from "../../domain/threads/model";
import type { ThreadRenameLifecycleEvent } from "../../domain/threads/rename-lifecycle";
import { DeferredTask } from "../../shared/runtime/deferred-task";
import { isStaleExecutionRuntimeError } from "../../shared/runtime/execution-runtime-lifetime";
import { OwnerLifetime } from "../../shared/runtime/owner-lifetime";
import type { ThreadCatalogEventSink, ThreadCatalogPaginatedActiveReader } from "../threads/catalog/thread-catalog";
import type { ArchiveExportDestination } from "../threads/workflows/archive-export";
@ -135,7 +135,7 @@ export class ThreadsViewSession {
try {
await request();
} catch (error) {
if (!this.lifetime.isCurrent(lifetime) || isStaleAppServerResourceContextError(error)) return;
if (!this.lifetime.isCurrent(lifetime) || isStaleExecutionRuntimeError(error)) return;
if (!this.currentThreadsSnapshot()) {
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
this.render();
@ -149,7 +149,7 @@ export class ThreadsViewSession {
try {
await this.host.threadCatalog.loadMoreActive();
} catch (error) {
if (!this.lifetime.isCurrent(lifetime) || isStaleAppServerResourceContextError(error)) return;
if (!this.lifetime.isCurrent(lifetime) || isStaleExecutionRuntimeError(error)) return;
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
this.render();
}

View file

@ -0,0 +1,10 @@
export class StaleExecutionRuntimeError extends Error {
constructor() {
super("Codex execution runtime was disposed while work was in progress.");
this.name = "StaleExecutionRuntimeError";
}
}
export function isStaleExecutionRuntimeError(error: unknown): error is StaleExecutionRuntimeError {
return error instanceof StaleExecutionRuntimeError;
}

View file

@ -1,12 +1,13 @@
import { onlineManager, QueryClient } from "@tanstack/query-core";
import { onlineManager } from "@tanstack/query-core";
import { describe, expect, it, vi } from "vitest";
import type { AppServerClientAccess } from "../../src/app-server/connection/client-access";
import type { AppServerExecutionContext } from "../../src/app-server/connection/execution-context";
import type { CatalogModel, CatalogSkillMetadata } from "../../src/app-server/protocol/catalog";
import { AppServerQueryCache, StaleAppServerResourceContextError } from "../../src/app-server/query/cache";
import type { AppServerQueryContext } from "../../src/app-server/query/keys";
import { AppServerQueryCache } from "../../src/app-server/query/cache";
import type { RateLimitSnapshot } from "../../src/domain/runtime/metrics";
import type { RuntimePermissionProfileSummary } from "../../src/domain/runtime/permissions";
import type { Thread } from "../../src/domain/threads/model";
import { StaleExecutionRuntimeError } from "../../src/shared/runtime/execution-runtime-lifetime";
describe("AppServerQueryCache", () => {
it("uses its required runtime-owned client access", async () => {
@ -22,25 +23,15 @@ describe("AppServerQueryCache", () => {
expect(withClient).toHaveBeenCalledOnce();
});
it("keeps executable and vault context out of runtime-local query keys", async () => {
const queryClient = new QueryClient();
it("copies its execution context before performing requests", async () => {
const context = { codexPath: "/opt/codex", vaultPath: "/vault-a" };
const request = vi.fn().mockResolvedValue({ data: [], nextCursor: null });
const cache = new AppServerQueryCache(context, {
client: queryClient,
clientAccess: { withClient: async (operation) => operation({ request } as never) },
});
const cache = new AppServerQueryCache(context, { withClient: async (operation) => operation({ request } as never) });
context.codexPath = "/changed";
context.vaultPath = "/vault-b";
await cache.fetchActiveThreads();
expect(
queryClient
.getQueryCache()
.getAll()
.map((query) => query.queryKey),
).toEqual([["app-server", "threads", "active"]]);
expect(request).toHaveBeenCalledWith("thread/list", {
cwd: "/vault-a",
archived: false,
@ -57,10 +48,10 @@ describe("AppServerQueryCache", () => {
cache.dispose();
cache.dispose();
expect(() => cache.fetchModels()).toThrow(StaleAppServerResourceContextError);
expect(() => cache.fetchModels()).toThrow(StaleExecutionRuntimeError);
});
it("classifies late completion after disposal as a stale resource context", async () => {
it("classifies late completion after disposal as a stale execution runtime", async () => {
const pending = deferred<readonly []>();
const cache = createCache({
withClient: vi.fn(() => pending.promise) as AppServerClientAccess["withClient"],
@ -70,7 +61,7 @@ describe("AppServerQueryCache", () => {
cache.dispose();
pending.resolve([]);
await expect(fetch).rejects.toBeInstanceOf(StaleAppServerResourceContextError);
await expect(fetch).rejects.toBeInstanceOf(StaleExecutionRuntimeError);
});
it("tears down observers without notifying them after disposal", async () => {
@ -85,7 +76,7 @@ describe("AppServerQueryCache", () => {
listener.mockClear();
cache.dispose();
pending.resolve([]);
await expect(fetch).rejects.toBeInstanceOf(StaleAppServerResourceContextError);
await expect(fetch).rejects.toBeInstanceOf(StaleExecutionRuntimeError);
expect(listener).not.toHaveBeenCalled();
});
@ -146,7 +137,7 @@ describe("AppServerQueryCache", () => {
});
it("keeps active and archived thread list snapshots separate", async () => {
const fetchThreads = vi.fn((_context: AppServerQueryContext, archived: boolean) =>
const fetchThreads = vi.fn((_context: AppServerExecutionContext, archived: boolean) =>
Promise.resolve(archived ? [thread("archived", true)] : [thread("active")]),
);
const cache = cacheWithThreads(fetchThreads);
@ -511,7 +502,7 @@ describe("AppServerQueryCache", () => {
cache.dispose();
expect(cache.modelsSnapshot()).toBeNull();
expect(() => cache.fetchModels()).toThrow(StaleAppServerResourceContextError);
expect(() => cache.fetchModels()).toThrow(StaleExecutionRuntimeError);
expect(listModels).toHaveBeenCalledOnce();
});
@ -843,7 +834,7 @@ describe("AppServerQueryCache", () => {
});
});
function cacheContext(overrides: Partial<AppServerQueryContext> = {}): AppServerQueryContext {
function cacheContext(overrides: Partial<AppServerExecutionContext> = {}): AppServerExecutionContext {
return {
codexPath: "codex",
vaultPath: "/vault",
@ -852,30 +843,28 @@ function cacheContext(overrides: Partial<AppServerQueryContext> = {}): AppServer
}
function cacheWithThreads(
fetchThreads: (context: AppServerQueryContext, archived: boolean) => Promise<readonly ReturnType<typeof thread>[]>,
context: AppServerQueryContext = cacheContext(),
fetchThreads: (context: AppServerExecutionContext, archived: boolean) => Promise<readonly ReturnType<typeof thread>[]>,
context: AppServerExecutionContext = cacheContext(),
): AppServerQueryCache {
const runtimeContext = { ...context };
return new AppServerQueryCache(context, {
clientAccess: {
withClient: async (operation) => {
return operation({
request: async (method: string, params: { archived?: boolean }) => {
if (method !== "thread/list") throw new Error(`Unexpected app-server request: ${method}`);
return {
data: await fetchThreads(runtimeContext, params.archived ?? false),
nextCursor: null,
};
},
} as never);
},
withClient: async (operation) => {
return operation({
request: async (method: string, params: { archived?: boolean }) => {
if (method !== "thread/list") throw new Error(`Unexpected app-server request: ${method}`);
return {
data: await fetchThreads(runtimeContext, params.archived ?? false),
nextCursor: null,
};
},
} as never);
},
});
}
function cacheWithRequestHandlers(
handlers: Record<string, (params: unknown) => Promise<unknown>>,
context: AppServerQueryContext = cacheContext(),
context: AppServerExecutionContext = cacheContext(),
): AppServerQueryCache {
const requestClient = {
request: async (method: string, params: unknown) => {
@ -885,14 +874,12 @@ function cacheWithRequestHandlers(
},
};
return new AppServerQueryCache(context, {
clientAccess: {
withClient: async (operation) => operation(requestClient as never),
},
withClient: async (operation) => operation(requestClient as never),
});
}
function createCache(clientAccess: AppServerClientAccess): AppServerQueryCache {
return new AppServerQueryCache(cacheContext(), { clientAccess });
return new AppServerQueryCache(cacheContext(), clientAccess);
}
function permissionProfile(id: string): RuntimePermissionProfileSummary {

View file

@ -1,9 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { StaleAppServerResourceContextError } from "../src/app-server/query/cache";
import { CodexExecutionRuntime } from "../src/execution-runtime";
import type { ChatRuntimeView, CodexChatHost } from "../src/features/chat/host/contracts";
import type { ThreadPickerController } from "../src/features/thread-picker/modal.obsidian";
import { DEFAULT_SETTINGS } from "../src/settings/model";
import { StaleExecutionRuntimeError } from "../src/shared/runtime/execution-runtime-lifetime";
const { openThreadPickerMock, withShortLivedAppServerClientMock } = vi.hoisted(() => ({
openThreadPickerMock: vi.fn(),
@ -80,16 +81,45 @@ describe("CodexExecutionRuntime", () => {
);
const runtime = executionRuntime();
const fetch = runtime.appServerQueries.fetchModels();
const fetch = attachChatHost(runtime).appServerQueries.fetchModels();
await Promise.resolve();
runtime.dispose();
expect(client.disconnect).toHaveBeenCalledOnce();
resolveModels({ data: [] });
await expect(fetch).rejects.toBeInstanceOf(StaleAppServerResourceContextError);
await expect(fetch).rejects.toBeInstanceOf(StaleExecutionRuntimeError);
});
it("classifies a rejected short-lived operation as stale when disposed in flight", async () => {
let rejectOperation: (error: Error) => void = () => undefined;
const operation = new Promise<never>((_resolve, reject) => {
rejectOperation = reject;
});
withShortLivedAppServerClientMock.mockReturnValue(operation);
const runtime = executionRuntime();
const request = attachChatHost(runtime).appServerClientAccess.withClient(() => Promise.resolve("unused"));
runtime.dispose();
rejectOperation(new Error("Disconnected"));
await expect(request).rejects.toBeInstanceOf(StaleExecutionRuntimeError);
});
});
function attachChatHost(runtime: CodexExecutionRuntime): CodexChatHost {
let host: CodexChatHost | null = null;
const view: ChatRuntimeView = {
attachRuntime: (nextHost) => {
host = nextHost;
},
detachRuntime: vi.fn(),
activateRuntime: vi.fn(),
};
runtime.attachChatView(view);
if (!host) throw new Error("Runtime did not attach a chat host");
return host;
}
function pickerFactory(): {
controllers: Array<ThreadPickerController & { close: ReturnType<typeof vi.fn> }>;
finish: Array<() => void>;

View file

@ -47,7 +47,7 @@ function createActionsHarness({ connected = false, canConnect = true } = {}) {
addSystemMessage: vi.fn(),
configuredCommand: () => "codex",
isStaleConnectionError: () => false,
isStaleResourceContextError: () => false,
isStaleRuntimeError: () => false,
notifyConnectionFailed: vi.fn(),
};
return {
@ -172,11 +172,11 @@ describe("ChatConnectionActions", () => {
const { actions, host } = createActionsHarness({ connected: true });
const error = new Error("stale");
vi.mocked(host.refreshSharedThreads).mockRejectedValueOnce(error);
host.isStaleResourceContextError = vi.fn((candidate) => candidate === error);
host.isStaleRuntimeError = vi.fn((candidate) => candidate === error);
await actions.refreshActiveThreads();
expect(host.isStaleResourceContextError).toHaveBeenCalledWith(error);
expect(host.isStaleRuntimeError).toHaveBeenCalledWith(error);
expect(host.addSystemMessage).not.toHaveBeenCalled();
});

View file

@ -28,7 +28,7 @@ describe("server metadata actions", () => {
stateStore,
...metadataCacheHost(),
refreshAppServerMetadata,
isStaleResourceContextError: () => false,
isStaleRuntimeError: () => false,
});
await actions.refreshAppServerMetadata();
@ -52,7 +52,7 @@ describe("server metadata actions", () => {
const actions = createServerMetadataActions({
stateStore,
...metadataCacheHost(),
isStaleResourceContextError: () => false,
isStaleRuntimeError: () => false,
});
actions.applyAppServerMetadataResource({
@ -75,7 +75,7 @@ describe("server metadata actions", () => {
stateStore,
...metadataCacheHost(cache),
refreshAppServerMetadata: vi.fn().mockResolvedValue(undefined),
isStaleResourceContextError: () => false,
isStaleRuntimeError: () => false,
});
await actions.applyAppServerResourceEvent({
@ -96,7 +96,7 @@ describe("server metadata actions", () => {
stateStore,
...metadataCacheHost(),
refreshAppServerMetadata: vi.fn().mockRejectedValue(stale),
isStaleResourceContextError: (error) => error === stale,
isStaleRuntimeError: (error) => error === stale,
});
await expect(actions.refreshAppServerMetadata()).resolves.toBeUndefined();
@ -112,7 +112,7 @@ describe("server metadata actions", () => {
stateStore,
...metadataCacheHost(),
refreshSkills: vi.fn().mockRejectedValue(stale),
isStaleResourceContextError: (error) => error === stale,
isStaleRuntimeError: (error) => error === stale,
});
await actions.applyAppServerResourceEvent({ type: "skills-changed" });
@ -172,7 +172,7 @@ describe("server diagnostics actions", () => {
refreshAppServerMetadata: vi.fn().mockImplementation(async () => {
cache.current = refreshedMetadata;
}),
isStaleResourceContextError: () => false,
isStaleRuntimeError: () => false,
});
const readServerDiagnostics = vi.fn().mockResolvedValue(serverDiagnosticsSnapshot());
const diagnostics = createServerDiagnosticsActions({
@ -269,7 +269,7 @@ describe("server diagnostics actions", () => {
stateStore,
...metadataCache,
refreshAppServerMetadata: vi.fn().mockResolvedValue(undefined),
isStaleResourceContextError: () => false,
isStaleRuntimeError: () => false,
});
const diagnostics = createServerDiagnosticsActions({
stateStore,
@ -422,14 +422,14 @@ function metadataCacheHost(cache: { current: SharedServerMetadata | null } = { c
refreshAppServerMetadata: () => Promise<void>;
refreshSkills: () => Promise<void>;
refreshRateLimits: () => Promise<void>;
isStaleResourceContextError: (error: unknown) => boolean;
isStaleRuntimeError: (error: unknown) => boolean;
} {
return {
appServerMetadataSnapshot: () => cache.current,
refreshAppServerMetadata: vi.fn().mockResolvedValue(undefined),
refreshSkills: vi.fn().mockResolvedValue(undefined),
refreshRateLimits: vi.fn().mockResolvedValue(undefined),
isStaleResourceContextError: () => false,
isStaleRuntimeError: () => false,
};
}

View file

@ -2,7 +2,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StaleAppServerResourceContextError } from "../../../../src/app-server/query/cache";
import type { Thread } from "../../../../src/domain/threads/model";
import { type ChatStateStore, createChatStateStore } from "../../../../src/features/chat/application/state/store";
import { createThreadGoalOperationCoordinator } from "../../../../src/features/chat/application/threads/goal-actions";
@ -13,6 +12,7 @@ import { ChatPanelSessionRuntime } from "../../../../src/features/chat/host/sess
import { createChatThreadStreamScrollBinding } from "../../../../src/features/chat/panel/thread-stream-scroll-binding";
import { createThreadNameMutationCoordinator } from "../../../../src/features/threads/workflows/thread-name-mutation-coordinator";
import { type CodexPanelSettings, DEFAULT_SETTINGS } from "../../../../src/settings/model";
import { StaleExecutionRuntimeError } from "../../../../src/shared/runtime/execution-runtime-lifetime";
import { createKeyedOperationQueue } from "../../../../src/shared/runtime/keyed-operation-queue";
import { deferred, waitForAsyncWork } from "../../../support/async";
import { installObsidianDomShims } from "../../../support/dom";
@ -73,7 +73,7 @@ describe("ChatPanelSessionRuntime actions", () => {
});
it("treats stale shared thread refreshes as runtime-local no-ops", async () => {
const refresh = vi.fn().mockRejectedValue(new StaleAppServerResourceContextError());
const refresh = vi.fn().mockRejectedValue(new StaleExecutionRuntimeError());
const { runtime, stateStore } = sessionRuntimeFixture({
environment: {
plugin: {

View file

@ -51,9 +51,45 @@ describe("chat toolbar archive confirmation state", () => {
unmountChatPanelShell(container);
});
});
it("reprojects the archive default when settings refresh the mounted shell", async () => {
const store = createChatStateStore();
const container = document.createElement("div");
const toolbarActions = createToolbarPanelActions({
stateStore: store,
threadActions: { archiveThread: vi.fn() } as unknown as ThreadManagementActions,
});
let archiveExportEnabled = true;
const parts = shellParts(store, toolbarActions, () => archiveExportEnabled);
store.dispatch({ type: "thread-list/applied", threads: [threadFixture("thread-1", "Thread one")] });
store.dispatch({ type: "ui/panel-set", panel: "history" });
toolbarActions.startArchive("thread-1");
document.body.appendChild(container);
await act(async () => {
renderChatPanelShell(container, { stateStore: store, showToolbar: true, parts });
await settle();
});
expect(container.querySelector(".codex-panel__archive-default")?.getAttribute("aria-label")).toBe("Save and archive thread");
archiveExportEnabled = false;
await act(async () => {
renderChatPanelShell(container, { stateStore: store, showToolbar: true, parts });
await settle();
});
expect(container.querySelector(".codex-panel__archive-default")?.getAttribute("aria-label")).toBe("Archive thread without saving");
await act(async () => {
unmountChatPanelShell(container);
});
});
});
function toolbarSurface(_store: ReturnType<typeof createChatStateStore>, _toolbarActions: ToolbarPanelActions): ChatPanelToolbarSurface {
function toolbarSurface(
_store: ReturnType<typeof createChatStateStore>,
_toolbarActions: ToolbarPanelActions,
archiveExportEnabled: () => boolean,
): ChatPanelToolbarSurface {
return {
connection: {
connected: () => false,
@ -64,13 +100,17 @@ function toolbarSurface(_store: ReturnType<typeof createChatStateStore>, _toolba
settings: {
vaultPath: () => "/vault",
configuredCommand: () => "codex",
archiveExportEnabled: () => true,
archiveExportEnabled,
},
};
}
function shellParts(store: ReturnType<typeof createChatStateStore>, toolbarPanelActions: ToolbarPanelActions): ChatPanelShellParts {
const surface = surfaceFixture(store, toolbarPanelActions);
function shellParts(
store: ReturnType<typeof createChatStateStore>,
toolbarPanelActions: ToolbarPanelActions,
archiveExportEnabled: () => boolean = () => true,
): ChatPanelShellParts {
const surface = surfaceFixture(store, toolbarPanelActions, archiveExportEnabled);
return {
toolbar: {
surface: surface.toolbar,
@ -168,12 +208,13 @@ function shellParts(store: ReturnType<typeof createChatStateStore>, toolbarPanel
function surfaceFixture(
store: ReturnType<typeof createChatStateStore>,
toolbarActions: ToolbarPanelActions,
archiveExportEnabled: () => boolean,
): {
toolbar: ChatPanelToolbarSurface;
goal: ChatPanelGoalSurface;
} {
return {
toolbar: toolbarSurface(store, toolbarActions),
toolbar: toolbarSurface(store, toolbarActions, archiveExportEnabled),
goal: {
sendShortcut: () => "enter",
actions: {

View file

@ -152,7 +152,7 @@ describe("CodexPanelPlugin runtime integration", () => {
);
const first = threadCatalog(plugin).refreshActive();
const staleFirst = expect(first).rejects.toThrow("Codex app-server resource context changed while loading.");
const staleFirst = expect(first).rejects.toThrow("Codex execution runtime was disposed while work was in progress.");
await flushMicrotasks();
await publishCodexPath(plugin, "codex-b");
const second = threadCatalog(plugin).refreshActive();
@ -230,7 +230,7 @@ describe("CodexPanelPlugin runtime integration", () => {
await publishCodexPath(plugin, "codex-b");
result.resolve("stale-result");
await expect(operation).rejects.toThrow("Codex app-server resource context changed while loading.");
await expect(operation).rejects.toThrow("Codex execution runtime was disposed while work was in progress.");
});
it("rejects an old A result after switching from A to B and back to A", async () => {
@ -246,7 +246,7 @@ describe("CodexPanelPlugin runtime integration", () => {
await publishCodexPath(plugin, "codex-a");
result.resolve("stale-a");
await expect(operation).rejects.toThrow("Codex app-server resource context changed while loading.");
await expect(operation).rejects.toThrow("Codex execution runtime was disposed while work was in progress.");
expect(currentChatHost(plugin).appServerContext).toEqual({ codexPath: "codex-a", vaultPath: "/vault" });
});
@ -267,7 +267,7 @@ describe("CodexPanelPlugin runtime integration", () => {
await publishCodexPath(plugin, "codex-b");
clientReady.resolve();
await expect(operation).rejects.toThrow("Codex app-server resource context changed while loading.");
await expect(operation).rejects.toThrow("Codex execution runtime was disposed while work was in progress.");
expect(callback).not.toHaveBeenCalled();
expect(shortLivedClient.request).not.toHaveBeenCalled();
});

View file

@ -2,11 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { modelMetadataFromCatalogModels } from "../../src/app-server/protocol/catalog";
import type { ThreadRecord } from "../../src/app-server/protocol/thread";
import { StaleAppServerResourceContextError } from "../../src/app-server/query/cache";
import type { ObservedResult } from "../../src/app-server/query/observed-result";
import type { ModelMetadata } from "../../src/domain/catalog/metadata";
import type { Thread } from "../../src/domain/threads/model";
import { SettingsDynamicSectionsController } from "../../src/settings/dynamic-sections-controller";
import { StaleExecutionRuntimeError } from "../../src/shared/runtime/execution-runtime-lifetime";
import { deferred } from "../support/async";
import {
appServerThread,
@ -350,7 +350,7 @@ describe("SettingsDynamicSectionsController", () => {
oldRestore.resolve({
thread: appServerThread({ id: "thread-shared", preview: "Old context" }),
});
await expect(staleMutation).rejects.toBeInstanceOf(StaleAppServerResourceContextError);
await expect(staleMutation).rejects.toBeInstanceOf(StaleExecutionRuntimeError);
});
it("records restored archived threads in the active catalog", async () => {

View file

@ -4,7 +4,6 @@ import type { AppServerClient } from "../../src/app-server/connection/client";
import type { AppServerClientAccessOptions } from "../../src/app-server/connection/client-access";
import type { CatalogHookMetadata, CatalogModel } from "../../src/app-server/protocol/catalog";
import type { ThreadRecord } from "../../src/app-server/protocol/thread";
import { StaleAppServerResourceContextError } from "../../src/app-server/query/cache";
import type { ModelMetadata, ReasoningEffort } from "../../src/domain/catalog/metadata";
import type { Thread } from "../../src/domain/threads/model";
import type { ThreadCatalogEvent } from "../../src/features/threads/catalog/thread-catalog";
@ -12,6 +11,7 @@ import { createSettingsAppServerDynamicData } from "../../src/settings/app-serve
import type { SettingsDynamicDataAccess } from "../../src/settings/dynamic-data";
import type { CodexPanelSettingTabHost } from "../../src/settings/host";
import { type CodexPanelSettings, DEFAULT_SETTINGS } from "../../src/settings/model";
import { StaleExecutionRuntimeError } from "../../src/shared/runtime/execution-runtime-lifetime";
type ShortLivedClientOperation = (
codexPath: string,
@ -234,12 +234,12 @@ export function settingsTabHost(options: SettingsTabHostOptions = {}): CodexPane
settings.codexPath,
"/vault",
async (client) => {
if (appServerQueries.contextKey() !== contextKey) throw new StaleAppServerResourceContextError();
if (appServerQueries.contextKey() !== contextKey) throw new StaleExecutionRuntimeError();
return operation(client);
},
clientOptions,
)) as T;
if (appServerQueries.contextKey() !== contextKey) throw new StaleAppServerResourceContextError();
if (appServerQueries.contextKey() !== contextKey) throw new StaleExecutionRuntimeError();
return result;
},
};