From 193b680f06b4dce1521e365fc14d8cb14363fc4b Mon Sep 17 00:00:00 2001 From: murashit Date: Mon, 20 Jul 2026 23:36:23 +0900 Subject: [PATCH] refactor(runtime): remove obsolete lifetime and query abstractions --- .../connection/execution-context.ts | 4 + src/app-server/query/cache.ts | 96 ++++++++----------- src/app-server/query/keys.ts | 48 ---------- src/execution-runtime.ts | 52 +++++----- .../connection/connection-actions.ts | 10 +- .../connection/server-metadata-actions.ts | 6 +- .../chat/host/bundles/connection-bundle.ts | 6 +- src/features/chat/host/contracts.ts | 4 +- src/features/chat/host/session-runtime.ts | 4 +- src/features/chat/panel/shell.dom.tsx | 5 +- src/features/threads-view/session.ts | 6 +- .../runtime/execution-runtime-lifetime.ts | 10 ++ tests/app-server/query-cache.test.ts | 69 ++++++------- tests/execution-runtime.test.ts | 36 ++++++- .../connection/connection-actions.test.ts | 6 +- .../connection/server-actions.test.ts | 18 ++-- .../chat/host/session-runtime.test.ts | 4 +- .../chat/panel/toolbar-archive-state.test.tsx | 51 +++++++++- tests/plugin-runtime.integration.test.ts | 8 +- .../dynamic-sections-controller.test.ts | 4 +- tests/settings/test-support.ts | 6 +- 21 files changed, 232 insertions(+), 221 deletions(-) create mode 100644 src/app-server/connection/execution-context.ts delete mode 100644 src/app-server/query/keys.ts create mode 100644 src/shared/runtime/execution-runtime-lifetime.ts diff --git a/src/app-server/connection/execution-context.ts b/src/app-server/connection/execution-context.ts new file mode 100644 index 00000000..e7b0d355 --- /dev/null +++ b/src/app-server/connection/execution-context.ts @@ -0,0 +1,4 @@ +export interface AppServerExecutionContext { + readonly codexPath: string; + readonly vaultPath: string; +} diff --git a/src/app-server/query/cache.ts b/src/app-server/query/cache.ts index 8ac1ab90..00249c69 100644 --- a/src/app-server/query/cache.ts +++ b/src/app-server/query/cache.ts @@ -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 { readonly queryKey: readonly unknown[]; @@ -69,7 +57,7 @@ interface AppServerQueryOptions { readonly staleTime?: number; } -type ActiveThreadsQueryKey = ReturnType; +type ActiveThreadsQueryKey = typeof ACTIVE_THREADS_QUERY_KEY; interface MetadataResourceSnapshot { 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; + private readonly context: Readonly; 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(activeThreadsQueryKey()); + const data = this.client.getQueryData(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(activeThreadsQueryKey()); + const data = this.client.getQueryData(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 { 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 { 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(activeThreadsQueryKey())); + return activeThreadDataHasMore(this.client.getQueryData(ACTIVE_THREADS_QUERY_KEY)); } loadMoreActiveThreads(): Promise { @@ -212,7 +200,7 @@ export class AppServerQueryCache { fetchArchivedThreads(options: { force?: boolean } = {}): Promise { 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(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(archivedThreadsQueryKey()); + const threads = this.client.getQueryData(ARCHIVED_THREADS_QUERY_KEY); return threads ? cloneThreads(threads) : null; } appServerMetadataSnapshot(): SharedServerMetadata | null { if (this.disposed) return null; - const runtimeConfig = this.client.getQueryData(appServerRuntimeConfigQueryKey()); + const runtimeConfig = this.client.getQueryData(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(appServerModelsQueryKey()); + const models = this.client.getQueryData(MODELS_QUERY_KEY); return models ? cloneModelMetadata(models) : null; } @@ -409,7 +397,7 @@ export class AppServerQueryCache { fetchModels(options: { force?: boolean } = {}): Promise { 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 { 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 { return { - queryKey: appServerRuntimeConfigQueryKey(), + queryKey: RUNTIME_CONFIG_QUERY_KEY, queryFn: async (): Promise => this.runWithClient(async (client) => runtimeConfigSnapshotFromAppServerConfig(await readEffectiveConfig(client, this.context.vaultPath)), @@ -468,7 +456,7 @@ export class AppServerQueryCache { private skillsQueryOptions(): AppServerQueryOptions> { 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> { 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> { 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 { - 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>(key); const failedProbe = diagnosticProbeFromError(state?.error); return { @@ -550,7 +534,7 @@ export class AppServerQueryCache { } private modelsProbe(): DiagnosticProbeResult { - const state = this.client.getQueryState(appServerModelsQueryKey()); + const state = this.client.getQueryState(MODELS_QUERY_KEY); return ( diagnosticProbeFromError(state?.error) ?? (state?.data @@ -561,7 +545,7 @@ export class AppServerQueryCache { private modelsQueryOptions(): AppServerQueryOptions { return { - queryKey: appServerModelsQueryKey(), + queryKey: MODELS_QUERY_KEY, queryFn: async (): Promise => { 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(operation: () => Promise): Promise { @@ -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; } })(); diff --git a/src/app-server/query/keys.ts b/src/app-server/query/keys.ts deleted file mode 100644 index f2f8af67..00000000 --- a/src/app-server/query/keys.ts +++ /dev/null @@ -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"]; -} diff --git a/src/execution-runtime.ts b/src/execution-runtime.ts index 1c51199a..e234d8dc 100644 --- a/src/execution-runtime.ts +++ b/src/execution-runtime.ts @@ -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; - readonly appServerQueries: AppServerQueryCache; - readonly threadCatalog: ThreadCatalog; + private readonly context: Readonly; + 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 { diff --git a/src/features/chat/application/connection/connection-actions.ts b/src/features/chat/application/connection/connection-actions.ts index aadc7b36..d10e3136 100644 --- a/src/features/chat/application/connection/connection-actions.ts +++ b/src/features/chat/application/connection/connection-actions.ts @@ -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 Promise; refreshSkills: () => Promise; refreshRateLimits: () => Promise; - 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; } } diff --git a/src/features/chat/host/bundles/connection-bundle.ts b/src/features/chat/host/bundles/connection-bundle.ts index 41dfd21c..3534d841 100644 --- a/src/features/chat/host/bundles/connection-bundle.ts +++ b/src/features/chat/host/bundles/connection-bundle.ts @@ -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."); }, diff --git a/src/features/chat/host/contracts.ts b/src/features/chat/host/contracts.ts index 13934028..877cdf1d 100644 --- a/src/features/chat/host/contracts.ts +++ b/src/features/chat/host/contracts.ts @@ -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; + readonly appServerContext: Readonly; readonly settings: ChatPanelSettingsAccess; readonly workspace: WorkspacePanels; readonly appServerQueries: ChatAppServerQueries; diff --git a/src/features/chat/host/session-runtime.ts b/src/features/chat/host/session-runtime.ts index 7431dd7e..d27cacd4 100644 --- a/src/features/chat/host/session-runtime.ts +++ b/src/features/chat/host/session-runtime.ts @@ -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; } }; diff --git a/src/features/chat/panel/shell.dom.tsx b/src/features/chat/panel/shell.dom.tsx index 2d200942..9f0af192 100644 --- a/src/features/chat/panel/shell.dom.tsx +++ b/src/features/chat/panel/shell.dom.tsx @@ -130,10 +130,7 @@ function ChatPanelToolbarRegion({ actions: ToolbarActions; }): UiNode { const model = useChatSelector(stateStore, selectChatPanelToolbar); - return useMemo( - () => , - [model, stateStore, surface, actions], - ); + return ; } function ChatPanelGoalRegion({ stateStore, surface }: { stateStore: ChatStateStore; surface: ChatPanelGoalSurface }): UiNode { diff --git a/src/features/threads-view/session.ts b/src/features/threads-view/session.ts index 51359b81..e68ecc0b 100644 --- a/src/features/threads-view/session.ts +++ b/src/features/threads-view/session.ts @@ -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(); } diff --git a/src/shared/runtime/execution-runtime-lifetime.ts b/src/shared/runtime/execution-runtime-lifetime.ts new file mode 100644 index 00000000..6f9c7da2 --- /dev/null +++ b/src/shared/runtime/execution-runtime-lifetime.ts @@ -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; +} diff --git a/tests/app-server/query-cache.test.ts b/tests/app-server/query-cache.test.ts index 0edac297..629d246d 100644 --- a/tests/app-server/query-cache.test.ts +++ b/tests/app-server/query-cache.test.ts @@ -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(); 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 { +function cacheContext(overrides: Partial = {}): AppServerExecutionContext { return { codexPath: "codex", vaultPath: "/vault", @@ -852,30 +843,28 @@ function cacheContext(overrides: Partial = {}): AppServer } function cacheWithThreads( - fetchThreads: (context: AppServerQueryContext, archived: boolean) => Promise[]>, - context: AppServerQueryContext = cacheContext(), + fetchThreads: (context: AppServerExecutionContext, archived: boolean) => Promise[]>, + 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 Promise>, - 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 { diff --git a/tests/execution-runtime.test.ts b/tests/execution-runtime.test.ts index 0c35787c..59a80163 100644 --- a/tests/execution-runtime.test.ts +++ b/tests/execution-runtime.test.ts @@ -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((_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 }>; finish: Array<() => void>; diff --git a/tests/features/chat/application/connection/connection-actions.test.ts b/tests/features/chat/application/connection/connection-actions.test.ts index 21e35cc3..7f933ad3 100644 --- a/tests/features/chat/application/connection/connection-actions.test.ts +++ b/tests/features/chat/application/connection/connection-actions.test.ts @@ -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(); }); diff --git a/tests/features/chat/application/connection/server-actions.test.ts b/tests/features/chat/application/connection/server-actions.test.ts index 384a3ab8..83079562 100644 --- a/tests/features/chat/application/connection/server-actions.test.ts +++ b/tests/features/chat/application/connection/server-actions.test.ts @@ -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; refreshSkills: () => Promise; refreshRateLimits: () => Promise; - 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, }; } diff --git a/tests/features/chat/host/session-runtime.test.ts b/tests/features/chat/host/session-runtime.test.ts index 1b1e48ac..3537d815 100644 --- a/tests/features/chat/host/session-runtime.test.ts +++ b/tests/features/chat/host/session-runtime.test.ts @@ -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: { diff --git a/tests/features/chat/panel/toolbar-archive-state.test.tsx b/tests/features/chat/panel/toolbar-archive-state.test.tsx index 5221fd45..069b1da2 100644 --- a/tests/features/chat/panel/toolbar-archive-state.test.tsx +++ b/tests/features/chat/panel/toolbar-archive-state.test.tsx @@ -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, _toolbarActions: ToolbarPanelActions): ChatPanelToolbarSurface { +function toolbarSurface( + _store: ReturnType, + _toolbarActions: ToolbarPanelActions, + archiveExportEnabled: () => boolean, +): ChatPanelToolbarSurface { return { connection: { connected: () => false, @@ -64,13 +100,17 @@ function toolbarSurface(_store: ReturnType, _toolba settings: { vaultPath: () => "/vault", configuredCommand: () => "codex", - archiveExportEnabled: () => true, + archiveExportEnabled, }, }; } -function shellParts(store: ReturnType, toolbarPanelActions: ToolbarPanelActions): ChatPanelShellParts { - const surface = surfaceFixture(store, toolbarPanelActions); +function shellParts( + store: ReturnType, + 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, toolbarPanel function surfaceFixture( store: ReturnType, toolbarActions: ToolbarPanelActions, + archiveExportEnabled: () => boolean, ): { toolbar: ChatPanelToolbarSurface; goal: ChatPanelGoalSurface; } { return { - toolbar: toolbarSurface(store, toolbarActions), + toolbar: toolbarSurface(store, toolbarActions, archiveExportEnabled), goal: { sendShortcut: () => "enter", actions: { diff --git a/tests/plugin-runtime.integration.test.ts b/tests/plugin-runtime.integration.test.ts index f54d594c..deef17e8 100644 --- a/tests/plugin-runtime.integration.test.ts +++ b/tests/plugin-runtime.integration.test.ts @@ -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(); }); diff --git a/tests/settings/dynamic-sections-controller.test.ts b/tests/settings/dynamic-sections-controller.test.ts index c3fb136b..5c8fdc1b 100644 --- a/tests/settings/dynamic-sections-controller.test.ts +++ b/tests/settings/dynamic-sections-controller.test.ts @@ -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 () => { diff --git a/tests/settings/test-support.ts b/tests/settings/test-support.ts index 844804b1..272c5fba 100644 --- a/tests/settings/test-support.ts +++ b/tests/settings/test-support.ts @@ -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; }, };