mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
refactor(app-server): own context leases in resource store
This commit is contained in:
parent
d202849313
commit
cb172edfb7
42 changed files with 937 additions and 795 deletions
|
|
@ -16,6 +16,7 @@ export type AppServerClientFactory = (codexPath: string, cwd: string, handlers:
|
|||
export interface AppServerConnectionContext {
|
||||
readonly codexPath: string;
|
||||
readonly cwd: string;
|
||||
readonly generation: number;
|
||||
}
|
||||
|
||||
type ConnectionLifecycleState =
|
||||
|
|
@ -23,12 +24,13 @@ type ConnectionLifecycleState =
|
|||
| {
|
||||
kind: "connecting";
|
||||
generation: number;
|
||||
contextGeneration: number;
|
||||
codexPath: string;
|
||||
cwd: string;
|
||||
client: AppServerClient;
|
||||
promise: Promise<ServerInitialization>;
|
||||
}
|
||||
| { kind: "connected"; generation: number; codexPath: string; cwd: string; client: AppServerClient }
|
||||
| { kind: "connected"; generation: number; contextGeneration: number; codexPath: string; cwd: string; client: AppServerClient }
|
||||
| { kind: "disconnected"; generation: number };
|
||||
|
||||
export class StaleConnectionError extends Error {
|
||||
|
|
@ -47,6 +49,7 @@ export class ConnectionManager {
|
|||
initializeParams: InitializeParams,
|
||||
private readonly clientFactory: AppServerClientFactory = (codexPath, cwd, handlers) =>
|
||||
new AppServerClient({ codexPath, cwd, handlers, initializeParams }),
|
||||
private readonly contextGeneration: () => number = () => 0,
|
||||
) {}
|
||||
|
||||
currentClient(): AppServerClient | null {
|
||||
|
|
@ -59,7 +62,7 @@ export class ConnectionManager {
|
|||
if (this.state.kind !== "connected" || !this.state.client.isConnected() || !this.stateMatchesCurrentContext(this.state)) {
|
||||
return null;
|
||||
}
|
||||
return { codexPath: this.state.codexPath, cwd: this.state.cwd };
|
||||
return { codexPath: this.state.codexPath, cwd: this.state.cwd, generation: this.state.contextGeneration };
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
|
|
@ -75,9 +78,10 @@ export class ConnectionManager {
|
|||
if (this.state.kind === "connecting" || this.state.kind === "connected") this.disconnect();
|
||||
|
||||
const generation = this.state.generation + 1;
|
||||
const contextGeneration = this.contextGeneration();
|
||||
const codexPath = this.codexPath();
|
||||
const cwd = this.cwd;
|
||||
const context = { codexPath, cwd };
|
||||
const context = { codexPath, cwd, generation: contextGeneration };
|
||||
const client = this.clientFactory(codexPath, cwd, {
|
||||
onNotification: (notification) => {
|
||||
if (this.isStale(generation)) return;
|
||||
|
|
@ -104,7 +108,7 @@ export class ConnectionManager {
|
|||
client.disconnect();
|
||||
throw new StaleConnectionError();
|
||||
}
|
||||
this.state = { kind: "connected", generation, codexPath, cwd, client };
|
||||
this.state = { kind: "connected", generation, contextGeneration, codexPath, cwd, client };
|
||||
return serverInitializationFromResponse(response);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
|
|
@ -118,7 +122,7 @@ export class ConnectionManager {
|
|||
throw error;
|
||||
});
|
||||
|
||||
this.state = { kind: "connecting", generation, codexPath, cwd, client, promise };
|
||||
this.state = { kind: "connecting", generation, contextGeneration, codexPath, cwd, client, promise };
|
||||
return promise;
|
||||
}
|
||||
|
||||
|
|
@ -139,7 +143,7 @@ export class ConnectionManager {
|
|||
}
|
||||
|
||||
private stateMatchesCurrentContext(state: Extract<ConnectionLifecycleState, { kind: "connecting" | "connected" }>): boolean {
|
||||
return state.codexPath === this.codexPath() && state.cwd === this.cwd;
|
||||
return state.codexPath === this.codexPath() && state.cwd === this.cwd && state.contextGeneration === this.contextGeneration();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,17 +19,17 @@ import { listModelMetadata } from "../services/catalog";
|
|||
import { readEffectiveConfig } from "../services/runtime-metadata";
|
||||
import { listThreads, readThreadPage } from "../services/threads";
|
||||
import {
|
||||
type AppServerQueryContext,
|
||||
type AppServerQueryContextIdentity as AppServerQueryContext,
|
||||
activeThreadsQueryKey,
|
||||
appServerModelsQueryKey,
|
||||
appServerPermissionProfilesQueryKey,
|
||||
appServerQueryContextIdentityKey,
|
||||
appServerQueryContextIsComplete,
|
||||
appServerQueryContextKey,
|
||||
appServerRateLimitsQueryKey,
|
||||
appServerRuntimeConfigQueryKey,
|
||||
appServerSkillsQueryKey,
|
||||
archivedThreadsQueryKey,
|
||||
cloneAppServerQueryContext,
|
||||
cloneAppServerQueryContextIdentity,
|
||||
} from "./keys";
|
||||
import { readPermissionProfileMetadataProbe, readRateLimitMetadataProbe, readSkillMetadataProbe } from "./metadata-probes";
|
||||
import type { ObservedResult, ObservedResultListener } from "./observed-result";
|
||||
|
|
@ -98,6 +98,22 @@ export class AppServerQueryCache {
|
|||
this.client.clear();
|
||||
}
|
||||
|
||||
release(context: AppServerQueryContext): void {
|
||||
this.generation += 1;
|
||||
const identityKey = appServerQueryContextIdentityKey(context);
|
||||
const activeThreadsKey = JSON.stringify(activeThreadsQueryKey(context));
|
||||
this.activeThreadCursors.delete(activeThreadsKey);
|
||||
this.activeThreadRevisions.delete(activeThreadsKey);
|
||||
this.metadataRefreshes.delete(identityKey);
|
||||
this.metadataProjectionListeners.delete(identityKey);
|
||||
for (const resource of ["skills", "permissionProfiles", "rateLimits"] as const) {
|
||||
const resourceKey = this.metadataResourceKey(context, resource);
|
||||
this.metadataResourceFetches.delete(resourceKey);
|
||||
this.metadataNotificationRefreshes.delete(resourceKey);
|
||||
}
|
||||
this.client.removeQueries({ queryKey: ["app-server", context.generation, context.codexPath, context.vaultPath] });
|
||||
}
|
||||
|
||||
activeThreadsSnapshot(context: AppServerQueryContext): readonly Thread[] | null {
|
||||
return this.threadListSnapshot(context, "active");
|
||||
}
|
||||
|
|
@ -135,7 +151,7 @@ export class AppServerQueryCache {
|
|||
}
|
||||
|
||||
async fetchAllActiveThreads(context: AppServerQueryContext): Promise<readonly Thread[]> {
|
||||
const refreshContext = cloneAppServerQueryContext(context);
|
||||
const refreshContext = cloneAppServerQueryContextIdentity(context);
|
||||
if (!appServerQueryContextIsComplete(refreshContext)) return [];
|
||||
const cursorKey = this.activeThreadCursorKey(refreshContext);
|
||||
const snapshot = this.activeThreadsSnapshot(refreshContext);
|
||||
|
|
@ -157,7 +173,7 @@ export class AppServerQueryCache {
|
|||
}
|
||||
|
||||
async loadMoreActiveThreads(context: AppServerQueryContext): Promise<readonly Thread[]> {
|
||||
const refreshContext = cloneAppServerQueryContext(context);
|
||||
const refreshContext = cloneAppServerQueryContextIdentity(context);
|
||||
if (!appServerQueryContextIsComplete(refreshContext)) return [];
|
||||
const current = this.activeThreadsSnapshot(refreshContext) ?? (await this.fetchActiveThreads(refreshContext));
|
||||
const cursor = this.activeThreadCursors.get(this.activeThreadCursorKey(refreshContext)) ?? null;
|
||||
|
|
@ -191,7 +207,7 @@ export class AppServerQueryCache {
|
|||
kind: ThreadListKind,
|
||||
options: { force?: boolean } = {},
|
||||
): Promise<readonly Thread[]> {
|
||||
const refreshContext = cloneAppServerQueryContext(context);
|
||||
const refreshContext = cloneAppServerQueryContextIdentity(context);
|
||||
if (!appServerQueryContextIsComplete(refreshContext)) {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -232,7 +248,7 @@ export class AppServerQueryCache {
|
|||
listener: ObservedResultListener<SharedServerMetadata>,
|
||||
options: { emitCurrent?: boolean } = {},
|
||||
): () => void {
|
||||
const refreshContext = cloneAppServerQueryContext(context);
|
||||
const refreshContext = cloneAppServerQueryContextIdentity(context);
|
||||
const generation = this.generation;
|
||||
const observers = [
|
||||
new QueryObserver(this.client, { ...this.runtimeConfigQueryOptions(refreshContext), enabled: false }),
|
||||
|
|
@ -256,11 +272,11 @@ export class AppServerQueryCache {
|
|||
};
|
||||
const schedule = (): void => {
|
||||
if (disposed || generation !== this.generation) return;
|
||||
if ((this.metadataRefreshes.get(appServerQueryContextKey(refreshContext)) ?? 0) > 0 || queued) return;
|
||||
if ((this.metadataRefreshes.get(appServerQueryContextIdentityKey(refreshContext)) ?? 0) > 0 || queued) return;
|
||||
queued = true;
|
||||
queueMicrotask(emit);
|
||||
};
|
||||
const contextKey = appServerQueryContextKey(refreshContext);
|
||||
const contextKey = appServerQueryContextIdentityKey(refreshContext);
|
||||
const projectionListeners = this.metadataProjectionListeners.get(contextKey) ?? new Set<() => void>();
|
||||
projectionListeners.add(schedule);
|
||||
this.metadataProjectionListeners.set(contextKey, projectionListeners);
|
||||
|
|
@ -275,7 +291,7 @@ export class AppServerQueryCache {
|
|||
}
|
||||
|
||||
async refreshAppServerMetadata(context: AppServerQueryContext): Promise<SharedServerMetadata | null> {
|
||||
const refreshContext = cloneAppServerQueryContext(context);
|
||||
const refreshContext = cloneAppServerQueryContextIdentity(context);
|
||||
if (!appServerQueryContextIsComplete(refreshContext)) return null;
|
||||
return this.runMetadataRefresh(refreshContext, async () => {
|
||||
const runtimeResult = this.fetchRuntimeConfig(refreshContext, true).then(
|
||||
|
|
@ -297,7 +313,7 @@ export class AppServerQueryCache {
|
|||
}
|
||||
|
||||
async refreshSkills(context: AppServerQueryContext): Promise<SharedServerMetadata | null> {
|
||||
const refreshContext = cloneAppServerQueryContext(context);
|
||||
const refreshContext = cloneAppServerQueryContextIdentity(context);
|
||||
if (!appServerQueryContextIsComplete(refreshContext)) return null;
|
||||
return this.runMetadataRefresh(refreshContext, async () => {
|
||||
const current = await this.refreshMetadataResourceNotification(refreshContext, "skills");
|
||||
|
|
@ -306,7 +322,7 @@ export class AppServerQueryCache {
|
|||
}
|
||||
|
||||
async refreshRateLimits(context: AppServerQueryContext): Promise<SharedServerMetadata | null> {
|
||||
const refreshContext = cloneAppServerQueryContext(context);
|
||||
const refreshContext = cloneAppServerQueryContextIdentity(context);
|
||||
if (!appServerQueryContextIsComplete(refreshContext)) return null;
|
||||
return this.runMetadataRefresh(refreshContext, async () => {
|
||||
const current = await this.refreshMetadataResourceNotification(refreshContext, "rateLimits");
|
||||
|
|
@ -329,7 +345,7 @@ export class AppServerQueryCache {
|
|||
}
|
||||
|
||||
async fetchModels(context: AppServerQueryContext, options: { force?: boolean } = {}): Promise<readonly ModelMetadata[]> {
|
||||
const refreshContext = cloneAppServerQueryContext(context);
|
||||
const refreshContext = cloneAppServerQueryContextIdentity(context);
|
||||
if (!appServerQueryContextIsComplete(refreshContext)) {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -344,7 +360,7 @@ export class AppServerQueryCache {
|
|||
}
|
||||
|
||||
private threadListQueryOptions(context: AppServerQueryContext, kind: ThreadListKind): AppServerQueryOptions<readonly Thread[]> {
|
||||
const refreshContext = cloneAppServerQueryContext(context);
|
||||
const refreshContext = cloneAppServerQueryContextIdentity(context);
|
||||
return {
|
||||
queryKey: this.threadListQueryKey(refreshContext, kind),
|
||||
queryFn: async (): Promise<readonly Thread[]> => {
|
||||
|
|
@ -373,7 +389,7 @@ export class AppServerQueryCache {
|
|||
}
|
||||
|
||||
private runtimeConfigQueryOptions(context: AppServerQueryContext): AppServerQueryOptions<RuntimeConfigSnapshot> {
|
||||
const refreshContext = cloneAppServerQueryContext(context);
|
||||
const refreshContext = cloneAppServerQueryContextIdentity(context);
|
||||
return {
|
||||
queryKey: appServerRuntimeConfigQueryKey(refreshContext),
|
||||
queryFn: async (): Promise<RuntimeConfigSnapshot> =>
|
||||
|
|
@ -388,7 +404,7 @@ export class AppServerQueryCache {
|
|||
context: AppServerQueryContext,
|
||||
forceReload = false,
|
||||
): AppServerQueryOptions<MetadataResourceSnapshot<readonly SkillMetadata[]>> {
|
||||
const refreshContext = cloneAppServerQueryContext(context);
|
||||
const refreshContext = cloneAppServerQueryContextIdentity(context);
|
||||
return {
|
||||
queryKey: appServerSkillsQueryKey(refreshContext),
|
||||
queryFn: async () =>
|
||||
|
|
@ -402,7 +418,7 @@ export class AppServerQueryCache {
|
|||
private permissionProfilesQueryOptions(
|
||||
context: AppServerQueryContext,
|
||||
): AppServerQueryOptions<MetadataResourceSnapshot<readonly RuntimePermissionProfileSummary[]>> {
|
||||
const refreshContext = cloneAppServerQueryContext(context);
|
||||
const refreshContext = cloneAppServerQueryContextIdentity(context);
|
||||
return {
|
||||
queryKey: appServerPermissionProfilesQueryKey(refreshContext),
|
||||
queryFn: async () =>
|
||||
|
|
@ -416,7 +432,7 @@ export class AppServerQueryCache {
|
|||
private rateLimitsQueryOptions(
|
||||
context: AppServerQueryContext,
|
||||
): AppServerQueryOptions<MetadataResourceSnapshot<RateLimitSnapshot | null>> {
|
||||
const refreshContext = cloneAppServerQueryContext(context);
|
||||
const refreshContext = cloneAppServerQueryContextIdentity(context);
|
||||
return {
|
||||
queryKey: appServerRateLimitsQueryKey(refreshContext),
|
||||
queryFn: async () =>
|
||||
|
|
@ -503,7 +519,7 @@ export class AppServerQueryCache {
|
|||
}
|
||||
|
||||
private metadataResourceKey(context: AppServerQueryContext, resource: MetadataResourceKind): string {
|
||||
return `${appServerQueryContextKey(context)}\u0000${resource}`;
|
||||
return `${appServerQueryContextIdentityKey(context)}\u0000${resource}`;
|
||||
}
|
||||
|
||||
private metadataResourceState(
|
||||
|
|
@ -547,7 +563,7 @@ export class AppServerQueryCache {
|
|||
}
|
||||
|
||||
private async runMetadataRefresh<T>(context: AppServerQueryContext, operation: () => Promise<T>): Promise<T> {
|
||||
const key = appServerQueryContextKey(context);
|
||||
const key = appServerQueryContextIdentityKey(context);
|
||||
this.metadataRefreshes.set(key, (this.metadataRefreshes.get(key) ?? 0) + 1);
|
||||
try {
|
||||
return await operation();
|
||||
|
|
@ -563,7 +579,7 @@ export class AppServerQueryCache {
|
|||
}
|
||||
|
||||
private modelsQueryOptions(context: AppServerQueryContext): AppServerQueryOptions<readonly ModelMetadata[]> {
|
||||
const refreshContext = cloneAppServerQueryContext(context);
|
||||
const refreshContext = cloneAppServerQueryContextIdentity(context);
|
||||
return {
|
||||
queryKey: appServerModelsQueryKey(refreshContext),
|
||||
queryFn: async (): Promise<readonly ModelMetadata[]> => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,16 @@ export interface AppServerQueryContext {
|
|||
vaultPath: string;
|
||||
}
|
||||
|
||||
type AppServerQueryScope = readonly ["app-server", string, string];
|
||||
export interface AppServerContextLease {
|
||||
readonly context: Readonly<AppServerQueryContext>;
|
||||
readonly generation: number;
|
||||
}
|
||||
|
||||
export interface AppServerQueryContextIdentity extends AppServerQueryContext {
|
||||
readonly generation: number;
|
||||
}
|
||||
|
||||
type AppServerQueryScope = readonly ["app-server", number, string, string];
|
||||
export type AppServerActiveThreadsQueryKey = readonly [...AppServerQueryScope, "threads", "active"];
|
||||
export type AppServerArchivedThreadsQueryKey = readonly [...AppServerQueryScope, "threads", "archived"];
|
||||
export type AppServerModelsQueryKey = readonly [...AppServerQueryScope, "models"];
|
||||
|
|
@ -16,51 +25,77 @@ export function appServerQueryContextIsComplete(context: AppServerQueryContext):
|
|||
return nonEmptyString(context.codexPath) && nonEmptyString(context.vaultPath);
|
||||
}
|
||||
|
||||
export function cloneAppServerQueryContext(context: AppServerQueryContext): AppServerQueryContext {
|
||||
function cloneAppServerQueryContext(context: AppServerQueryContext): AppServerQueryContext {
|
||||
return { ...context };
|
||||
}
|
||||
|
||||
export function createAppServerContextLease(context: AppServerQueryContext, generation: number): AppServerContextLease {
|
||||
return Object.freeze({
|
||||
context: Object.freeze(cloneAppServerQueryContext(context)),
|
||||
generation,
|
||||
});
|
||||
}
|
||||
|
||||
export function appServerQueryContextIdentity(lease: AppServerContextLease): AppServerQueryContextIdentity {
|
||||
return {
|
||||
...lease.context,
|
||||
generation: lease.generation,
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneAppServerQueryContextIdentity(identity: AppServerQueryContextIdentity): AppServerQueryContextIdentity {
|
||||
return { ...identity };
|
||||
}
|
||||
|
||||
export function appServerQueryContextRawEquals(left: AppServerQueryContext, right: AppServerQueryContext): boolean {
|
||||
return left.codexPath === right.codexPath && left.vaultPath === right.vaultPath;
|
||||
}
|
||||
|
||||
export function appServerQueryContextMatches(left: AppServerQueryContext, right: AppServerQueryContext): boolean {
|
||||
function appServerQueryContextMatches(left: AppServerQueryContext, right: AppServerQueryContext): boolean {
|
||||
return appServerQueryContextIsComplete(left) && appServerQueryContextIsComplete(right) && appServerQueryContextRawEquals(left, right);
|
||||
}
|
||||
|
||||
export function appServerQueryContextKey(context: AppServerQueryContext): string {
|
||||
function appServerQueryContextKey(context: AppServerQueryContext): string {
|
||||
return `${context.codexPath}\u0000${context.vaultPath}`;
|
||||
}
|
||||
|
||||
function appServerQueryScope(context: AppServerQueryContext): AppServerQueryScope {
|
||||
return ["app-server", context.codexPath, context.vaultPath];
|
||||
export function appServerQueryContextIdentityMatches(left: AppServerQueryContextIdentity, right: AppServerQueryContextIdentity): boolean {
|
||||
return left.generation === right.generation && appServerQueryContextMatches(left, right);
|
||||
}
|
||||
|
||||
export function activeThreadsQueryKey(context: AppServerQueryContext): AppServerActiveThreadsQueryKey {
|
||||
export function appServerQueryContextIdentityKey(identity: AppServerQueryContextIdentity): string {
|
||||
return `${String(identity.generation)}\u0000${appServerQueryContextKey(identity)}`;
|
||||
}
|
||||
|
||||
function appServerQueryScope(context: AppServerQueryContextIdentity): AppServerQueryScope {
|
||||
return ["app-server", context.generation, context.codexPath, context.vaultPath];
|
||||
}
|
||||
|
||||
export function activeThreadsQueryKey(context: AppServerQueryContextIdentity): AppServerActiveThreadsQueryKey {
|
||||
return [...appServerQueryScope(context), "threads", "active"];
|
||||
}
|
||||
|
||||
export function archivedThreadsQueryKey(context: AppServerQueryContext): AppServerArchivedThreadsQueryKey {
|
||||
export function archivedThreadsQueryKey(context: AppServerQueryContextIdentity): AppServerArchivedThreadsQueryKey {
|
||||
return [...appServerQueryScope(context), "threads", "archived"];
|
||||
}
|
||||
|
||||
export function appServerModelsQueryKey(context: AppServerQueryContext): AppServerModelsQueryKey {
|
||||
export function appServerModelsQueryKey(context: AppServerQueryContextIdentity): AppServerModelsQueryKey {
|
||||
return [...appServerQueryScope(context), "models"];
|
||||
}
|
||||
|
||||
export function appServerRuntimeConfigQueryKey(context: AppServerQueryContext): AppServerRuntimeConfigQueryKey {
|
||||
export function appServerRuntimeConfigQueryKey(context: AppServerQueryContextIdentity): AppServerRuntimeConfigQueryKey {
|
||||
return [...appServerQueryScope(context), "runtime-config"];
|
||||
}
|
||||
|
||||
export function appServerSkillsQueryKey(context: AppServerQueryContext): AppServerSkillsQueryKey {
|
||||
export function appServerSkillsQueryKey(context: AppServerQueryContextIdentity): AppServerSkillsQueryKey {
|
||||
return [...appServerQueryScope(context), "skills"];
|
||||
}
|
||||
|
||||
export function appServerPermissionProfilesQueryKey(context: AppServerQueryContext): AppServerPermissionProfilesQueryKey {
|
||||
export function appServerPermissionProfilesQueryKey(context: AppServerQueryContextIdentity): AppServerPermissionProfilesQueryKey {
|
||||
return [...appServerQueryScope(context), "permission-profiles"];
|
||||
}
|
||||
|
||||
export function appServerRateLimitsQueryKey(context: AppServerQueryContext): AppServerRateLimitsQueryKey {
|
||||
export function appServerRateLimitsQueryKey(context: AppServerQueryContextIdentity): AppServerRateLimitsQueryKey {
|
||||
return [...appServerQueryScope(context), "rate-limits"];
|
||||
}
|
||||
|
||||
|
|
|
|||
260
src/app-server/query/resource-store.ts
Normal file
260
src/app-server/query/resource-store.ts
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
import type { ModelMetadata } from "../../domain/catalog/metadata";
|
||||
import type { SharedServerMetadata } from "../../domain/server/metadata";
|
||||
import type { Thread } from "../../domain/threads/model";
|
||||
import { AppServerQueryCache, type AppServerQueryClientRunner } from "./cache";
|
||||
import {
|
||||
type AppServerContextLease,
|
||||
type AppServerQueryContext,
|
||||
type AppServerQueryContextIdentity,
|
||||
appServerQueryContextIdentity,
|
||||
appServerQueryContextIdentityKey,
|
||||
appServerQueryContextIdentityMatches,
|
||||
appServerQueryContextRawEquals,
|
||||
createAppServerContextLease,
|
||||
} from "./keys";
|
||||
import type { ObservedResultListener } from "./observed-result";
|
||||
|
||||
export interface AppServerResourceStoreOptions {
|
||||
cache?: AppServerQueryCache;
|
||||
clientRunner?: AppServerQueryClientRunner;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface ResourceObserver<T> {
|
||||
readonly observe: (
|
||||
context: AppServerQueryContextIdentity,
|
||||
listener: (value: T) => void,
|
||||
options: { emitCurrent?: boolean },
|
||||
) => () => void;
|
||||
readonly listener: (value: T) => void;
|
||||
readonly options: { emitCurrent?: boolean };
|
||||
firstSubscribe: boolean;
|
||||
unsubscribeQuery: (() => void) | null;
|
||||
}
|
||||
|
||||
export class AppServerResourceStore {
|
||||
private readonly observers = new Set<ResourceObserver<unknown>>();
|
||||
private readonly cache: AppServerQueryCache;
|
||||
private lease: AppServerContextLease | null = null;
|
||||
private generation = 0;
|
||||
|
||||
constructor(options: AppServerResourceStoreOptions = {}) {
|
||||
this.cache = options.cache ?? new AppServerQueryCache(options.clientRunner ? { clientRunner: options.clientRunner } : {});
|
||||
}
|
||||
|
||||
initialize(context: AppServerQueryContext): AppServerContextLease {
|
||||
if (this.lease) throw new Error("Codex app-server resource store is already initialized.");
|
||||
this.lease = this.nextLease(context);
|
||||
this.rebindObservers();
|
||||
return this.lease;
|
||||
}
|
||||
|
||||
replaceContext(context: AppServerQueryContext): AppServerContextLease {
|
||||
if (!this.lease) throw new Error("Codex app-server resource store is not initialized.");
|
||||
if (appServerQueryContextRawEquals(this.lease.context, context)) return this.lease;
|
||||
this.unsubscribeObservers();
|
||||
this.cache.release(appServerQueryContextIdentity(this.lease));
|
||||
this.lease = this.nextLease(context);
|
||||
this.rebindObservers();
|
||||
return this.lease;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.unsubscribeObservers();
|
||||
if (this.lease) this.cache.release(appServerQueryContextIdentity(this.lease));
|
||||
this.lease = null;
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
contextLease(): AppServerContextLease {
|
||||
if (!this.lease) throw new Error("Codex app-server resource store is not initialized.");
|
||||
return this.lease;
|
||||
}
|
||||
|
||||
contextIdentity(): AppServerQueryContextIdentity {
|
||||
return appServerQueryContextIdentity(this.contextLease());
|
||||
}
|
||||
|
||||
contextKey(): string {
|
||||
return appServerQueryContextIdentityKey(this.contextIdentity());
|
||||
}
|
||||
|
||||
contextKeyFor(context: AppServerQueryContextIdentity): string {
|
||||
return appServerQueryContextIdentityKey(context);
|
||||
}
|
||||
|
||||
activeThreadsSnapshot(): readonly Thread[] | null {
|
||||
return this.lease ? this.cache.activeThreadsSnapshot(this.contextIdentity()) : null;
|
||||
}
|
||||
|
||||
archivedThreadsSnapshot(): readonly Thread[] | null {
|
||||
return this.lease ? this.cache.archivedThreadsSnapshot(this.contextIdentity()) : null;
|
||||
}
|
||||
|
||||
fetchAllActiveThreads(): Promise<readonly Thread[]> {
|
||||
return this.runForCurrentContext((context) => this.cache.fetchAllActiveThreads(context));
|
||||
}
|
||||
|
||||
hasMoreActiveThreads(): boolean {
|
||||
return this.lease ? this.cache.hasMoreActiveThreads(this.contextIdentity()) : false;
|
||||
}
|
||||
|
||||
loadMoreActiveThreads(): Promise<readonly Thread[]> {
|
||||
return this.runForCurrentContext((context) => this.cache.loadMoreActiveThreads(context));
|
||||
}
|
||||
|
||||
refreshActiveThreads(): Promise<readonly Thread[]> {
|
||||
return this.runForCurrentContext((context) => this.cache.refreshActiveThreads(context));
|
||||
}
|
||||
|
||||
refreshArchivedThreads(): Promise<readonly Thread[]> {
|
||||
return this.runForCurrentContext((context) => this.cache.refreshArchivedThreads(context));
|
||||
}
|
||||
|
||||
observeActiveThreadsResult(listener: ObservedResultListener<readonly Thread[]>, options?: { emitCurrent?: boolean }): () => void {
|
||||
return this.observeCurrentContext(
|
||||
(context, contextListener, observeOptions) => this.cache.observeActiveThreadsResult(context, contextListener, observeOptions),
|
||||
listener,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
observeArchivedThreadsResult(listener: ObservedResultListener<readonly Thread[]>, options?: { emitCurrent?: boolean }): () => void {
|
||||
return this.observeCurrentContext(
|
||||
(context, contextListener, observeOptions) => this.cache.observeArchivedThreadsResult(context, contextListener, observeOptions),
|
||||
listener,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
appServerMetadataSnapshot(): SharedServerMetadata | null {
|
||||
return this.lease ? this.cache.appServerMetadataSnapshot(this.contextIdentity()) : null;
|
||||
}
|
||||
|
||||
refreshAppServerMetadata(): Promise<SharedServerMetadata | null> {
|
||||
return this.runForCurrentContext((context) => this.cache.refreshAppServerMetadata(context));
|
||||
}
|
||||
|
||||
refreshSkills(): Promise<SharedServerMetadata | null> {
|
||||
return this.runForCurrentContext((context) => this.cache.refreshSkills(context));
|
||||
}
|
||||
|
||||
refreshRateLimits(): Promise<SharedServerMetadata | null> {
|
||||
return this.runForCurrentContext((context) => this.cache.refreshRateLimits(context));
|
||||
}
|
||||
|
||||
observeAppServerMetadataResult(listener: ObservedResultListener<SharedServerMetadata>, options?: { emitCurrent?: boolean }): () => void {
|
||||
return this.observeCurrentContext(
|
||||
(context, contextListener, observeOptions) => this.cache.observeAppServerMetadataResult(context, contextListener, observeOptions),
|
||||
listener,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
modelsSnapshot(): readonly ModelMetadata[] | null {
|
||||
return this.lease ? this.cache.modelsSnapshot(this.contextIdentity()) : null;
|
||||
}
|
||||
|
||||
fetchModels(): Promise<readonly ModelMetadata[]> {
|
||||
return this.runForCurrentContext((context) => this.cache.fetchModels(context));
|
||||
}
|
||||
|
||||
refreshModels(): Promise<readonly ModelMetadata[]> {
|
||||
return this.runForCurrentContext((context) => this.cache.refreshModels(context));
|
||||
}
|
||||
|
||||
observeModelsResult(listener: ObservedResultListener<readonly ModelMetadata[]>, options?: { emitCurrent?: boolean }): () => void {
|
||||
return this.observeCurrentContext(
|
||||
(context, contextListener, observeOptions) => this.cache.observeModelsResult(context, contextListener, observeOptions),
|
||||
listener,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
private nextLease(context: AppServerQueryContext): AppServerContextLease {
|
||||
return createAppServerContextLease(context, ++this.generation);
|
||||
}
|
||||
|
||||
private isCurrent(context: AppServerQueryContextIdentity): boolean {
|
||||
return Boolean(this.lease && appServerQueryContextIdentityMatches(this.contextIdentity(), context));
|
||||
}
|
||||
|
||||
private runForCurrentContext<T>(operation: (context: AppServerQueryContextIdentity) => Promise<T>): Promise<T> {
|
||||
const context = this.contextIdentity();
|
||||
return this.runForIdentity(context, () => operation(context));
|
||||
}
|
||||
|
||||
private async runForIdentity<T>(context: AppServerQueryContextIdentity, operation: () => Promise<T>): Promise<T> {
|
||||
if (!this.isCurrent(context)) throw new StaleAppServerResourceContextError();
|
||||
let result: T;
|
||||
try {
|
||||
result = await operation();
|
||||
} catch (error) {
|
||||
if (!this.isCurrent(context)) throw new StaleAppServerResourceContextError();
|
||||
throw error;
|
||||
}
|
||||
if (!this.isCurrent(context)) throw new StaleAppServerResourceContextError();
|
||||
return result;
|
||||
}
|
||||
|
||||
private observeCurrentContext<T>(
|
||||
observe: ResourceObserver<T>["observe"],
|
||||
listener: ResourceObserver<T>["listener"],
|
||||
options: { emitCurrent?: boolean } = {},
|
||||
): () => void {
|
||||
const observer: ResourceObserver<T> = {
|
||||
observe,
|
||||
listener,
|
||||
options,
|
||||
firstSubscribe: true,
|
||||
unsubscribeQuery: null,
|
||||
};
|
||||
this.observers.add(observer as ResourceObserver<unknown>);
|
||||
this.bindObserver(observer);
|
||||
return () => {
|
||||
this.observers.delete(observer as ResourceObserver<unknown>);
|
||||
observer.unsubscribeQuery?.();
|
||||
observer.unsubscribeQuery = null;
|
||||
};
|
||||
}
|
||||
|
||||
private bindObserver<T>(observer: ResourceObserver<T>): void {
|
||||
if (!this.lease) return;
|
||||
const context = this.contextIdentity();
|
||||
const observeOptions: { emitCurrent?: boolean } = {};
|
||||
if (observer.firstSubscribe) {
|
||||
if (observer.options.emitCurrent !== undefined) observeOptions.emitCurrent = observer.options.emitCurrent;
|
||||
} else {
|
||||
observeOptions.emitCurrent = true;
|
||||
}
|
||||
observer.unsubscribeQuery = observer.observe(
|
||||
context,
|
||||
(value) => {
|
||||
if (this.isCurrent(context)) observer.listener(value);
|
||||
},
|
||||
observeOptions,
|
||||
);
|
||||
observer.firstSubscribe = false;
|
||||
}
|
||||
|
||||
private unsubscribeObservers(): void {
|
||||
for (const observer of this.observers) {
|
||||
observer.unsubscribeQuery?.();
|
||||
observer.unsubscribeQuery = null;
|
||||
}
|
||||
}
|
||||
|
||||
private rebindObservers(): void {
|
||||
for (const observer of this.observers) this.bindObserver(observer);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,205 +0,0 @@
|
|||
import type { ModelMetadata } from "../../domain/catalog/metadata";
|
||||
import type { SharedServerMetadata } from "../../domain/server/metadata";
|
||||
import type { Thread } from "../../domain/threads/model";
|
||||
import type { AppServerQueryCache } from "./cache";
|
||||
import {
|
||||
type AppServerQueryContext,
|
||||
appServerQueryContextKey,
|
||||
appServerQueryContextMatches,
|
||||
appServerQueryContextRawEquals,
|
||||
cloneAppServerQueryContext,
|
||||
} from "./keys";
|
||||
import type { ObservedResultListener } from "./observed-result";
|
||||
|
||||
export interface AppServerSharedQueriesOptions {
|
||||
cache: AppServerQueryCache;
|
||||
context: () => AppServerQueryContext;
|
||||
}
|
||||
|
||||
export class StaleAppServerSharedQueryContextError extends Error {
|
||||
constructor() {
|
||||
super("Codex app-server query context changed while loading shared queries.");
|
||||
this.name = "StaleAppServerSharedQueryContextError";
|
||||
}
|
||||
}
|
||||
|
||||
export function isStaleAppServerSharedQueryContextError(error: unknown): error is StaleAppServerSharedQueryContextError {
|
||||
return error instanceof StaleAppServerSharedQueryContextError;
|
||||
}
|
||||
|
||||
export class AppServerSharedQueries {
|
||||
private readonly contextChangeListeners = new Set<() => void>();
|
||||
|
||||
constructor(private readonly options: AppServerSharedQueriesOptions) {}
|
||||
|
||||
contextKey(): string {
|
||||
return appServerQueryContextKey(this.context());
|
||||
}
|
||||
|
||||
contextKeyFor(context: AppServerQueryContext): string {
|
||||
return appServerQueryContextKey(context);
|
||||
}
|
||||
|
||||
activeThreadsSnapshot(): readonly Thread[] | null {
|
||||
return this.options.cache.activeThreadsSnapshot(this.context());
|
||||
}
|
||||
|
||||
activeThreadsSnapshotFor(context: AppServerQueryContext): readonly Thread[] | null {
|
||||
return this.options.cache.activeThreadsSnapshot(context);
|
||||
}
|
||||
|
||||
archivedThreadsSnapshot(): readonly Thread[] | null {
|
||||
return this.options.cache.archivedThreadsSnapshot(this.context());
|
||||
}
|
||||
|
||||
archivedThreadsSnapshotFor(context: AppServerQueryContext): readonly Thread[] | null {
|
||||
return this.options.cache.archivedThreadsSnapshot(context);
|
||||
}
|
||||
|
||||
fetchAllActiveThreads(): Promise<readonly Thread[]> {
|
||||
return this.runForCurrentContext((context) => this.options.cache.fetchAllActiveThreads(context));
|
||||
}
|
||||
|
||||
hasMoreActiveThreads(): boolean {
|
||||
return this.options.cache.hasMoreActiveThreads(this.context());
|
||||
}
|
||||
|
||||
loadMoreActiveThreads(): Promise<readonly Thread[]> {
|
||||
return this.runForCurrentContext((context) => this.options.cache.loadMoreActiveThreads(context));
|
||||
}
|
||||
|
||||
refreshActiveThreads(): Promise<readonly Thread[]> {
|
||||
return this.runForCurrentContext((context) => this.options.cache.refreshActiveThreads(context));
|
||||
}
|
||||
|
||||
refreshActiveThreadsFor(context: AppServerQueryContext): Promise<readonly Thread[]> {
|
||||
return this.options.cache.refreshActiveThreads(context);
|
||||
}
|
||||
|
||||
refreshArchivedThreads(): Promise<readonly Thread[]> {
|
||||
return this.runForCurrentContext((context) => this.options.cache.refreshArchivedThreads(context));
|
||||
}
|
||||
|
||||
refreshArchivedThreadsFor(context: AppServerQueryContext): Promise<readonly Thread[]> {
|
||||
return this.options.cache.refreshArchivedThreads(context);
|
||||
}
|
||||
|
||||
observeActiveThreadsResult(listener: ObservedResultListener<readonly Thread[]>, options?: { emitCurrent?: boolean }): () => void {
|
||||
return this.observeCurrentContext(
|
||||
(context, contextListener, observeOptions) => this.options.cache.observeActiveThreadsResult(context, contextListener, observeOptions),
|
||||
listener,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
observeArchivedThreadsResult(listener: ObservedResultListener<readonly Thread[]>, options?: { emitCurrent?: boolean }): () => void {
|
||||
return this.observeCurrentContext(
|
||||
(context, contextListener, observeOptions) =>
|
||||
this.options.cache.observeArchivedThreadsResult(context, contextListener, observeOptions),
|
||||
listener,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
appServerMetadataSnapshot(): SharedServerMetadata | null {
|
||||
return this.options.cache.appServerMetadataSnapshot(this.context());
|
||||
}
|
||||
|
||||
refreshAppServerMetadata(): Promise<SharedServerMetadata | null> {
|
||||
return this.runForCurrentContext((context) => this.options.cache.refreshAppServerMetadata(context));
|
||||
}
|
||||
|
||||
refreshSkills(): Promise<SharedServerMetadata | null> {
|
||||
return this.runForCurrentContext((context) => this.options.cache.refreshSkills(context));
|
||||
}
|
||||
|
||||
refreshRateLimits(): Promise<SharedServerMetadata | null> {
|
||||
return this.runForCurrentContext((context) => this.options.cache.refreshRateLimits(context));
|
||||
}
|
||||
|
||||
observeAppServerMetadataResult(listener: ObservedResultListener<SharedServerMetadata>, options?: { emitCurrent?: boolean }): () => void {
|
||||
return this.observeCurrentContext(
|
||||
(context, contextListener, observeOptions) =>
|
||||
this.options.cache.observeAppServerMetadataResult(context, contextListener, observeOptions),
|
||||
listener,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
modelsSnapshot(): readonly ModelMetadata[] | null {
|
||||
return this.options.cache.modelsSnapshot(this.context());
|
||||
}
|
||||
|
||||
fetchModels(): Promise<readonly ModelMetadata[]> {
|
||||
return this.runForCurrentContext((context) => this.options.cache.fetchModels(context));
|
||||
}
|
||||
|
||||
refreshModels(): Promise<readonly ModelMetadata[]> {
|
||||
return this.runForCurrentContext((context) => this.options.cache.refreshModels(context));
|
||||
}
|
||||
|
||||
observeModelsResult(listener: ObservedResultListener<readonly ModelMetadata[]>, options?: { emitCurrent?: boolean }): () => void {
|
||||
return this.observeCurrentContext(
|
||||
(context, contextListener, observeOptions) => this.options.cache.observeModelsResult(context, contextListener, observeOptions),
|
||||
listener,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
notifyContextChanged(): void {
|
||||
for (const listener of [...this.contextChangeListeners]) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
private context(): AppServerQueryContext {
|
||||
return this.options.context();
|
||||
}
|
||||
|
||||
private async runForCurrentContext<T>(operation: (context: AppServerQueryContext) => Promise<T>): Promise<T> {
|
||||
const context = cloneAppServerQueryContext(this.context());
|
||||
const result = await operation(context);
|
||||
if (!appServerQueryContextRawEquals(this.context(), context)) {
|
||||
throw new StaleAppServerSharedQueryContextError();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private observeCurrentContext<T>(
|
||||
observe: (context: AppServerQueryContext, listener: (value: T) => void, options: { emitCurrent?: boolean }) => () => void,
|
||||
listener: (value: T) => void,
|
||||
options: { emitCurrent?: boolean } = {},
|
||||
): () => void {
|
||||
let observedContext: AppServerQueryContext | null = null;
|
||||
let unsubscribeQuery: (() => void) | null = null;
|
||||
let firstSubscribe = true;
|
||||
|
||||
const subscribe = (): void => {
|
||||
const context = cloneAppServerQueryContext(this.context());
|
||||
if (observedContext && appServerQueryContextMatches(observedContext, context)) return;
|
||||
unsubscribeQuery?.();
|
||||
observedContext = context;
|
||||
const observeOptions: { emitCurrent?: boolean } = {};
|
||||
if (firstSubscribe) {
|
||||
if (options.emitCurrent !== undefined) observeOptions.emitCurrent = options.emitCurrent;
|
||||
} else {
|
||||
observeOptions.emitCurrent = true;
|
||||
}
|
||||
unsubscribeQuery = observe(
|
||||
context,
|
||||
(value) => {
|
||||
if (appServerQueryContextMatches(this.context(), context)) listener(value);
|
||||
},
|
||||
observeOptions,
|
||||
);
|
||||
firstSubscribe = false;
|
||||
};
|
||||
|
||||
subscribe();
|
||||
this.contextChangeListeners.add(subscribe);
|
||||
return () => {
|
||||
this.contextChangeListeners.delete(subscribe);
|
||||
unsubscribeQuery?.();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { RequestId, ServerNotification, ServerRequest } from "../../../../app-server/connection/rpc-messages";
|
||||
import type { AppServerQueryContext } from "../../../../app-server/query/keys";
|
||||
import type { AppServerQueryContextIdentity } from "../../../../app-server/query/keys";
|
||||
import {
|
||||
routeServerRequest,
|
||||
serverRequestApprovalResponse,
|
||||
|
|
@ -36,13 +36,13 @@ export interface ChatInboundHandlerActions {
|
|||
refreshServerDiagnostics: (options?: { forceResourceProbes?: boolean }) => void;
|
||||
applyAppServerResourceEvent: (event: AppServerResourceEvent) => void;
|
||||
maybeNameThread: (threadId: string, turnId: string, completedTurnTranscriptSummary: TurnTranscriptSummary | null) => void;
|
||||
applyThreadCatalogEvent: (event: ThreadCatalogEvent, sourceContext: AppServerQueryContext) => void;
|
||||
applyThreadCatalogEvent: (event: ThreadCatalogEvent, sourceContext: AppServerQueryContextIdentity) => void;
|
||||
respondToServerRequest: (requestId: RequestId, result: unknown) => boolean;
|
||||
rejectServerRequest: (requestId: RequestId, code: number, message: string) => boolean;
|
||||
}
|
||||
|
||||
export interface ChatInboundHandler {
|
||||
handleNotification(notification: ServerNotification, sourceContext: AppServerQueryContext): void;
|
||||
handleNotification(notification: ServerNotification, sourceContext: AppServerQueryContextIdentity): void;
|
||||
handleServerRequest(request: ServerRequest): void;
|
||||
handleAppServerLog(message: string): void;
|
||||
resolveApproval(requestId: PendingRequestId, action: ApprovalAction): void;
|
||||
|
|
@ -111,7 +111,7 @@ function dispatch(context: ChatInboundHandlerContext, action: ChatAction): void
|
|||
function handleNotification(
|
||||
context: ChatInboundHandlerContext,
|
||||
notification: ServerNotification,
|
||||
sourceContext: AppServerQueryContext,
|
||||
sourceContext: AppServerQueryContextIdentity,
|
||||
): void {
|
||||
const plan = planChatNotification(state(context), notification, (prefix) => localItemId(context, prefix));
|
||||
for (const action of plan.actions) dispatch(context, action);
|
||||
|
|
@ -246,7 +246,7 @@ function localItemId(context: ChatInboundHandlerContext, prefix: string): string
|
|||
function runNotificationEffect(
|
||||
context: ChatInboundHandlerContext,
|
||||
effect: ChatNotificationEffect,
|
||||
sourceContext: AppServerQueryContext,
|
||||
sourceContext: AppServerQueryContextIdentity,
|
||||
): void {
|
||||
switch (effect.type) {
|
||||
case "refresh-threads":
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export interface ChatConnectionActionsHost {
|
|||
configuredCommand: () => string;
|
||||
refreshLiveState: () => void;
|
||||
isStaleConnectionError: (error: unknown) => boolean;
|
||||
isStaleSharedQueryError: (error: unknown) => boolean;
|
||||
isStaleResourceContextError: (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.isStaleSharedQueryError(error)) return;
|
||||
if (host.isStaleResourceContextError(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.isStaleSharedQueryError(error)) return;
|
||||
if (host.isStaleResourceContextError(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.isStaleSharedQueryError(error)) return;
|
||||
if (isStale() || host.isStaleResourceContextError(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.isStaleSharedQueryError(error)) return;
|
||||
if (isStale() || host.isStaleResourceContextError(error)) return;
|
||||
host.addSystemMessage(`Could not refresh Codex threads: ${errorMessage(error)}`);
|
||||
}
|
||||
if (isStale()) return;
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export interface ServerMetadataActionsHost {
|
|||
refreshAppServerMetadata: () => Promise<SharedServerMetadata | null>;
|
||||
refreshSkills: () => Promise<SharedServerMetadata | null>;
|
||||
refreshRateLimits: () => Promise<SharedServerMetadata | null>;
|
||||
isStaleSharedQueryError: (error: unknown) => boolean;
|
||||
isStaleResourceContextError: (error: unknown) => boolean;
|
||||
}
|
||||
|
||||
export interface ServerMetadataActions {
|
||||
|
|
@ -81,7 +81,7 @@ async function refreshAppServerMetadata(host: ServerMetadataActionsHost): Promis
|
|||
try {
|
||||
metadata = await host.refreshAppServerMetadata();
|
||||
} catch (error) {
|
||||
if (host.isStaleSharedQueryError(error)) return null;
|
||||
if (host.isStaleResourceContextError(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
if (!metadata) return null;
|
||||
|
|
@ -102,7 +102,7 @@ async function refreshMetadataResource(
|
|||
const metadata = await refresh();
|
||||
if (metadata) applyAppServerMetadata(host, metadata);
|
||||
} catch (error) {
|
||||
if (!host.isStaleSharedQueryError(error)) throw error;
|
||||
if (!host.isStaleResourceContextError(error)) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,12 +15,14 @@ export interface AutoTitleCoordinatorHost {
|
|||
}
|
||||
|
||||
export interface AutoTitleCoordinator {
|
||||
invalidate(): void;
|
||||
resetThreadTurnPresence(hadTurns: boolean): void;
|
||||
maybeAutoTitleThread(threadId: string, turnId: string, completedTurnTranscriptSummary: TurnTranscriptSummary | null): void;
|
||||
}
|
||||
|
||||
export function createAutoTitleCoordinator(host: AutoTitleCoordinatorHost): AutoTitleCoordinator {
|
||||
let activeThreadHadTurns = false;
|
||||
let generation = 0;
|
||||
const attemptedThreadIds = new Set<string>();
|
||||
const inFlightThreadIds = new Set<string>();
|
||||
|
||||
|
|
@ -31,23 +33,30 @@ export function createAutoTitleCoordinator(host: AutoTitleCoordinatorHost): Auto
|
|||
const candidate = thread(threadId);
|
||||
return Boolean(candidate && !candidate.name?.trim());
|
||||
};
|
||||
const generateAndSetTitle = async (threadId: string, context: ThreadTitleContext): Promise<void> => {
|
||||
const generateAndSetTitle = async (threadId: string, context: ThreadTitleContext, operationGeneration: number): Promise<void> => {
|
||||
try {
|
||||
const title = await host.generateTitleFromContext(context);
|
||||
if (!title || !threadCanReceiveGeneratedTitle(threadId)) return;
|
||||
if (operationGeneration !== generation || !title || !threadCanReceiveGeneratedTitle(threadId)) return;
|
||||
|
||||
await host.renameGeneratedTitle(threadId, title, {
|
||||
shouldStart: () => threadCanReceiveGeneratedTitle(threadId),
|
||||
shouldPublish: () => threadCanReceiveGeneratedTitle(threadId),
|
||||
shouldStart: () => operationGeneration === generation && threadCanReceiveGeneratedTitle(threadId),
|
||||
shouldPublish: () => operationGeneration === generation && threadCanReceiveGeneratedTitle(threadId),
|
||||
});
|
||||
} catch {
|
||||
// Auto-title is best-effort metadata. Leave the thread preview untouched on failure.
|
||||
} finally {
|
||||
inFlightThreadIds.delete(threadId);
|
||||
if (operationGeneration === generation) inFlightThreadIds.delete(threadId);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
invalidate() {
|
||||
generation += 1;
|
||||
activeThreadHadTurns = false;
|
||||
attemptedThreadIds.clear();
|
||||
inFlightThreadIds.clear();
|
||||
},
|
||||
|
||||
resetThreadTurnPresence(hadTurns) {
|
||||
activeThreadHadTurns = hadTurns;
|
||||
},
|
||||
|
|
@ -64,7 +73,7 @@ export function createAutoTitleCoordinator(host: AutoTitleCoordinatorHost): Auto
|
|||
|
||||
attemptedThreadIds.add(threadId);
|
||||
inFlightThreadIds.add(threadId);
|
||||
void generateAndSetTitle(threadId, context);
|
||||
void generateAndSetTitle(threadId, context, generation);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export interface ThreadRenameEditorActionsHost {
|
|||
}
|
||||
|
||||
export interface ThreadRenameEditorActions {
|
||||
invalidate(): void;
|
||||
editState(threadId: string): RenameEditState | null;
|
||||
isEditing(): boolean;
|
||||
start(threadId: string): void;
|
||||
|
|
@ -33,6 +34,10 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH
|
|||
let nextRenameGenerationToken = 1;
|
||||
|
||||
const action = {
|
||||
invalidate(): void {
|
||||
dispatch(host, { type: "ui/rename-cleared" });
|
||||
},
|
||||
|
||||
editState(threadId: string): RenameEditState | null {
|
||||
const current = renameState(host);
|
||||
if (current.kind === "idle" || current.threadId !== threadId) return null;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Notice } from "obsidian";
|
|||
|
||||
import type { AppServerClient, AppServerServerRequestResponder } from "../../../../app-server/connection/client";
|
||||
import { type ConnectionManager, StaleConnectionError } from "../../../../app-server/connection/connection-manager";
|
||||
import { isStaleAppServerSharedQueryContextError } from "../../../../app-server/query/shared-queries";
|
||||
import { isStaleAppServerResourceContextError } from "../../../../app-server/query/resource-store";
|
||||
import type { SharedServerMetadata } from "../../../../domain/server/metadata";
|
||||
import { type ChatInboundHandler, createChatInboundHandler } from "../../app-server/inbound/handler";
|
||||
import { type ChatConnectionActions, createChatConnectionActions } from "../../application/connection/connection-actions";
|
||||
|
|
@ -128,7 +128,7 @@ export function createConnectionBundle(
|
|||
refreshAppServerMetadata: () => environment.plugin.appServerQueries.refreshAppServerMetadata(),
|
||||
refreshSkills: () => environment.plugin.appServerQueries.refreshSkills(),
|
||||
refreshRateLimits: () => environment.plugin.appServerQueries.refreshRateLimits(),
|
||||
isStaleSharedQueryError: isStaleAppServerSharedQueryContextError,
|
||||
isStaleResourceContextError: isStaleAppServerResourceContextError,
|
||||
});
|
||||
const serverDiagnostics = createServerDiagnosticsActions({
|
||||
stateStore,
|
||||
|
|
@ -141,7 +141,7 @@ export function createConnectionBundle(
|
|||
};
|
||||
const refreshSharedThreadsQuietly = (): void => {
|
||||
void refreshSharedThreads().catch((error: unknown) => {
|
||||
if (isStaleAppServerSharedQueryContextError(error)) return;
|
||||
if (isStaleAppServerResourceContextError(error)) return;
|
||||
status.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
};
|
||||
|
|
@ -194,6 +194,7 @@ export function createConnectionBundle(
|
|||
inboundHandler.handleNotification(notification, {
|
||||
codexPath: sourceContext.codexPath,
|
||||
vaultPath: sourceContext.cwd,
|
||||
generation: sourceContext.generation,
|
||||
});
|
||||
host.deferLiveStateRefresh();
|
||||
},
|
||||
|
|
@ -245,7 +246,7 @@ export function createConnectionBundle(
|
|||
host.refreshLiveState();
|
||||
},
|
||||
isStaleConnectionError: (error) => error instanceof StaleConnectionError,
|
||||
isStaleSharedQueryError: isStaleAppServerSharedQueryContextError,
|
||||
isStaleResourceContextError: isStaleAppServerResourceContextError,
|
||||
notifyConnectionFailed: () => {
|
||||
new Notice("Codex app-server connection failed.");
|
||||
},
|
||||
|
|
|
|||
|
|
@ -114,8 +114,8 @@ export function createThreadFoundation(host: ChatPanelThreadHost, input: ChatPan
|
|||
const threadOperationsTransport = createThreadOperationsTransport(appServer.clientAccess);
|
||||
const threadTitleTransport = createThreadTitleTransport({
|
||||
clientAccess: appServer.clientAccess,
|
||||
codexPath: () => environment.plugin.settingsRef.settings.codexPath(),
|
||||
vaultPath: environment.plugin.settingsRef.vaultPath,
|
||||
codexPath: () => environment.plugin.appServerQueries.contextLease().context.codexPath,
|
||||
vaultPath: environment.plugin.appServerQueries.contextLease().context.vaultPath,
|
||||
threadNamingModel: () => environment.plugin.settingsRef.settings.threadNamingModel(),
|
||||
threadNamingEffort: () => environment.plugin.settingsRef.settings.threadNamingEffort(),
|
||||
});
|
||||
|
|
@ -165,6 +165,8 @@ export function createThreadFoundation(host: ChatPanelThreadHost, input: ChatPan
|
|||
const invalidateThreadWork = () => {
|
||||
host.resumeWork.invalidate();
|
||||
history.invalidate();
|
||||
titleService.invalidate();
|
||||
autoTitleCoordinator.invalidate();
|
||||
};
|
||||
const goalSync = createThreadGoalSyncActions({
|
||||
stateStore,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { App, Component, EventRef } from "obsidian";
|
||||
|
||||
import type { AppServerClient } from "../../../app-server/connection/client";
|
||||
import type { AppServerQueryContext } from "../../../app-server/query/keys";
|
||||
import type { AppServerContextLease, AppServerQueryContextIdentity } from "../../../app-server/query/keys";
|
||||
import type { ObservedResultListener } from "../../../app-server/query/observed-result";
|
||||
import type { ModelMetadata, ReasoningEffort } from "../../../domain/catalog/metadata";
|
||||
import type { SendShortcut } from "../../../domain/input/send-shortcut";
|
||||
|
|
@ -54,6 +54,7 @@ interface WorkspacePanels {
|
|||
type ChatThreadCatalog = ThreadCatalogActiveReader & ThreadCatalogEventSink & ThreadCatalogConnectionEventSink;
|
||||
|
||||
interface ChatAppServerQueries {
|
||||
contextLease(): AppServerContextLease;
|
||||
appServerMetadataSnapshot(): SharedServerMetadata | null;
|
||||
refreshAppServerMetadata(): Promise<SharedServerMetadata | null>;
|
||||
refreshSkills(): Promise<SharedServerMetadata | null>;
|
||||
|
|
@ -122,7 +123,7 @@ export interface ChatSharedThreadSurface {
|
|||
}
|
||||
|
||||
export interface ChatPanelClientSurface {
|
||||
canServeAppServerContext(context: AppServerQueryContext): boolean;
|
||||
canServeAppServerContext(context: AppServerQueryContextIdentity): boolean;
|
||||
runWithAppServerClient<T>(operation: (client: AppServerClient) => Promise<T>): Promise<T>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { codexPanelAppServerInitializeParams } from "../../../app-server/connection/client-profile";
|
||||
import { ConnectionManager } from "../../../app-server/connection/connection-manager";
|
||||
import { isStaleAppServerSharedQueryContextError } from "../../../app-server/query/shared-queries";
|
||||
import { isStaleAppServerResourceContextError } from "../../../app-server/query/resource-store";
|
||||
import { createChatAppServerGateway, createChatCurrentAppServerGateway } from "../app-server/session-gateway";
|
||||
import { reconnectPanel } from "../application/connection/reconnect-actions";
|
||||
import { createLocalIdSource, type LocalIdSource } from "../application/local-id-source";
|
||||
|
|
@ -114,9 +114,10 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat
|
|||
const localItemIds = createLocalIdSource();
|
||||
const connection = createConnectionManager(environment);
|
||||
const currentClient = () => connection.currentClient();
|
||||
const resourceContext = () => environment.plugin.appServerQueries.contextLease().context;
|
||||
const currentAppServer = createChatCurrentAppServerGateway({
|
||||
codexPath: () => environment.plugin.settingsRef.settings.codexPath(),
|
||||
vaultPath: environment.plugin.settingsRef.vaultPath,
|
||||
codexPath: () => resourceContext().codexPath,
|
||||
vaultPath: resourceContext().vaultPath,
|
||||
currentClient,
|
||||
});
|
||||
const status = createSessionStatus(stateStore, localItemIds);
|
||||
|
|
@ -167,7 +168,7 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat
|
|||
} = connectionBundle;
|
||||
const ensureConnected = () => connectionActions.ensureConnected();
|
||||
const appServer = createChatAppServerGateway(currentAppServer, {
|
||||
vaultPath: environment.plugin.settingsRef.vaultPath,
|
||||
vaultPath: resourceContext().vaultPath,
|
||||
currentClient,
|
||||
connectedClient: async () => {
|
||||
await connectionActions.ensureConnected();
|
||||
|
|
@ -259,6 +260,7 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat
|
|||
const prepareAppServerContextChange = (): void => {
|
||||
connectionActions.invalidate();
|
||||
threadFoundation.invalidateThreadWork();
|
||||
threadLifecycle.rename.invalidate();
|
||||
threadLifecycle.restoration.invalidate();
|
||||
host.deferredTasks.clearDiagnostics();
|
||||
connectionBundle.invalidateConnectionScope();
|
||||
|
|
@ -302,7 +304,7 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat
|
|||
try {
|
||||
await connectionBundle.refreshSharedThreads();
|
||||
} catch (error) {
|
||||
if (isStaleAppServerSharedQueryContextError(error)) return;
|
||||
if (isStaleAppServerResourceContextError(error)) return;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
|
@ -355,9 +357,11 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat
|
|||
|
||||
function createConnectionManager(environment: ChatPanelEnvironment): ConnectionManager {
|
||||
return new ConnectionManager(
|
||||
() => environment.plugin.settingsRef.settings.codexPath(),
|
||||
environment.plugin.settingsRef.vaultPath,
|
||||
() => environment.plugin.appServerQueries.contextLease().context.codexPath,
|
||||
environment.plugin.appServerQueries.contextLease().context.vaultPath,
|
||||
codexPanelAppServerInitializeParams(),
|
||||
undefined,
|
||||
() => environment.plugin.appServerQueries.contextLease().generation,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import type { AppServerClient } from "../../../app-server/connection/client";
|
||||
import { type AppServerQueryContext, appServerQueryContextMatches, appServerQueryContextRawEquals } from "../../../app-server/query/keys";
|
||||
import {
|
||||
type AppServerQueryContextIdentity,
|
||||
appServerQueryContextIdentity,
|
||||
appServerQueryContextIdentityMatches,
|
||||
} from "../../../app-server/query/keys";
|
||||
import { pendingRequestCountsFromQueues } from "../../../domain/pending-requests/aggregate";
|
||||
import { threadMeaningfulTitle, threadWindowTitle } from "../../../domain/threads/title";
|
||||
import {
|
||||
|
|
@ -25,7 +29,7 @@ export class ChatPanelSession implements ChatPanelHandle {
|
|||
private readonly deferredTasks: ChatViewDeferredTasks;
|
||||
private readonly resumeWork = new ChatResumeWorkTracker();
|
||||
private readonly threadStreamScrollBinding: ChatThreadStreamScrollBinding = createChatThreadStreamScrollBinding();
|
||||
private observedAppServerContext: AppServerQueryContext;
|
||||
private observedAppServerContext: AppServerQueryContextIdentity;
|
||||
private appServerContextReconnectAttemptGeneration = 0;
|
||||
private appServerContextReplacementGeneration = 0;
|
||||
private pendingAppServerContextReplacement: { activeThreadId: string | null; generation: number } | null = null;
|
||||
|
|
@ -84,7 +88,7 @@ export class ChatPanelSession implements ChatPanelHandle {
|
|||
|
||||
refreshSettings(): void {
|
||||
const nextContext = this.currentAppServerContext();
|
||||
if (!appServerQueryContextRawEquals(this.observedAppServerContext, nextContext)) {
|
||||
if (!appServerQueryContextIdentityMatches(this.observedAppServerContext, nextContext)) {
|
||||
this.observedAppServerContext = nextContext;
|
||||
const replacement = this.pendingAppServerContextReplacement ?? this.captureAppServerContextReplacement(activeThreadId(this.state));
|
||||
void this.reconnectAfterAppServerContextChange(replacement);
|
||||
|
|
@ -142,14 +146,15 @@ export class ChatPanelSession implements ChatPanelHandle {
|
|||
return this.runtime.actions.refreshSharedThreads();
|
||||
}
|
||||
|
||||
canServeAppServerContext(context: AppServerQueryContext): boolean {
|
||||
canServeAppServerContext(context: AppServerQueryContextIdentity): boolean {
|
||||
const connectionContext = this.runtime.connection.manager.currentConnectionContext();
|
||||
return Boolean(
|
||||
connectionContext &&
|
||||
appServerQueryContextMatches(
|
||||
appServerQueryContextIdentityMatches(
|
||||
{
|
||||
codexPath: connectionContext.codexPath,
|
||||
vaultPath: connectionContext.cwd,
|
||||
generation: connectionContext.generation,
|
||||
},
|
||||
context,
|
||||
),
|
||||
|
|
@ -276,11 +281,8 @@ export class ChatPanelSession implements ChatPanelHandle {
|
|||
return this.environment.view.viewWindow() ?? window;
|
||||
}
|
||||
|
||||
private currentAppServerContext(): AppServerQueryContext {
|
||||
return {
|
||||
codexPath: this.environment.plugin.settingsRef.settings.codexPath(),
|
||||
vaultPath: this.environment.plugin.settingsRef.vaultPath,
|
||||
};
|
||||
private currentAppServerContext(): AppServerQueryContextIdentity {
|
||||
return appServerQueryContextIdentity(this.environment.plugin.appServerQueries.contextLease());
|
||||
}
|
||||
|
||||
private closeToolbarPanelOnOutsidePointer(event: PointerEvent): void {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Notice } from "obsidian";
|
|||
import { type AppServerQueryContext, appServerQueryContextRawEquals } from "../../app-server/query/keys";
|
||||
import type { ObservedResult } from "../../app-server/query/observed-result";
|
||||
import { observedInitialError, observedInitialLoading } from "../../app-server/query/observed-result";
|
||||
import { isStaleAppServerSharedQueryContextError } from "../../app-server/query/shared-queries";
|
||||
import { isStaleAppServerResourceContextError } from "../../app-server/query/resource-store";
|
||||
import type { ReasoningEffort } from "../../domain/catalog/metadata";
|
||||
import type { ArchiveExportSettings } from "../../domain/threads/archive-markdown";
|
||||
import type { Thread } from "../../domain/threads/model";
|
||||
|
|
@ -115,6 +115,7 @@ export class ThreadsViewSession {
|
|||
|
||||
close(): void {
|
||||
this.lifetime.dispose();
|
||||
this.titleService.invalidate();
|
||||
this.activeRefresh = null;
|
||||
this.renderTask.clear();
|
||||
this.unsubscribeThreads?.();
|
||||
|
|
@ -138,7 +139,7 @@ export class ThreadsViewSession {
|
|||
this.status = threads.length === 0 ? { kind: "empty", message: "No threads" } : { kind: "idle" };
|
||||
} catch (error) {
|
||||
if (!this.lifetime.isCurrent(lifetime)) return;
|
||||
if (isStaleAppServerSharedQueryContextError(error)) return;
|
||||
if (isStaleAppServerResourceContextError(error)) return;
|
||||
if (!this.currentThreadsSnapshot()) {
|
||||
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
|
|
@ -159,7 +160,7 @@ export class ThreadsViewSession {
|
|||
this.threadsLoaded = true;
|
||||
this.status = threads.length === 0 ? { kind: "empty", message: "No threads" } : { kind: "idle" };
|
||||
} catch (error) {
|
||||
if (!this.lifetime.isCurrent(lifetime) || isStaleAppServerSharedQueryContextError(error)) return;
|
||||
if (!this.lifetime.isCurrent(lifetime) || isStaleAppServerResourceContextError(error)) return;
|
||||
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
|
||||
} finally {
|
||||
this.finishRefresh(refresh);
|
||||
|
|
@ -172,6 +173,7 @@ export class ThreadsViewSession {
|
|||
|
||||
prepareAppServerContextChange(): void {
|
||||
this.operationGeneration += 1;
|
||||
this.titleService.invalidate();
|
||||
this.activeRefresh = null;
|
||||
this.renderTask.clear();
|
||||
this.threads = [];
|
||||
|
|
|
|||
|
|
@ -38,10 +38,16 @@ export function createThreadTitleTransport(options: {
|
|||
readCompletedTurnTranscriptSummariesPage(client, id, cursor, limit, sortDirection),
|
||||
}),
|
||||
),
|
||||
generateTitle: (context) =>
|
||||
generateThreadTitleWithCodex(options.codexPath(), options.vaultPath, context, {
|
||||
threadNamingModel: options.threadNamingModel(),
|
||||
threadNamingEffort: options.threadNamingEffort(),
|
||||
}),
|
||||
generateTitle: (context, signal) =>
|
||||
generateThreadTitleWithCodex(
|
||||
options.codexPath(),
|
||||
options.vaultPath,
|
||||
context,
|
||||
{
|
||||
threadNamingModel: options.threadNamingModel(),
|
||||
threadNamingEffort: options.threadNamingEffort(),
|
||||
},
|
||||
{ signal },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { AppServerQueryContext } from "../../../app-server/query/keys";
|
||||
import type { AppServerQueryContextIdentity } from "../../../app-server/query/keys";
|
||||
import type { ObservedResult, ObservedResultListener } from "../../../app-server/query/observed-result";
|
||||
import type { Thread } from "../../../domain/threads/model";
|
||||
|
||||
|
|
@ -6,18 +6,14 @@ type ThreadListObserver = ObservedResultListener<readonly Thread[]>;
|
|||
|
||||
interface ThreadCatalogStore {
|
||||
contextKey(): string;
|
||||
contextKeyFor(context: AppServerQueryContext): string;
|
||||
contextKeyFor(context: AppServerQueryContextIdentity): string;
|
||||
activeThreadsSnapshot(): readonly Thread[] | null;
|
||||
activeThreadsSnapshotFor(context: AppServerQueryContext): readonly Thread[] | null;
|
||||
archivedThreadsSnapshot(): readonly Thread[] | null;
|
||||
archivedThreadsSnapshotFor(context: AppServerQueryContext): readonly Thread[] | null;
|
||||
fetchAllActiveThreads(): Promise<readonly Thread[]>;
|
||||
hasMoreActiveThreads(): boolean;
|
||||
loadMoreActiveThreads(): Promise<readonly Thread[]>;
|
||||
refreshActiveThreads(): Promise<readonly Thread[]>;
|
||||
refreshActiveThreadsFor(context: AppServerQueryContext): Promise<readonly Thread[]>;
|
||||
refreshArchivedThreads(): Promise<readonly Thread[]>;
|
||||
refreshArchivedThreadsFor(context: AppServerQueryContext): Promise<readonly Thread[]>;
|
||||
observeActiveThreadsResult(observer: ThreadListObserver, options?: { emitCurrent?: boolean }): () => void;
|
||||
observeArchivedThreadsResult(observer: ThreadListObserver, options?: { emitCurrent?: boolean }): () => void;
|
||||
}
|
||||
|
|
@ -87,7 +83,7 @@ export interface ThreadCatalogEventSink {
|
|||
}
|
||||
|
||||
export interface ThreadCatalogConnectionEventSink {
|
||||
applyConnectionEvent(context: AppServerQueryContext, event: ThreadCatalogEvent): void;
|
||||
applyConnectionEvent(context: AppServerQueryContextIdentity, event: ThreadCatalogEvent): void;
|
||||
}
|
||||
|
||||
export interface ThreadCatalog
|
||||
|
|
@ -138,12 +134,8 @@ export function createThreadCatalog(options: ThreadCatalogOptions): ThreadCatalo
|
|||
apply,
|
||||
applyConnectionEvent: (context, event) => {
|
||||
const capturedContextKey = store.contextKeyFor(context);
|
||||
applyToFacts(
|
||||
threadCatalogEventStoreForContext(store, context),
|
||||
threadCatalogFactsForContext(factsByContext, capturedContextKey),
|
||||
event,
|
||||
capturedContextKey === store.contextKey(),
|
||||
);
|
||||
if (capturedContextKey !== store.contextKey()) return;
|
||||
applyToFacts(store, currentFacts(), event, true);
|
||||
},
|
||||
clear: () => {
|
||||
factsByContext.clear();
|
||||
|
|
@ -190,19 +182,10 @@ export function createThreadCatalog(options: ThreadCatalogOptions): ThreadCatalo
|
|||
};
|
||||
}
|
||||
|
||||
function threadCatalogEventStoreForContext(store: ThreadCatalogStore, context: AppServerQueryContext): ThreadCatalogEventStore {
|
||||
return {
|
||||
activeThreadsSnapshot: () => store.activeThreadsSnapshotFor(context),
|
||||
archivedThreadsSnapshot: () => store.archivedThreadsSnapshotFor(context),
|
||||
refreshActiveThreads: () => store.refreshActiveThreadsFor(context),
|
||||
refreshArchivedThreads: () => store.refreshArchivedThreadsFor(context),
|
||||
};
|
||||
}
|
||||
|
||||
type ThreadListKind = "active" | "archived";
|
||||
|
||||
function threadCatalogFactsForContext(factsByContext: Map<string, ThreadCatalogFacts>, contextKey: string): ThreadCatalogFacts {
|
||||
pruneInactiveSettledContexts(factsByContext, contextKey);
|
||||
pruneInactiveContexts(factsByContext, contextKey);
|
||||
const existing = factsByContext.get(contextKey);
|
||||
if (existing) {
|
||||
factsByContext.delete(contextKey);
|
||||
|
|
@ -219,18 +202,13 @@ function threadCatalogFactsForContext(factsByContext: Map<string, ThreadCatalogF
|
|||
return facts;
|
||||
}
|
||||
|
||||
function pruneInactiveSettledContexts(factsByContext: Map<string, ThreadCatalogFacts>, activeContextKey: string): void {
|
||||
for (const [contextKey, facts] of factsByContext) {
|
||||
function pruneInactiveContexts(factsByContext: Map<string, ThreadCatalogFacts>, activeContextKey: string): void {
|
||||
for (const contextKey of factsByContext.keys()) {
|
||||
if (contextKey === activeContextKey) continue;
|
||||
if (threadListFactsPending(facts.active) || threadListFactsPending(facts.archived)) continue;
|
||||
factsByContext.delete(contextKey);
|
||||
}
|
||||
}
|
||||
|
||||
function threadListFactsPending(facts: PendingThreadListFacts): boolean {
|
||||
return facts.upserts.size > 0 || facts.removals.size > 0;
|
||||
}
|
||||
|
||||
function applyThreadCatalogEvent(
|
||||
store: ThreadCatalogEventStore,
|
||||
facts: ThreadCatalogFacts,
|
||||
|
|
|
|||
|
|
@ -8,5 +8,5 @@ export interface ThreadOperationsTransport {
|
|||
|
||||
export interface ThreadTitleTransport {
|
||||
persistedContext(threadId: string): Promise<ThreadTitleContext | null>;
|
||||
generateTitle(context: ThreadTitleContext): Promise<string | null>;
|
||||
generateTitle(context: ThreadTitleContext, signal: AbortSignal): Promise<string | null>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,10 +10,11 @@ export interface ThreadTitleServiceHost {
|
|||
transport: ThreadTitleTransport;
|
||||
visibleContext?(threadId: string): ThreadTitleContext | null;
|
||||
visibleCompletedTurnContext?(turnId: string): ThreadTitleContext | null;
|
||||
generateThreadTitle?(context: ThreadTitleContext): Promise<string | null>;
|
||||
generateThreadTitle?(context: ThreadTitleContext, signal: AbortSignal): Promise<string | null>;
|
||||
}
|
||||
|
||||
export interface ThreadTitleService {
|
||||
invalidate(): void;
|
||||
generateTitle(threadId: string): Promise<string>;
|
||||
resolveContext(threadId: string): Promise<ThreadTitleContext | null>;
|
||||
completedTurnContext(turnId: string, completedTurnTranscriptSummary: TurnTranscriptSummary | null): ThreadTitleContext | null;
|
||||
|
|
@ -21,19 +22,26 @@ export interface ThreadTitleService {
|
|||
}
|
||||
|
||||
export function createThreadTitleService(host: ThreadTitleServiceHost): ThreadTitleService {
|
||||
let controller = new AbortController();
|
||||
|
||||
return {
|
||||
generateTitle: (threadId) => generateTitle(host, threadId),
|
||||
invalidate() {
|
||||
controller.abort();
|
||||
controller = new AbortController();
|
||||
},
|
||||
generateTitle: (threadId) => generateTitle(host, threadId, controller.signal),
|
||||
resolveContext: (threadId) => resolveThreadTitleContext(host, threadId),
|
||||
completedTurnContext: (turnId, completedTurnTranscriptSummary) => completedTurnContext(host, turnId, completedTurnTranscriptSummary),
|
||||
generate: (context) => generateTitleFromContext(host, context),
|
||||
generate: (context) => generateTitleFromContext(host, context, controller.signal),
|
||||
};
|
||||
}
|
||||
|
||||
async function generateTitle(host: ThreadTitleServiceHost, threadId: string): Promise<string> {
|
||||
async function generateTitle(host: ThreadTitleServiceHost, threadId: string, signal: AbortSignal): Promise<string> {
|
||||
const context = await resolveThreadTitleContext(host, threadId);
|
||||
throwIfTitleGenerationCancelled(signal);
|
||||
if (!context) throw new Error(THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE);
|
||||
|
||||
const title = await generateTitleFromContext(host, context);
|
||||
const title = await generateTitleFromContext(host, context, signal);
|
||||
if (!title) throw new Error("Codex did not return a usable thread title.");
|
||||
return title;
|
||||
}
|
||||
|
|
@ -59,6 +67,19 @@ function completedTurnContext(
|
|||
);
|
||||
}
|
||||
|
||||
async function generateTitleFromContext(host: ThreadTitleServiceHost, context: ThreadTitleContext): Promise<string | null> {
|
||||
return host.generateThreadTitle ? host.generateThreadTitle(context) : host.transport.generateTitle(context);
|
||||
async function generateTitleFromContext(
|
||||
host: ThreadTitleServiceHost,
|
||||
context: ThreadTitleContext,
|
||||
signal: AbortSignal,
|
||||
): Promise<string | null> {
|
||||
throwIfTitleGenerationCancelled(signal);
|
||||
const title = await (host.generateThreadTitle
|
||||
? host.generateThreadTitle(context, signal)
|
||||
: host.transport.generateTitle(context, signal));
|
||||
throwIfTitleGenerationCancelled(signal);
|
||||
return title;
|
||||
}
|
||||
|
||||
function throwIfTitleGenerationCancelled(signal: AbortSignal): void {
|
||||
if (signal.aborted) throw new Error("Thread title generation cancelled.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export default class CodexPanelPlugin extends Plugin {
|
|||
this.runtime.reset();
|
||||
this.vaultPath = getVaultPath(this.app);
|
||||
await this.loadSettings();
|
||||
this.runtime.initialize();
|
||||
|
||||
this.registerView(VIEW_TYPE_CODEX_PANEL, (leaf) => new CodexChatView(leaf, this.runtime.chatHost()));
|
||||
this.registerView(VIEW_TYPE_CODEX_TURN_DIFF, (leaf) => new CodexTurnDiffView(leaf));
|
||||
|
|
@ -75,8 +76,8 @@ export default class CodexPanelPlugin extends Plugin {
|
|||
registerSelectionRewriteCommand(
|
||||
this,
|
||||
createAppServerSelectionRewriteTransport({
|
||||
codexPath: () => this.settings.codexPath,
|
||||
cwd: this.vaultPath,
|
||||
codexPath: () => this.runtime.appServerContextLease().context.codexPath,
|
||||
cwd: this.runtime.appServerContextLease().context.vaultPath,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,9 +2,14 @@ import type { App } from "obsidian";
|
|||
import type { AppServerClient } from "./app-server/connection/client";
|
||||
import type { AppServerClientAccess, AppServerClientAccessOptions } from "./app-server/connection/client-access";
|
||||
import { withShortLivedAppServerClient } from "./app-server/connection/short-lived-client";
|
||||
import { AppServerQueryCache } from "./app-server/query/cache";
|
||||
import { type AppServerQueryContext, appServerQueryContextIsComplete, appServerQueryContextRawEquals } from "./app-server/query/keys";
|
||||
import { AppServerSharedQueries, StaleAppServerSharedQueryContextError } from "./app-server/query/shared-queries";
|
||||
import {
|
||||
type AppServerContextLease,
|
||||
type AppServerQueryContext,
|
||||
type AppServerQueryContextIdentity,
|
||||
appServerQueryContextIdentityMatches,
|
||||
appServerQueryContextIsComplete,
|
||||
} from "./app-server/query/keys";
|
||||
import { AppServerResourceStore, StaleAppServerResourceContextError } from "./app-server/query/resource-store";
|
||||
import { VIEW_TYPE_CODEX_THREADS, VIEW_TYPE_CODEX_TURN_DIFF } from "./constants";
|
||||
import { hasPendingRequests } from "./domain/pending-requests/aggregate";
|
||||
import type {
|
||||
|
|
@ -42,15 +47,11 @@ export interface CodexPanelRuntimeOptions {
|
|||
}
|
||||
|
||||
export class CodexPanelRuntime implements AppServerClientAccess {
|
||||
private readonly appServerQueries = new AppServerQueryCache({
|
||||
private readonly appServerResourceStore = new AppServerResourceStore({
|
||||
clientRunner: {
|
||||
runWithClient: (context, operation, options) => this.runWithAppServerClient(context, operation, options),
|
||||
},
|
||||
});
|
||||
private readonly appServerSharedQueries = new AppServerSharedQueries({
|
||||
cache: this.appServerQueries,
|
||||
context: () => this.appServerQueryContext(),
|
||||
});
|
||||
private readonly panels: WorkspacePanelCoordinator;
|
||||
private readonly threadCatalog: ThreadCatalog;
|
||||
private readonly threadNameMutations = createThreadNameMutationCoordinator();
|
||||
|
|
@ -64,18 +65,26 @@ export class CodexPanelRuntime implements AppServerClientAccess {
|
|||
},
|
||||
});
|
||||
this.threadCatalog = createThreadCatalog({
|
||||
store: this.appServerSharedQueries,
|
||||
store: this.appServerResourceStore,
|
||||
onEventApplied: (event) => {
|
||||
this.applyThreadCatalogSurfaceEvent(event);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
initialize(): void {
|
||||
this.appServerResourceStore.initialize(this.configuredAppServerContext());
|
||||
}
|
||||
|
||||
appServerContextLease(): AppServerContextLease {
|
||||
return this.appServerResourceStore.contextLease();
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.selectionRewriteController?.closeAll();
|
||||
this.selectionRewriteController = null;
|
||||
this.panels.reset();
|
||||
this.appServerQueries.clear();
|
||||
this.appServerResourceStore.reset();
|
||||
this.threadCatalog.clear();
|
||||
}
|
||||
|
||||
|
|
@ -138,7 +147,7 @@ export class CodexPanelRuntime implements AppServerClientAccess {
|
|||
this.refreshThreadsViewLiveState();
|
||||
},
|
||||
},
|
||||
appServerQueries: this.appServerSharedQueries,
|
||||
appServerQueries: this.appServerResourceStore,
|
||||
threadCatalog: this.threadCatalog,
|
||||
threadNameMutations: this.threadNameMutations,
|
||||
};
|
||||
|
|
@ -172,8 +181,8 @@ export class CodexPanelRuntime implements AppServerClientAccess {
|
|||
threadOperationsTransport: createThreadOperationsTransport(this),
|
||||
threadTitleTransport: createThreadTitleTransport({
|
||||
clientAccess: this,
|
||||
codexPath: () => this.options.settingsRef.settings.codexPath,
|
||||
vaultPath: this.options.settingsRef.vaultPath,
|
||||
codexPath: () => this.appServerResourceStore.contextLease().context.codexPath,
|
||||
vaultPath: this.appServerResourceStore.contextLease().context.vaultPath,
|
||||
threadNamingModel: () => this.options.settingsRef.settings.threadNamingModel,
|
||||
threadNamingEffort: () => this.options.settingsRef.settings.threadNamingEffort,
|
||||
}),
|
||||
|
|
@ -206,18 +215,10 @@ export class CodexPanelRuntime implements AppServerClientAccess {
|
|||
dynamicData: createSettingsAppServerDynamicData({
|
||||
vaultPath: this.options.settingsRef.vaultPath,
|
||||
clientAccess: this,
|
||||
appServerQueries: this.appServerSharedQueries,
|
||||
appServerQueries: this.appServerResourceStore,
|
||||
threadCatalog: this.threadCatalog,
|
||||
}),
|
||||
saveSettings: (settings) => this.options.saveSettings(settings),
|
||||
prepareAppServerContextChange: () => {
|
||||
this.selectionRewriteController?.closeAll();
|
||||
for (const view of this.panels.panelViews()) view.surface.prepareAppServerContextChange();
|
||||
for (const view of this.threadsViews()) view.prepareAppServerContextChange();
|
||||
},
|
||||
refreshOpenViews: () => {
|
||||
this.refreshOpenViews();
|
||||
},
|
||||
publishSettings: (settings) => this.publishSettings(settings),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -231,7 +232,24 @@ export class CodexPanelRuntime implements AppServerClientAccess {
|
|||
}
|
||||
|
||||
withClient<T>(operation: (client: AppServerClient) => Promise<T>, options: AppServerClientAccessOptions = {}): Promise<T> {
|
||||
return this.runWithAppServerClient(this.appServerQueryContext(), operation, options);
|
||||
return this.runWithAppServerClient(this.appServerResourceStore.contextIdentity(), operation, options);
|
||||
}
|
||||
|
||||
private async publishSettings(settings: CodexPanelSettings): Promise<{ appServerContextReplaced: boolean }> {
|
||||
const previousSettings = { ...this.options.settingsRef.settings };
|
||||
await this.options.saveSettings(settings);
|
||||
const appServerContextReplaced = previousSettings.codexPath !== settings.codexPath;
|
||||
if (appServerContextReplaced) this.prepareAppServerContextChange();
|
||||
Object.assign(this.options.settingsRef.settings, settings);
|
||||
if (appServerContextReplaced) this.appServerResourceStore.replaceContext(this.configuredAppServerContext());
|
||||
if (appServerContextReplaced || previousSettings.showToolbar !== settings.showToolbar) this.refreshOpenViews();
|
||||
return { appServerContextReplaced };
|
||||
}
|
||||
|
||||
private prepareAppServerContextChange(): void {
|
||||
this.selectionRewriteController?.closeAll();
|
||||
for (const view of this.panels.panelViews()) view.surface.prepareAppServerContextChange();
|
||||
for (const view of this.threadsViews()) view.prepareAppServerContextChange();
|
||||
}
|
||||
|
||||
private async openTurnDiff(state: TurnDiffViewState): Promise<void> {
|
||||
|
|
@ -309,7 +327,7 @@ export class CodexPanelRuntime implements AppServerClientAccess {
|
|||
}
|
||||
|
||||
private async runWithAppServerClient<T>(
|
||||
context: AppServerQueryContext,
|
||||
context: AppServerQueryContextIdentity,
|
||||
operation: (client: AppServerClient) => Promise<T>,
|
||||
options: AppServerClientAccessOptions = {},
|
||||
): Promise<T> {
|
||||
|
|
@ -317,14 +335,14 @@ export class CodexPanelRuntime implements AppServerClientAccess {
|
|||
throw new Error("Codex app-server query context is incomplete.");
|
||||
}
|
||||
const result = await this.runWithContextClient(context, operation, options);
|
||||
if (!appServerQueryContextRawEquals(this.appServerQueryContext(), context)) {
|
||||
throw new StaleAppServerSharedQueryContextError();
|
||||
if (!appServerQueryContextIdentityMatches(this.appServerResourceStore.contextIdentity(), context)) {
|
||||
throw new StaleAppServerResourceContextError();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private runWithContextClient<T>(
|
||||
context: AppServerQueryContext,
|
||||
context: AppServerQueryContextIdentity,
|
||||
operation: (client: AppServerClient) => Promise<T>,
|
||||
options: AppServerClientAccessOptions,
|
||||
): Promise<T> {
|
||||
|
|
@ -337,7 +355,7 @@ export class CodexPanelRuntime implements AppServerClientAccess {
|
|||
: withShortLivedAppServerClient(context.codexPath, context.vaultPath, operation, options);
|
||||
}
|
||||
|
||||
private connectedClientSurface(context: AppServerQueryContext): ChatPanelClientSurface | null {
|
||||
private connectedClientSurface(context: AppServerQueryContextIdentity): ChatPanelClientSurface | null {
|
||||
for (const view of this.panels.panelViews()) {
|
||||
const workspaceSurface: ChatWorkspacePanelSurface = view.surface;
|
||||
if (!workspaceSurface.openPanelSnapshot().connected) continue;
|
||||
|
|
@ -348,7 +366,7 @@ export class CodexPanelRuntime implements AppServerClientAccess {
|
|||
return null;
|
||||
}
|
||||
|
||||
private appServerQueryContext(): AppServerQueryContext {
|
||||
private configuredAppServerContext(): AppServerQueryContext {
|
||||
return {
|
||||
codexPath: this.options.settingsRef.settings.codexPath,
|
||||
vaultPath: this.options.settingsRef.vaultPath,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { AppServerClient } from "../app-server/connection/client";
|
||||
import type { AppServerClientAccess } from "../app-server/connection/client-access";
|
||||
import type { ObservedResultListener } from "../app-server/query/observed-result";
|
||||
import { isStaleAppServerSharedQueryContextError } from "../app-server/query/shared-queries";
|
||||
import { isStaleAppServerResourceContextError } from "../app-server/query/resource-store";
|
||||
import { listHookCatalog, setHookItemEnabled, trustHookItem } from "../app-server/services/catalog";
|
||||
import { deleteThread, restoreArchivedThread as restoreArchivedThreadOnAppServer } from "../app-server/services/threads";
|
||||
import type { ModelMetadata } from "../domain/catalog/metadata";
|
||||
|
|
@ -13,7 +13,6 @@ interface SettingsAppServerQueries {
|
|||
observeModelsResult(listener: ObservedResultListener<readonly ModelMetadata[]>, options?: { emitCurrent?: boolean }): () => void;
|
||||
fetchModels(): Promise<readonly ModelMetadata[]>;
|
||||
refreshModels(): Promise<readonly ModelMetadata[]>;
|
||||
notifyContextChanged(): void;
|
||||
}
|
||||
|
||||
type SettingsArchivedThreadCatalog = ThreadCatalogArchivedReader & ThreadCatalogEventSink;
|
||||
|
|
@ -55,9 +54,6 @@ export function createSettingsAppServerDynamicData(options: SettingsAppServerDyn
|
|||
options.threadCatalog.apply({ type: "thread-deleted", threadId });
|
||||
}
|
||||
},
|
||||
notifyContextChanged: () => {
|
||||
options.appServerQueries.notifyContextChanged();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -74,7 +70,7 @@ async function mapStaleContextError<T>(operation: () => Promise<T>): Promise<T>
|
|||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (isStaleAppServerSharedQueryContextError(error)) throw new StaleSettingsDynamicDataContextError();
|
||||
if (isStaleAppServerResourceContextError(error)) throw new StaleSettingsDynamicDataContextError();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ export interface SettingsDynamicDataAccess {
|
|||
setHookEnabled(hook: HookItem, enabled: boolean): Promise<void>;
|
||||
restoreArchivedThread(threadId: string, options?: SettingsDynamicDataMutationOptions): Promise<Thread>;
|
||||
deleteArchivedThread(threadId: string, options?: SettingsDynamicDataMutationOptions): Promise<void>;
|
||||
notifyContextChanged(): void;
|
||||
}
|
||||
|
||||
export class StaleSettingsDynamicDataContextError extends Error {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,5 @@ export interface SettingsDynamicSectionsHost {
|
|||
}
|
||||
|
||||
export interface CodexPanelSettingTabHost extends SettingsDynamicSectionsHost {
|
||||
saveSettings(settings: CodexPanelSettings): Promise<void>;
|
||||
prepareAppServerContextChange(): void;
|
||||
refreshOpenViews(): void;
|
||||
publishSettings(settings: CodexPanelSettings): Promise<{ appServerContextReplaced: boolean }>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,29 +195,17 @@ export class CodexPanelSettingTab extends PluginSettingTab {
|
|||
return true;
|
||||
},
|
||||
{
|
||||
beforePublish: () => {
|
||||
this.plugin.prepareAppServerContextChange();
|
||||
},
|
||||
onPublished: () => {
|
||||
this.dynamicSections.resetDynamicSectionContext();
|
||||
this.plugin.dynamicData.notifyContextChanged();
|
||||
this.plugin.refreshOpenViews();
|
||||
onPublished: ({ appServerContextReplaced }) => {
|
||||
if (appServerContextReplaced) this.dynamicSections.resetDynamicSectionContext();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private setShowToolbar(value: boolean): Promise<void> {
|
||||
return this.queueSettingsMutation(
|
||||
(settings) => {
|
||||
settings.showToolbar = value;
|
||||
},
|
||||
{
|
||||
onPublished: () => {
|
||||
this.plugin.refreshOpenViews();
|
||||
},
|
||||
},
|
||||
);
|
||||
return this.queueSettingsMutation((settings) => {
|
||||
settings.showToolbar = value;
|
||||
});
|
||||
}
|
||||
|
||||
private setSendShortcut(value: "enter" | "mod-enter"): Promise<void> {
|
||||
|
|
@ -300,21 +288,19 @@ export class CodexPanelSettingTab extends PluginSettingTab {
|
|||
|
||||
private queueSettingsMutation(
|
||||
mutate: (settings: CodexPanelSettings) => boolean | undefined,
|
||||
publication: { beforePublish?: () => void; onPublished?: () => void } = {},
|
||||
publication: { onPublished?: (result: { appServerContextReplaced: boolean }) => void } = {},
|
||||
): Promise<void> {
|
||||
const operation = this.settingsMutationQueue.then(async () => {
|
||||
const candidateSettings: CodexPanelSettings = { ...this.plugin.settings };
|
||||
if (mutate(candidateSettings) === false) return;
|
||||
try {
|
||||
await this.plugin.saveSettings(candidateSettings);
|
||||
const result = await this.plugin.publishSettings(candidateSettings);
|
||||
publication.onPublished?.(result);
|
||||
} catch (error) {
|
||||
this.settingsShellRevision += 1;
|
||||
new Notice(`Failed to save Codex Panel settings: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return;
|
||||
}
|
||||
publication.beforePublish?.();
|
||||
Object.assign(this.plugin.settings, candidateSettings);
|
||||
publication.onPublished?.();
|
||||
});
|
||||
const settledOperation = operation.catch(() => undefined);
|
||||
this.settingsMutationQueue = settledOperation;
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ describe("ConnectionManager", () => {
|
|||
transports[0]?.emitLine({ id: 1, result: { codexHome: "/tmp/codex-a" } });
|
||||
await expect(first).resolves.toMatchObject({ codexHome: "/tmp/codex-a" });
|
||||
expect(manager.currentClient()).toBeInstanceOf(AppServerClient);
|
||||
expect(manager.currentConnectionContext()).toEqual({ codexPath: "/bin/codex-a", cwd: "/vault" });
|
||||
expect(manager.currentConnectionContext()).toEqual({ codexPath: "/bin/codex-a", cwd: "/vault", generation: 0 });
|
||||
|
||||
codexPath = "/bin/codex-b";
|
||||
|
||||
|
|
@ -124,7 +124,7 @@ describe("ConnectionManager", () => {
|
|||
expect(transports).toHaveLength(2);
|
||||
expect(transports[0]?.running).toBe(false);
|
||||
expect(manager.currentClient()).toBeInstanceOf(AppServerClient);
|
||||
expect(manager.currentConnectionContext()).toEqual({ codexPath: "/bin/codex-b", cwd: "/vault" });
|
||||
expect(manager.currentConnectionContext()).toEqual({ codexPath: "/bin/codex-b", cwd: "/vault", generation: 0 });
|
||||
});
|
||||
|
||||
it("labels inbound events with the context captured when the connection was created", async () => {
|
||||
|
|
@ -149,7 +149,31 @@ describe("ConnectionManager", () => {
|
|||
|
||||
transport.emitLine(notification);
|
||||
|
||||
expect(onNotification).toHaveBeenCalledWith(notification, { codexPath: "/bin/codex-a", cwd: "/vault" });
|
||||
expect(onNotification).toHaveBeenCalledWith(notification, { codexPath: "/bin/codex-a", cwd: "/vault", generation: 0 });
|
||||
});
|
||||
|
||||
it("does not reuse a connected client across generations of the same raw context", async () => {
|
||||
let contextGeneration = 1;
|
||||
const transports: SilentTransport[] = [];
|
||||
const manager = new ConnectionManager(
|
||||
() => "/bin/codex",
|
||||
"/vault",
|
||||
TEST_INITIALIZE_PARAMS,
|
||||
testClientFactory({ onTransport: (transport) => transports.push(transport) }),
|
||||
() => contextGeneration,
|
||||
);
|
||||
const first = manager.connect(silentConnectionHandlers());
|
||||
transports[0]?.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } });
|
||||
await first;
|
||||
|
||||
contextGeneration = 3;
|
||||
|
||||
expect(manager.currentClient()).toBeNull();
|
||||
const second = manager.connect(silentConnectionHandlers());
|
||||
transports[1]?.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } });
|
||||
await second;
|
||||
expect(manager.currentConnectionContext()).toEqual({ codexPath: "/bin/codex", cwd: "/vault", generation: 3 });
|
||||
expect(transports[0]?.running).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects app-server exit during initialization without reporting a connected-client exit", async () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { CatalogModel, CatalogSkillMetadata } from "../../src/app-server/protocol/catalog";
|
||||
import { AppServerQueryCache } from "../../src/app-server/query/cache";
|
||||
import type { AppServerQueryContext } from "../../src/app-server/query/keys";
|
||||
import type { AppServerQueryContext, AppServerQueryContextIdentity } from "../../src/app-server/query/keys";
|
||||
import type { RateLimitSnapshot } from "../../src/domain/runtime/metrics";
|
||||
import type { RuntimePermissionProfileSummary } from "../../src/domain/runtime/permissions";
|
||||
|
||||
|
|
@ -144,6 +144,26 @@ describe("AppServerQueryCache", () => {
|
|||
expect(cache.activeThreadsSnapshot(oldContext)?.map((item) => item.id)).toEqual(["old-thread"]);
|
||||
});
|
||||
|
||||
it("does not join or publish thread refreshes across generations of the same raw context", async () => {
|
||||
const firstRefresh = deferred<readonly ReturnType<typeof thread>[]>();
|
||||
const fetchThreads = vi.fn((context: AppServerQueryContextIdentity) =>
|
||||
context.generation === 1 ? firstRefresh.promise : Promise.resolve([thread("current-a")]),
|
||||
);
|
||||
const cache = cacheWithThreads(fetchThreads);
|
||||
const firstA = cacheContext({ generation: 1 });
|
||||
const secondA = cacheContext({ generation: 3 });
|
||||
|
||||
const stale = cache.refreshActiveThreads(firstA);
|
||||
await flushMicrotasks();
|
||||
await expect(cache.refreshActiveThreads(secondA)).resolves.toEqual([thread("current-a")]);
|
||||
firstRefresh.resolve([thread("stale-a")]);
|
||||
await stale;
|
||||
|
||||
expect(fetchThreads).toHaveBeenCalledTimes(2);
|
||||
expect(cache.activeThreadsSnapshot(secondA)).toEqual([thread("current-a")]);
|
||||
expect(cache.activeThreadsSnapshot(firstA)).toEqual([thread("stale-a")]);
|
||||
});
|
||||
|
||||
it("stores in-flight thread list refreshes under the captured app-server cache context", async () => {
|
||||
const context = cacheContext({ codexPath: "codex-captured" });
|
||||
const capturedContext = { ...context };
|
||||
|
|
@ -537,16 +557,17 @@ describe("AppServerQueryCache", () => {
|
|||
});
|
||||
});
|
||||
|
||||
function cacheContext(overrides: Partial<AppServerQueryContext> = {}): AppServerQueryContext {
|
||||
function cacheContext(overrides: Partial<AppServerQueryContextIdentity> = {}): AppServerQueryContextIdentity {
|
||||
return {
|
||||
codexPath: "codex",
|
||||
vaultPath: "/vault",
|
||||
generation: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function cacheWithThreads(
|
||||
fetchThreads: (context: AppServerQueryContext, archived: boolean) => Promise<readonly ReturnType<typeof thread>[]>,
|
||||
fetchThreads: (context: AppServerQueryContextIdentity, archived: boolean) => Promise<readonly ReturnType<typeof thread>[]>,
|
||||
): AppServerQueryCache {
|
||||
return new AppServerQueryCache({
|
||||
clientRunner: {
|
||||
|
|
|
|||
139
tests/app-server/resource-store.test.ts
Normal file
139
tests/app-server/resource-store.test.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { AppServerQueryCache } from "../../src/app-server/query/cache";
|
||||
import type { AppServerQueryContextIdentity } from "../../src/app-server/query/keys";
|
||||
import type { ObservedResult } from "../../src/app-server/query/observed-result";
|
||||
import { AppServerResourceStore, StaleAppServerResourceContextError } from "../../src/app-server/query/resource-store";
|
||||
import type { Thread } from "../../src/domain/threads/model";
|
||||
import { deferred } from "../support/async";
|
||||
|
||||
describe("AppServerResourceStore", () => {
|
||||
it("requires explicit initialization and freezes the active context lease", () => {
|
||||
const store = new AppServerResourceStore({ cache: cacheWith() });
|
||||
|
||||
expect(() => store.contextLease()).toThrow("not initialized");
|
||||
const lease = store.initialize({ codexPath: "codex-a", vaultPath: "/vault" });
|
||||
|
||||
expect(lease).toEqual({ context: { codexPath: "codex-a", vaultPath: "/vault" }, generation: 1 });
|
||||
expect(Object.isFrozen(lease)).toBe(true);
|
||||
expect(Object.isFrozen(lease.context)).toBe(true);
|
||||
expect(() => store.initialize({ codexPath: "codex-b", vaultPath: "/vault" })).toThrow("already initialized");
|
||||
});
|
||||
|
||||
it("keeps the same lease and cache coordination when replacement receives the same raw context", async () => {
|
||||
const pending = deferred<readonly Thread[]>();
|
||||
const refreshActiveThreads = vi.fn(() => pending.promise);
|
||||
const cache = cacheWith({ refreshActiveThreads });
|
||||
const store = new AppServerResourceStore({ cache });
|
||||
const firstLease = store.initialize({ codexPath: "codex-a", vaultPath: "/vault" });
|
||||
|
||||
const refresh = store.refreshActiveThreads();
|
||||
const secondLease = store.replaceContext({ codexPath: "codex-a", vaultPath: "/vault" });
|
||||
pending.resolve([thread("same-lease")]);
|
||||
|
||||
expect(secondLease).toBe(firstLease);
|
||||
await expect(refresh).resolves.toEqual([thread("same-lease")]);
|
||||
expect(cache.release).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an A1 result after A to B to A replacement and never reuses a generation", async () => {
|
||||
const pending = deferred<readonly Thread[]>();
|
||||
const identities: AppServerQueryContextIdentity[] = [];
|
||||
const cache = cacheWith({
|
||||
refreshActiveThreads: vi.fn((identity: AppServerQueryContextIdentity) => {
|
||||
identities.push(identity);
|
||||
return pending.promise;
|
||||
}),
|
||||
});
|
||||
const store = new AppServerResourceStore({ cache });
|
||||
const firstA = store.initialize({ codexPath: "codex-a", vaultPath: "/vault" });
|
||||
|
||||
const refresh = store.refreshActiveThreads();
|
||||
const b = store.replaceContext({ codexPath: "codex-b", vaultPath: "/vault" });
|
||||
const secondA = store.replaceContext({ codexPath: "codex-a", vaultPath: "/vault" });
|
||||
pending.resolve([thread("stale-a")]);
|
||||
|
||||
await expect(refresh).rejects.toBeInstanceOf(StaleAppServerResourceContextError);
|
||||
expect([firstA.generation, b.generation, secondA.generation]).toEqual([1, 2, 3]);
|
||||
expect(identities).toEqual([{ codexPath: "codex-a", vaultPath: "/vault", generation: 1 }]);
|
||||
});
|
||||
|
||||
it("rebinds observers by generation and ignores events from an earlier identical raw context", () => {
|
||||
const listeners = new Map<number, (result: ObservedResult<readonly Thread[]>) => void>();
|
||||
const unsubscribers = new Map<number, ReturnType<typeof vi.fn>>();
|
||||
const store = new AppServerResourceStore({
|
||||
cache: cacheWith({
|
||||
observeActiveThreadsResult: (identity, listener) => {
|
||||
listeners.set(identity.generation, listener);
|
||||
const unsubscribe = vi.fn();
|
||||
unsubscribers.set(identity.generation, unsubscribe);
|
||||
return unsubscribe;
|
||||
},
|
||||
}),
|
||||
});
|
||||
store.initialize({ codexPath: "codex-a", vaultPath: "/vault" });
|
||||
const listener = vi.fn();
|
||||
store.observeActiveThreadsResult(listener);
|
||||
|
||||
store.replaceContext({ codexPath: "codex-b", vaultPath: "/vault" });
|
||||
store.replaceContext({ codexPath: "codex-a", vaultPath: "/vault" });
|
||||
listeners.get(1)?.(observedResult([thread("stale-a")]));
|
||||
listeners.get(3)?.(observedResult([thread("current-a")]));
|
||||
|
||||
expect(unsubscribers.get(1)).toHaveBeenCalledOnce();
|
||||
expect(unsubscribers.get(2)).toHaveBeenCalledOnce();
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
expect(listener).toHaveBeenCalledWith(expect.objectContaining({ value: [thread("current-a")] }));
|
||||
});
|
||||
|
||||
it("does not reuse a lease generation after reset", () => {
|
||||
const store = new AppServerResourceStore({ cache: cacheWith() });
|
||||
expect(store.initialize({ codexPath: "codex", vaultPath: "/vault" }).generation).toBe(1);
|
||||
|
||||
store.reset();
|
||||
|
||||
expect(store.initialize({ codexPath: "codex", vaultPath: "/vault" }).generation).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
function cacheWith(overrides: Partial<AppServerQueryCache> = {}): AppServerQueryCache {
|
||||
return {
|
||||
clear: vi.fn(),
|
||||
release: vi.fn(),
|
||||
activeThreadsSnapshot: vi.fn(() => null),
|
||||
archivedThreadsSnapshot: vi.fn(() => null),
|
||||
fetchAllActiveThreads: vi.fn(() => Promise.resolve([])),
|
||||
hasMoreActiveThreads: vi.fn(() => false),
|
||||
loadMoreActiveThreads: vi.fn(() => Promise.resolve([])),
|
||||
refreshActiveThreads: vi.fn(() => Promise.resolve([])),
|
||||
refreshArchivedThreads: vi.fn(() => Promise.resolve([])),
|
||||
observeActiveThreadsResult: vi.fn(() => () => undefined),
|
||||
observeArchivedThreadsResult: vi.fn(() => () => undefined),
|
||||
appServerMetadataSnapshot: vi.fn(() => null),
|
||||
refreshAppServerMetadata: vi.fn(() => Promise.resolve(null)),
|
||||
refreshSkills: vi.fn(() => Promise.resolve(null)),
|
||||
refreshRateLimits: vi.fn(() => Promise.resolve(null)),
|
||||
observeAppServerMetadataResult: vi.fn(() => () => undefined),
|
||||
modelsSnapshot: vi.fn(() => null),
|
||||
fetchModels: vi.fn(() => Promise.resolve([])),
|
||||
refreshModels: vi.fn(() => Promise.resolve([])),
|
||||
observeModelsResult: vi.fn(() => () => undefined),
|
||||
...overrides,
|
||||
} as unknown as AppServerQueryCache;
|
||||
}
|
||||
|
||||
function observedResult<T>(value: T): ObservedResult<T> {
|
||||
return { value, error: null, isFetching: false };
|
||||
}
|
||||
|
||||
function thread(id: string): Thread {
|
||||
return {
|
||||
id,
|
||||
preview: id,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
name: null,
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
};
|
||||
}
|
||||
|
|
@ -1,204 +0,0 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { AppServerQueryCache } from "../../src/app-server/query/cache";
|
||||
import type { ObservedResult } from "../../src/app-server/query/observed-result";
|
||||
import { AppServerSharedQueries, StaleAppServerSharedQueryContextError } from "../../src/app-server/query/shared-queries";
|
||||
import type { ModelMetadata } from "../../src/domain/catalog/metadata";
|
||||
import { createServerDiagnostics, diagnosticProbeOk, diagnosticsWithProbe } from "../../src/domain/server/diagnostics";
|
||||
import type { SharedServerMetadata } from "../../src/domain/server/metadata";
|
||||
import type { Thread } from "../../src/domain/threads/model";
|
||||
import { deferred } from "../support/async";
|
||||
|
||||
describe("AppServerSharedQueries", () => {
|
||||
it("rejects active thread refreshes when the app-server query context changes while loading", async () => {
|
||||
const context = { codexPath: "codex-a", vaultPath: "/vault" };
|
||||
const pending = deferred<readonly Thread[]>();
|
||||
const queries = new AppServerSharedQueries({
|
||||
cache: cacheWith({
|
||||
refreshActiveThreads: vi.fn(() => pending.promise),
|
||||
}),
|
||||
context: () => context,
|
||||
});
|
||||
|
||||
const refresh = queries.refreshActiveThreads();
|
||||
context.codexPath = "codex-b";
|
||||
pending.resolve([thread("stale")]);
|
||||
|
||||
await expect(refresh).rejects.toBeInstanceOf(StaleAppServerSharedQueryContextError);
|
||||
});
|
||||
|
||||
it("rejects metadata refreshes when the app-server query context changes while loading", async () => {
|
||||
const context = { codexPath: "codex-a", vaultPath: "/vault" };
|
||||
const pending = deferred<SharedServerMetadata | null>();
|
||||
const queries = new AppServerSharedQueries({
|
||||
cache: cacheWith({
|
||||
refreshAppServerMetadata: vi.fn(() => pending.promise),
|
||||
}),
|
||||
context: () => context,
|
||||
});
|
||||
|
||||
const refresh = queries.refreshAppServerMetadata();
|
||||
context.vaultPath = "/other-vault";
|
||||
pending.resolve(serverMetadata());
|
||||
|
||||
await expect(refresh).rejects.toBeInstanceOf(StaleAppServerSharedQueryContextError);
|
||||
});
|
||||
|
||||
it("rejects model fetches when the app-server query context changes while loading", async () => {
|
||||
const context = { codexPath: "codex-a", vaultPath: "/vault" };
|
||||
const pending = deferred<readonly ModelMetadata[]>();
|
||||
const queries = new AppServerSharedQueries({
|
||||
cache: cacheWith({
|
||||
fetchModels: vi.fn(() => pending.promise),
|
||||
}),
|
||||
context: () => context,
|
||||
});
|
||||
|
||||
const fetch = queries.fetchModels();
|
||||
context.codexPath = "codex-b";
|
||||
pending.resolve([model("stale-model")]);
|
||||
|
||||
await expect(fetch).rejects.toBeInstanceOf(StaleAppServerSharedQueryContextError);
|
||||
});
|
||||
|
||||
it("does not notify stale active thread observers after the app-server query context changes", async () => {
|
||||
const context = { codexPath: "codex-a", vaultPath: "/vault" };
|
||||
const listener = vi.fn();
|
||||
const queries = new AppServerSharedQueries({
|
||||
cache: cacheWith({
|
||||
observeActiveThreadsResult: (_queryContext, queryListener) => {
|
||||
observedThreadListener = queryListener;
|
||||
return () => undefined;
|
||||
},
|
||||
}),
|
||||
context: () => context,
|
||||
});
|
||||
let observedThreadListener!: (result: ObservedResult<readonly Thread[]>) => void;
|
||||
|
||||
queries.observeActiveThreadsResult(listener);
|
||||
context.codexPath = "codex-b";
|
||||
observedThreadListener(observedResult([thread("stale")]));
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resubscribes observers when the app-server query context changes", () => {
|
||||
const context = { codexPath: "codex-a", vaultPath: "/vault" };
|
||||
const listeners = new Map<string, (result: ObservedResult<readonly Thread[]>) => void>();
|
||||
const queries = new AppServerSharedQueries({
|
||||
cache: cacheWith({
|
||||
observeActiveThreadsResult: (queryContext, listener) => {
|
||||
listeners.set(queryContext.codexPath, listener);
|
||||
return () => undefined;
|
||||
},
|
||||
}),
|
||||
context: () => context,
|
||||
});
|
||||
const listener = vi.fn();
|
||||
queries.observeActiveThreadsResult(listener);
|
||||
|
||||
listeners.get("codex-a")?.(observedResult([thread("a")]));
|
||||
context.codexPath = "codex-b";
|
||||
queries.notifyContextChanged();
|
||||
listeners.get("codex-b")?.(observedResult([thread("b")]));
|
||||
|
||||
expect(listener).toHaveBeenLastCalledWith(expect.objectContaining({ value: [thread("b")] }));
|
||||
});
|
||||
|
||||
it("publishes metadata and model snapshots to shared query observers", () => {
|
||||
const metadata = serverMetadata();
|
||||
const models = [model("gpt-test")];
|
||||
const metadataListener = vi.fn();
|
||||
const modelListener = vi.fn();
|
||||
const queries = new AppServerSharedQueries({
|
||||
cache: cacheWith({
|
||||
appServerMetadataSnapshot: () => metadata,
|
||||
modelsSnapshot: () => models,
|
||||
observeAppServerMetadataResult: (_context, listener) => {
|
||||
metadataObserver = listener;
|
||||
return () => undefined;
|
||||
},
|
||||
observeModelsResult: (_context, listener) => {
|
||||
modelObserver = listener;
|
||||
return () => undefined;
|
||||
},
|
||||
}),
|
||||
context: () => ({ codexPath: "codex", vaultPath: "/vault" }),
|
||||
});
|
||||
let metadataObserver!: (result: ObservedResult<SharedServerMetadata>) => void;
|
||||
let modelObserver!: (result: ObservedResult<readonly ModelMetadata[]>) => void;
|
||||
|
||||
queries.observeAppServerMetadataResult(metadataListener);
|
||||
queries.observeModelsResult(modelListener);
|
||||
metadataObserver(observedResult(metadata));
|
||||
modelObserver(observedResult(models));
|
||||
|
||||
expect(queries.appServerMetadataSnapshot()).toEqual(metadata);
|
||||
expect(queries.modelsSnapshot()).toEqual(models);
|
||||
expect(metadataListener).toHaveBeenLastCalledWith(expect.objectContaining({ value: metadata }));
|
||||
expect(modelListener).toHaveBeenCalledWith(expect.objectContaining({ value: models }));
|
||||
});
|
||||
});
|
||||
|
||||
function cacheWith(overrides: Partial<AppServerQueryCache>): AppServerQueryCache {
|
||||
return {
|
||||
activeThreadsSnapshot: vi.fn(() => null),
|
||||
fetchActiveThreads: vi.fn(() => Promise.resolve([])),
|
||||
refreshActiveThreads: vi.fn(() => Promise.resolve([])),
|
||||
observeActiveThreadsResult: vi.fn(() => () => undefined),
|
||||
appServerMetadataSnapshot: vi.fn(() => null),
|
||||
refreshAppServerMetadata: vi.fn(() => Promise.resolve(null)),
|
||||
refreshSkills: vi.fn(() => Promise.resolve(null)),
|
||||
refreshRateLimits: vi.fn(() => Promise.resolve(null)),
|
||||
observeAppServerMetadataResult: vi.fn(() => () => undefined),
|
||||
modelsSnapshot: vi.fn(() => null),
|
||||
fetchModels: vi.fn(() => Promise.resolve([])),
|
||||
refreshModels: vi.fn(() => Promise.resolve([])),
|
||||
observeModelsResult: vi.fn(() => () => undefined),
|
||||
...overrides,
|
||||
} as unknown as AppServerQueryCache;
|
||||
}
|
||||
|
||||
function observedResult<T>(value: T): ObservedResult<T> {
|
||||
return { value, error: null, isFetching: false };
|
||||
}
|
||||
|
||||
function thread(id: string): Thread {
|
||||
return {
|
||||
id,
|
||||
preview: id,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
name: null,
|
||||
archived: false,
|
||||
provenance: { kind: "interactive" },
|
||||
};
|
||||
}
|
||||
|
||||
function model(modelId: string): ModelMetadata {
|
||||
return {
|
||||
id: modelId,
|
||||
model: modelId,
|
||||
displayName: modelId,
|
||||
description: "",
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: [],
|
||||
defaultReasoningEffort: null,
|
||||
inputModalities: [],
|
||||
serviceTiers: [],
|
||||
defaultServiceTier: null,
|
||||
isDefault: false,
|
||||
};
|
||||
}
|
||||
|
||||
function serverMetadata(overrides: Partial<SharedServerMetadata> = {}): SharedServerMetadata {
|
||||
const diagnostics = diagnosticsWithProbe(createServerDiagnostics(), diagnosticProbeOk("models", "0 models", 1));
|
||||
return {
|
||||
runtimeConfig: null,
|
||||
availableSkills: [],
|
||||
availablePermissionProfiles: [],
|
||||
rateLimit: null,
|
||||
serverDiagnostics: diagnostics,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
@ -23,7 +23,7 @@ import { chatStateFixture, chatStateWith } from "../../support/state";
|
|||
import { chatStateThreadStreamItems, withChatStateThreadStreamItems } from "../../support/thread-stream";
|
||||
|
||||
type ThreadStartedNotification = Extract<ServerNotification, { method: "thread/started" }>;
|
||||
const TEST_APP_SERVER_CONTEXT = { codexPath: "codex", vaultPath: "/vault" } as const;
|
||||
const TEST_APP_SERVER_CONTEXT = { codexPath: "codex", vaultPath: "/vault", generation: 1 } as const;
|
||||
|
||||
type TestChatInboundHandler = Omit<ChatInboundHandler, "handleNotification"> & {
|
||||
handleNotification(notification: ServerNotification): void;
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ function createActionsHarness({ connected = false } = {}) {
|
|||
configuredCommand: () => "codex",
|
||||
refreshLiveState: vi.fn(),
|
||||
isStaleConnectionError: () => false,
|
||||
isStaleSharedQueryError: () => false,
|
||||
isStaleResourceContextError: () => false,
|
||||
notifyConnectionFailed: vi.fn(),
|
||||
};
|
||||
return {
|
||||
|
|
@ -160,15 +160,15 @@ describe("ChatConnectionActions", () => {
|
|||
expect(refreshAppServerMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores stale shared query failures while refreshing active threads", async () => {
|
||||
it("ignores stale resource context failures while refreshing active threads", async () => {
|
||||
const { actions, host } = createActionsHarness({ connected: true });
|
||||
const error = new Error("stale");
|
||||
vi.mocked(host.refreshSharedThreads).mockRejectedValueOnce(error);
|
||||
host.isStaleSharedQueryError = vi.fn((candidate) => candidate === error);
|
||||
host.isStaleResourceContextError = vi.fn((candidate) => candidate === error);
|
||||
|
||||
await actions.refreshActiveThreads();
|
||||
|
||||
expect(host.isStaleSharedQueryError).toHaveBeenCalledWith(error);
|
||||
expect(host.isStaleResourceContextError).toHaveBeenCalledWith(error);
|
||||
expect(host.addSystemMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ describe("server metadata actions", () => {
|
|||
stateStore,
|
||||
...metadataCacheHost(),
|
||||
refreshAppServerMetadata: vi.fn().mockResolvedValue(metadata),
|
||||
isStaleSharedQueryError: () => false,
|
||||
isStaleResourceContextError: () => false,
|
||||
});
|
||||
|
||||
await actions.refreshAppServerMetadata();
|
||||
|
|
@ -56,7 +56,7 @@ describe("server metadata actions", () => {
|
|||
stateStore,
|
||||
...metadataCacheHost(),
|
||||
refreshAppServerMetadata: vi.fn().mockResolvedValue(metadata),
|
||||
isStaleSharedQueryError: () => false,
|
||||
isStaleResourceContextError: () => false,
|
||||
});
|
||||
|
||||
await actions.refreshAppServerMetadata();
|
||||
|
|
@ -75,7 +75,7 @@ describe("server metadata actions", () => {
|
|||
stateStore,
|
||||
...metadataCacheHost(cache),
|
||||
refreshAppServerMetadata: vi.fn().mockResolvedValue(null),
|
||||
isStaleSharedQueryError: () => false,
|
||||
isStaleResourceContextError: () => false,
|
||||
});
|
||||
|
||||
await actions.applyAppServerResourceEvent({
|
||||
|
|
@ -96,7 +96,7 @@ describe("server metadata actions", () => {
|
|||
stateStore,
|
||||
...metadataCacheHost(),
|
||||
refreshAppServerMetadata: vi.fn().mockRejectedValue(stale),
|
||||
isStaleSharedQueryError: (error) => error === stale,
|
||||
isStaleResourceContextError: (error) => error === stale,
|
||||
});
|
||||
|
||||
await expect(actions.refreshAppServerMetadata()).resolves.toBeNull();
|
||||
|
|
@ -112,7 +112,7 @@ describe("server metadata actions", () => {
|
|||
stateStore,
|
||||
...metadataCacheHost(),
|
||||
refreshSkills: vi.fn().mockRejectedValue(stale),
|
||||
isStaleSharedQueryError: (error) => error === stale,
|
||||
isStaleResourceContextError: (error) => error === stale,
|
||||
});
|
||||
|
||||
await actions.applyAppServerResourceEvent({ type: "skills-changed" });
|
||||
|
|
@ -176,7 +176,7 @@ describe("server diagnostics actions", () => {
|
|||
cache.current = refreshedMetadata;
|
||||
return refreshedMetadata;
|
||||
}),
|
||||
isStaleSharedQueryError: () => false,
|
||||
isStaleResourceContextError: () => false,
|
||||
});
|
||||
const readServerDiagnostics = vi.fn().mockResolvedValue(serverDiagnosticsSnapshot());
|
||||
const diagnostics = createServerDiagnosticsActions({
|
||||
|
|
@ -273,7 +273,7 @@ describe("server diagnostics actions", () => {
|
|||
stateStore,
|
||||
...metadataCache,
|
||||
refreshAppServerMetadata: vi.fn().mockResolvedValue(null),
|
||||
isStaleSharedQueryError: () => false,
|
||||
isStaleResourceContextError: () => false,
|
||||
});
|
||||
const diagnostics = createServerDiagnosticsActions({
|
||||
stateStore,
|
||||
|
|
@ -438,14 +438,14 @@ function metadataCacheHost(cache: { current: SharedServerMetadata | null } = { c
|
|||
refreshAppServerMetadata: () => Promise<SharedServerMetadata | null>;
|
||||
refreshSkills: () => Promise<SharedServerMetadata | null>;
|
||||
refreshRateLimits: () => Promise<SharedServerMetadata | null>;
|
||||
isStaleSharedQueryError: (error: unknown) => boolean;
|
||||
isStaleResourceContextError: (error: unknown) => boolean;
|
||||
} {
|
||||
return {
|
||||
appServerMetadataSnapshot: () => cache.current,
|
||||
refreshAppServerMetadata: vi.fn().mockResolvedValue(null),
|
||||
refreshSkills: vi.fn().mockResolvedValue(null),
|
||||
refreshRateLimits: vi.fn().mockResolvedValue(null),
|
||||
isStaleSharedQueryError: () => false,
|
||||
isStaleResourceContextError: () => false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,10 +48,13 @@ describe("AutoTitleCoordinator", () => {
|
|||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(generateThreadTitle).toHaveBeenCalledWith({
|
||||
userRequest: "Visible streamed request.",
|
||||
assistantResponse: "Visible streamed response.",
|
||||
});
|
||||
expect(generateThreadTitle).toHaveBeenCalledWith(
|
||||
{
|
||||
userRequest: "Visible streamed request.",
|
||||
assistantResponse: "Visible streamed response.",
|
||||
},
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(renameThreadRequest).toHaveBeenCalledWith({ threadId: "thread", name: "Visible context title" });
|
||||
});
|
||||
|
||||
|
|
@ -81,10 +84,13 @@ describe("AutoTitleCoordinator", () => {
|
|||
coordinator.maybeAutoTitleThread("thread", "turn", null);
|
||||
await flushPromises();
|
||||
|
||||
expect(generateThreadTitle).toHaveBeenCalledWith({
|
||||
userRequest: "Please diagnose auto naming.",
|
||||
assistantResponse: "I found the regression.",
|
||||
});
|
||||
expect(generateThreadTitle).toHaveBeenCalledWith(
|
||||
{
|
||||
userRequest: "Please diagnose auto naming.",
|
||||
assistantResponse: "I found the regression.",
|
||||
},
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(renameThreadRequest).toHaveBeenCalledWith({ threadId: "thread", name: "Visible context title" });
|
||||
});
|
||||
|
||||
|
|
@ -142,10 +148,38 @@ describe("AutoTitleCoordinator", () => {
|
|||
expect(persistedName).toBe("Manual title");
|
||||
expect(stateStore.getState().threadList.listedThreads[0]?.name).toBe("Manual title");
|
||||
});
|
||||
|
||||
it("retries after A→B→A invalidation without publishing the old title", async () => {
|
||||
const oldTitle = deferred<string | null>();
|
||||
const renameThreadRequest = vi.fn().mockResolvedValue({});
|
||||
const generateThreadTitle = vi.fn().mockReturnValueOnce(oldTitle.promise).mockResolvedValueOnce("Fresh title");
|
||||
const { coordinator } = coordinatorFixture({
|
||||
currentClient: () => fakeClient({ renameThreadRequest }),
|
||||
generateThreadTitle,
|
||||
});
|
||||
|
||||
coordinator.maybeAutoTitleThread("thread", "turn-a", { userText: "Old request", assistantText: "Old response" });
|
||||
await flushPromises();
|
||||
coordinator.invalidate();
|
||||
coordinator.invalidate();
|
||||
coordinator.maybeAutoTitleThread("thread", "turn-a2", { userText: "Fresh request", assistantText: "Fresh response" });
|
||||
await flushPromises();
|
||||
|
||||
expect(renameThreadRequest).toHaveBeenCalledOnce();
|
||||
expect(renameThreadRequest).toHaveBeenCalledWith({ threadId: "thread", name: "Fresh title" });
|
||||
|
||||
oldTitle.resolve("Stale title");
|
||||
await flushPromises();
|
||||
|
||||
expect(renameThreadRequest).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
function coordinatorFixture(
|
||||
overrides: { currentClient?: () => AppServerClient; generateThreadTitle?: (context: ThreadTitleContext) => Promise<string | null> } = {},
|
||||
overrides: {
|
||||
currentClient?: () => AppServerClient;
|
||||
generateThreadTitle?: (context: ThreadTitleContext, signal: AbortSignal) => Promise<string | null>;
|
||||
} = {},
|
||||
): AutoTitleCoordinatorHost & {
|
||||
coordinator: AutoTitleCoordinator;
|
||||
notifyThreadRenamed: ReturnType<typeof vi.fn>;
|
||||
|
|
|
|||
|
|
@ -153,6 +153,26 @@ describe("ThreadRenameEditorActions", () => {
|
|||
|
||||
expect(actions.editState("thread")).toEqual({ draft: "New generated title", generating: false });
|
||||
});
|
||||
|
||||
it("does not publish or report title work invalidated by a context replacement", async () => {
|
||||
const oldTitle = deferred<string>();
|
||||
const generateThreadTitle = vi.fn().mockReturnValueOnce(oldTitle.promise).mockResolvedValueOnce("Fresh title");
|
||||
const addSystemMessage = vi.fn();
|
||||
const { actions } = actionsFixture({ generateThreadTitle, addSystemMessage });
|
||||
|
||||
actions.start("thread");
|
||||
const staleAutoName = actions.autoNameDraft("thread");
|
||||
await flushPromises();
|
||||
actions.invalidate();
|
||||
|
||||
actions.start("thread");
|
||||
await actions.autoNameDraft("thread");
|
||||
oldTitle.resolve("Stale title");
|
||||
await staleAutoName;
|
||||
|
||||
expect(actions.editState("thread")).toEqual({ draft: "Fresh title", generating: false });
|
||||
expect(addSystemMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function actionsFixture(
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { StaleAppServerSharedQueryContextError } from "../../../../src/app-server/query/shared-queries";
|
||||
import { StaleAppServerResourceContextError } from "../../../../src/app-server/query/resource-store";
|
||||
import type { ModelMetadata } from "../../../../src/domain/catalog/metadata";
|
||||
import type { Thread } from "../../../../src/domain/threads/model";
|
||||
import { activeThreadId } from "../../../../src/features/chat/application/state/root-reducer";
|
||||
|
|
@ -46,6 +46,16 @@ describe("ChatPanelSessionRuntime actions", () => {
|
|||
expect(invalidateHistory).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("clears rename work before replacing the app-server context", () => {
|
||||
const { runtime, stateStore } = sessionRuntimeFixture();
|
||||
stateStore.dispatch({ type: "thread-list/applied", threads: [threadFixture({ id: "thread-1" })] });
|
||||
stateStore.dispatch({ type: "ui/rename-started", threadId: "thread-1", draft: "Draft" });
|
||||
|
||||
runtime.actions.prepareAppServerContextChange();
|
||||
|
||||
expect(stateStore.getState().ui.rename).toEqual({ kind: "idle" });
|
||||
});
|
||||
|
||||
it("refreshes shared threads inside the runtime connection bundle", async () => {
|
||||
const thread = threadFixture({ id: "thread-1", preview: "From catalog" });
|
||||
const refresh = vi.fn().mockResolvedValue([thread]);
|
||||
|
|
@ -66,7 +76,7 @@ describe("ChatPanelSessionRuntime actions", () => {
|
|||
});
|
||||
|
||||
it("treats stale shared thread refreshes as runtime-local no-ops", async () => {
|
||||
const refresh = vi.fn().mockRejectedValue(new StaleAppServerSharedQueryContextError());
|
||||
const refresh = vi.fn().mockRejectedValue(new StaleAppServerResourceContextError());
|
||||
const { runtime, stateStore } = sessionRuntimeFixture({
|
||||
environment: {
|
||||
plugin: {
|
||||
|
|
@ -377,6 +387,7 @@ describe("ChatPanelSessionRuntime actions", () => {
|
|||
overrides: Partial<ChatPanelEnvironment["plugin"]["appServerQueries"]> = {},
|
||||
): ChatPanelEnvironment["plugin"]["appServerQueries"] {
|
||||
return {
|
||||
contextLease: vi.fn(() => ({ context: { codexPath: "codex", vaultPath: "/vault" }, generation: 1 })),
|
||||
appServerMetadataSnapshot: vi.fn(() => null),
|
||||
refreshAppServerMetadata: vi.fn().mockResolvedValue(null),
|
||||
refreshSkills: vi.fn().mockResolvedValue(null),
|
||||
|
|
|
|||
|
|
@ -1406,6 +1406,7 @@ function chatHost(overrides: ChatHostFixtureOverrides = {}): TestCodexChatHost {
|
|||
openSideChat: overrides.openSideChat ?? vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
appServerQueries: {
|
||||
contextLease: () => ({ context: { codexPath: settings.codexPath, vaultPath }, generation: 1 }),
|
||||
appServerMetadataSnapshot: overrides.appServerMetadataSnapshot ?? vi.fn(() => metadata),
|
||||
refreshAppServerMetadata:
|
||||
overrides.refreshAppServerMetadata ??
|
||||
|
|
|
|||
|
|
@ -569,7 +569,9 @@ describe("CodexThreadsView", () => {
|
|||
await waitForAsyncWork(() => {
|
||||
expect(namingMock.generateThreadTitleWithCodex).toHaveBeenCalledOnce();
|
||||
});
|
||||
const generationSignal = namingMock.generateThreadTitleWithCodex.mock.calls[0]?.[4]?.signal as AbortSignal | undefined;
|
||||
await view.onClose();
|
||||
expect(generationSignal?.aborted).toBe(true);
|
||||
generatedTitle.resolve("Late title");
|
||||
for (let index = 0; index < 10; index += 1) await Promise.resolve();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { describe, expect, it, type Mock, vi } from "vitest";
|
||||
|
||||
import { AppServerQueryCache } from "../../../../src/app-server/query/cache";
|
||||
import { type AppServerQueryContext, appServerQueryContextKey } from "../../../../src/app-server/query/keys";
|
||||
import type { AppServerQueryContext, AppServerQueryContextIdentity } from "../../../../src/app-server/query/keys";
|
||||
import type { ObservedResult, ObservedResultListener } from "../../../../src/app-server/query/observed-result";
|
||||
import { AppServerSharedQueries } from "../../../../src/app-server/query/shared-queries";
|
||||
import { AppServerResourceStore } from "../../../../src/app-server/query/resource-store";
|
||||
import type { Thread } from "../../../../src/domain/threads/model";
|
||||
import {
|
||||
createThreadCatalog,
|
||||
|
|
@ -180,109 +180,29 @@ describe("ThreadCatalog", () => {
|
|||
expect(catalog.activeSnapshot()).toEqual([thread("other")]);
|
||||
});
|
||||
|
||||
it("scopes pending lifecycle facts by app-server query context", () => {
|
||||
const context = { codexPath: "codex-a", vaultPath: "/vault" };
|
||||
const { catalog } = catalogFixture({ context: () => context });
|
||||
it("starts a fresh lifecycle overlay for every resource lease", () => {
|
||||
const { catalog, replaceContext } = catalogFixture({ context: () => ({ codexPath: "codex-a", vaultPath: "/vault" }) });
|
||||
catalog.apply({ type: "thread-started", thread: thread("started-a1") });
|
||||
expect(catalog.activeSnapshot()).toEqual([thread("started-a1")]);
|
||||
|
||||
catalog.apply({ type: "thread-started", thread: thread("started-a") });
|
||||
expect(catalog.activeSnapshot()).toEqual([thread("started-a")]);
|
||||
|
||||
context.codexPath = "codex-b";
|
||||
replaceContext({ codexPath: "codex-b", vaultPath: "/vault" });
|
||||
expect(catalog.activeSnapshot()).toBeNull();
|
||||
receiveActive(catalog, [thread("thread-b")]);
|
||||
expect(catalog.activeSnapshot()).toEqual([thread("thread-b")]);
|
||||
|
||||
context.codexPath = "codex-a";
|
||||
expect(catalog.activeSnapshot()).toEqual([thread("started-a")]);
|
||||
});
|
||||
|
||||
it("applies a connection event to its captured context after the current context changes", () => {
|
||||
const context = { codexPath: "codex-a", vaultPath: "/vault" };
|
||||
const { catalog } = catalogFixture({ context: () => context });
|
||||
const connectionContext = { ...context };
|
||||
|
||||
context.codexPath = "codex-b";
|
||||
catalog.applyConnectionEvent(connectionContext, { type: "thread-started", thread: thread("started-a") });
|
||||
replaceContext({ codexPath: "codex-a", vaultPath: "/vault" });
|
||||
|
||||
expect(catalog.activeSnapshot()).toBeNull();
|
||||
context.codexPath = "codex-a";
|
||||
expect(catalog.activeSnapshot()).toEqual([thread("started-a")]);
|
||||
});
|
||||
|
||||
it("reads captured-context snapshots when applying connection events", async () => {
|
||||
const context = { codexPath: "codex-a", vaultPath: "/vault" };
|
||||
const connectionContext = { ...context };
|
||||
const { catalog } = catalogFixture({
|
||||
context: () => context,
|
||||
fetchThreads: (source) => Promise.resolve(source.codexPath === "codex-a" ? [thread("thread-a")] : []),
|
||||
it("does not publish connection events captured by an earlier lease", () => {
|
||||
const { catalog, contextIdentity, replaceContext } = catalogFixture({
|
||||
context: () => ({ codexPath: "codex-a", vaultPath: "/vault" }),
|
||||
});
|
||||
await catalog.refreshActive();
|
||||
const sourceA1 = contextIdentity();
|
||||
|
||||
context.codexPath = "codex-b";
|
||||
catalog.applyConnectionEvent(connectionContext, { type: "thread-renamed", threadId: "thread-a", name: "Renamed A" });
|
||||
replaceContext({ codexPath: "codex-b", vaultPath: "/vault" });
|
||||
catalog.applyConnectionEvent(sourceA1, { type: "thread-started", thread: thread("stale-a1") });
|
||||
replaceContext({ codexPath: "codex-a", vaultPath: "/vault" });
|
||||
|
||||
expect(catalog.activeSnapshot()).toBeNull();
|
||||
context.codexPath = "codex-a";
|
||||
expect(catalog.activeSnapshot()).toEqual([thread("thread-a", false, { name: "Renamed A" })]);
|
||||
});
|
||||
|
||||
it("clears pending lifecycle facts across every app-server query context", () => {
|
||||
const context = { codexPath: "codex-a", vaultPath: "/vault" };
|
||||
const { catalog, cache } = catalogFixture({ context: () => context });
|
||||
catalog.apply({ type: "thread-started", thread: thread("started-a") });
|
||||
context.codexPath = "codex-b";
|
||||
catalog.apply({ type: "thread-started", thread: thread("started-b") });
|
||||
|
||||
cache.clear();
|
||||
catalog.clear();
|
||||
|
||||
expect(catalog.activeSnapshot()).toBeNull();
|
||||
context.codexPath = "codex-a";
|
||||
expect(catalog.activeSnapshot()).toBeNull();
|
||||
});
|
||||
|
||||
it("retains unacknowledged lifecycle facts across connection contexts", () => {
|
||||
const context = { codexPath: "codex-a", vaultPath: "/vault" };
|
||||
const { catalog } = catalogFixture({ context: () => context });
|
||||
catalog.apply({ type: "thread-started", thread: thread("started-a") });
|
||||
|
||||
for (const suffix of ["b", "c", "d", "e"]) {
|
||||
context.codexPath = `codex-${suffix}`;
|
||||
catalog.activeSnapshot();
|
||||
}
|
||||
|
||||
context.codexPath = "codex-a";
|
||||
receiveActive(catalog, [thread("server-a")]);
|
||||
|
||||
expect(catalog.activeSnapshot()).toEqual([thread("started-a"), thread("server-a")]);
|
||||
});
|
||||
|
||||
it("prunes inactive lifecycle facts without discarding store snapshots", () => {
|
||||
const context = { codexPath: "codex-a", vaultPath: "/vault" };
|
||||
const { catalog } = catalogFixture({ context: () => context });
|
||||
receiveActive(catalog, [thread("server-a")]);
|
||||
|
||||
context.codexPath = "codex-b";
|
||||
expect(catalog.activeSnapshot()).toBeNull();
|
||||
context.codexPath = "codex-a";
|
||||
|
||||
expect(catalog.activeSnapshot()).toEqual([thread("server-a")]);
|
||||
});
|
||||
|
||||
it("prunes revisited contexts after pending lifecycle facts settle", () => {
|
||||
const context = { codexPath: "codex-a", vaultPath: "/vault" };
|
||||
const { catalog } = catalogFixture({ context: () => context });
|
||||
catalog.apply({ type: "thread-started", thread: thread("started-a") });
|
||||
context.codexPath = "codex-b";
|
||||
catalog.apply({ type: "thread-started", thread: thread("started-b") });
|
||||
|
||||
context.codexPath = "codex-a";
|
||||
receiveActive(catalog, [thread("started-a")]);
|
||||
context.codexPath = "codex-b";
|
||||
receiveActive(catalog, [thread("started-b")]);
|
||||
context.codexPath = "codex-a";
|
||||
|
||||
expect(catalog.activeSnapshot()).toEqual([thread("started-a")]);
|
||||
});
|
||||
|
||||
it("preserves raw query status when lifecycle overlays publish", async () => {
|
||||
|
|
@ -634,11 +554,9 @@ function catalogFixture(
|
|||
) {
|
||||
const cache = cacheWithThreads(options.fetchThreads ?? (() => Promise.resolve([])));
|
||||
const context = options.context ?? (() => ({ codexPath: "codex", vaultPath: "/vault" }));
|
||||
const queries = new AppServerSharedQueries({
|
||||
cache,
|
||||
context,
|
||||
});
|
||||
const store = new TestThreadCatalogStore(cache, queries, context);
|
||||
const queries = new AppServerResourceStore({ cache });
|
||||
queries.initialize(context());
|
||||
const store = new TestThreadCatalogStore(queries);
|
||||
const catalog = createThreadCatalog(
|
||||
options.onEventApplied
|
||||
? {
|
||||
|
|
@ -650,7 +568,12 @@ function catalogFixture(
|
|||
catalogStores.set(catalog, store);
|
||||
catalog.observeActive(() => undefined);
|
||||
catalog.observeArchived(() => undefined);
|
||||
return { cache, catalog };
|
||||
return {
|
||||
cache,
|
||||
catalog,
|
||||
contextIdentity: () => queries.contextIdentity(),
|
||||
replaceContext: (nextContext: AppServerQueryContext) => queries.replaceContext(nextContext),
|
||||
};
|
||||
}
|
||||
|
||||
class TestThreadCatalogStore {
|
||||
|
|
@ -659,36 +582,24 @@ class TestThreadCatalogStore {
|
|||
private readonly activeObservers = new Set<ObservedResultListener<readonly Thread[]>>();
|
||||
private readonly archivedObservers = new Set<ObservedResultListener<readonly Thread[]>>();
|
||||
|
||||
constructor(
|
||||
private readonly cache: AppServerQueryCache,
|
||||
private readonly queries: AppServerSharedQueries,
|
||||
private readonly currentContext: () => AppServerQueryContext,
|
||||
) {}
|
||||
constructor(private readonly queries: AppServerResourceStore) {}
|
||||
|
||||
contextKey(): string {
|
||||
return appServerQueryContextKey(this.currentContext());
|
||||
return this.queries.contextKey();
|
||||
}
|
||||
|
||||
contextKeyFor(context: AppServerQueryContext): string {
|
||||
return appServerQueryContextKey(context);
|
||||
contextKeyFor(context: AppServerQueryContextIdentity): string {
|
||||
return this.queries.contextKeyFor(context);
|
||||
}
|
||||
|
||||
activeThreadsSnapshot(): readonly Thread[] | null {
|
||||
return this.activeSnapshots.get(this.contextKey()) ?? this.queries.activeThreadsSnapshot();
|
||||
}
|
||||
|
||||
activeThreadsSnapshotFor(context: AppServerQueryContext): readonly Thread[] | null {
|
||||
return this.activeSnapshots.get(this.contextKeyFor(context)) ?? this.cache.activeThreadsSnapshot(context);
|
||||
}
|
||||
|
||||
archivedThreadsSnapshot(): readonly Thread[] | null {
|
||||
return this.archivedSnapshots.get(this.contextKey()) ?? this.queries.archivedThreadsSnapshot();
|
||||
}
|
||||
|
||||
archivedThreadsSnapshotFor(context: AppServerQueryContext): readonly Thread[] | null {
|
||||
return this.archivedSnapshots.get(this.contextKeyFor(context)) ?? this.cache.archivedThreadsSnapshot(context);
|
||||
}
|
||||
|
||||
async fetchAllActiveThreads(): Promise<readonly Thread[]> {
|
||||
return this.receiveLoadedActive(await this.queries.fetchAllActiveThreads());
|
||||
}
|
||||
|
|
@ -705,22 +616,10 @@ class TestThreadCatalogStore {
|
|||
return this.receiveLoadedActive(await this.queries.refreshActiveThreads());
|
||||
}
|
||||
|
||||
async refreshActiveThreadsFor(context: AppServerQueryContext): Promise<readonly Thread[]> {
|
||||
const threads = await this.cache.refreshActiveThreads(context);
|
||||
this.activeSnapshots.delete(this.contextKeyFor(context));
|
||||
return threads;
|
||||
}
|
||||
|
||||
async refreshArchivedThreads(): Promise<readonly Thread[]> {
|
||||
return this.receiveLoadedArchived(await this.queries.refreshArchivedThreads());
|
||||
}
|
||||
|
||||
async refreshArchivedThreadsFor(context: AppServerQueryContext): Promise<readonly Thread[]> {
|
||||
const threads = await this.cache.refreshArchivedThreads(context);
|
||||
this.archivedSnapshots.delete(this.contextKeyFor(context));
|
||||
return threads;
|
||||
}
|
||||
|
||||
observeActiveThreadsResult(observer: ObservedResultListener<readonly Thread[]>, options?: { emitCurrent?: boolean }): () => void {
|
||||
this.activeObservers.add(observer);
|
||||
const unsubscribe = this.queries.observeActiveThreadsResult((result) => {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ describe("ThreadTitleService", () => {
|
|||
await expect(service.generateTitle("thread")).resolves.toBe("Generated title");
|
||||
|
||||
expect(withClient).not.toHaveBeenCalled();
|
||||
expect(generateThreadTitle).toHaveBeenCalledWith(titleContext("visible request", "visible response"));
|
||||
expect(generateThreadTitle).toHaveBeenCalledWith(titleContext("visible request", "visible response"), expect.any(AbortSignal));
|
||||
});
|
||||
|
||||
it("throws the existing unavailable-context error when no context can be resolved", async () => {
|
||||
|
|
@ -51,6 +51,36 @@ describe("ThreadTitleService", () => {
|
|||
titleContext("summary turn", "summary answer"),
|
||||
);
|
||||
});
|
||||
|
||||
it("cancels stale title work and starts later work with a fresh signal", async () => {
|
||||
let resolveOldTitle!: (title: string | null) => void;
|
||||
const generateTitle = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
(_context: ThreadTitleContext, _signal: AbortSignal) =>
|
||||
new Promise<string | null>((resolve) => {
|
||||
resolveOldTitle = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce("Fresh title");
|
||||
const service = titleService({
|
||||
visibleContext: () => titleContext("request", "response"),
|
||||
transport: {
|
||||
persistedContext: vi.fn().mockResolvedValue(null),
|
||||
generateTitle,
|
||||
},
|
||||
});
|
||||
|
||||
const staleTitle = service.generateTitle("thread");
|
||||
await Promise.resolve();
|
||||
const staleSignal = generateTitle.mock.calls[0]?.[1];
|
||||
service.invalidate();
|
||||
|
||||
expect(staleSignal?.aborted).toBe(true);
|
||||
await expect(service.generateTitle("thread")).resolves.toBe("Fresh title");
|
||||
resolveOldTitle("Stale title");
|
||||
await expect(staleTitle).rejects.toThrow("Thread title generation cancelled.");
|
||||
});
|
||||
});
|
||||
|
||||
function titleService(options: Partial<ThreadTitleServiceHost> = {}): ThreadTitleService {
|
||||
|
|
|
|||
|
|
@ -637,7 +637,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => {
|
|||
let resolveFirst!: (threads: Thread[]) => void;
|
||||
const runWithAppServerClient = vi.spyOn(connectedView.surface, "runWithAppServerClient");
|
||||
const plugin = await pluginWithLeaves([connectedLeaf]);
|
||||
plugin.settings.codexPath = "codex-a";
|
||||
await publishCodexPath(plugin, "codex-a");
|
||||
runWithAppServerClient.mockImplementation((operation) =>
|
||||
operation(
|
||||
threadListClient(() =>
|
||||
|
|
@ -651,8 +651,9 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => {
|
|||
);
|
||||
|
||||
const first = threadCatalog(plugin).refreshActive();
|
||||
const staleFirst = expect(first).rejects.toThrow("Codex app-server resource context changed while loading.");
|
||||
await flushMicrotasks();
|
||||
plugin.settings.codexPath = "codex-b";
|
||||
await publishCodexPath(plugin, "codex-b");
|
||||
const second = threadCatalog(plugin).refreshActive();
|
||||
await flushMicrotasks();
|
||||
|
||||
|
|
@ -661,9 +662,9 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => {
|
|||
expect(threadCatalog(plugin).activeSnapshot()).toEqual([thread("second")]);
|
||||
|
||||
resolveFirst([thread("first")]);
|
||||
await expect(first).rejects.toThrow("Codex app-server query context changed while loading shared queries.");
|
||||
await staleFirst;
|
||||
expect(threadCatalog(plugin).activeSnapshot()).toEqual([thread("second")]);
|
||||
plugin.settings.codexPath = "codex-a";
|
||||
await publishCodexPath(plugin, "codex-a");
|
||||
expect(threadCatalog(plugin).activeSnapshot()).toBeNull();
|
||||
});
|
||||
|
||||
|
|
@ -680,7 +681,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => {
|
|||
operation(threadListClient(() => Promise.resolve([thread("matching-context")]))),
|
||||
);
|
||||
const plugin = await pluginWithLeaves([connectedLeaf]);
|
||||
plugin.settings.codexPath = "codex-b";
|
||||
await publishCodexPath(plugin, "codex-b");
|
||||
|
||||
await expect(threadCatalog(plugin).refreshActive()).resolves.toEqual([thread("matching-context")]);
|
||||
|
||||
|
|
@ -746,28 +747,51 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => {
|
|||
const result = deferred<string>();
|
||||
vi.spyOn(connectedView.surface, "runWithAppServerClient").mockReturnValue(result.promise);
|
||||
const plugin = await pluginWithLeaves([connectedLeaf]);
|
||||
plugin.settings.codexPath = "codex-a";
|
||||
await publishCodexPath(plugin, "codex-a");
|
||||
|
||||
const operation = plugin.runtime.withClient(() => Promise.resolve("unused"));
|
||||
plugin.settings.codexPath = "codex-b";
|
||||
await publishCodexPath(plugin, "codex-b");
|
||||
result.resolve("stale-result");
|
||||
|
||||
await expect(operation).rejects.toThrow("Codex app-server query context changed while loading shared queries.");
|
||||
await expect(operation).rejects.toThrow("Codex app-server resource context changed while loading.");
|
||||
});
|
||||
|
||||
it("rejects a short-lived result when the app-server context changes during the operation", async () => {
|
||||
const result = deferred<string>();
|
||||
withShortLivedAppServerClientMock.mockReturnValue(result.promise);
|
||||
const plugin = await pluginWithLeaves([]);
|
||||
plugin.settings.codexPath = "codex-a";
|
||||
await publishCodexPath(plugin, "codex-a");
|
||||
|
||||
const operation = plugin.runtime.withClient(() => Promise.resolve("unused"), {
|
||||
serverRequests: { kind: "reject", message: "test" },
|
||||
});
|
||||
plugin.settings.codexPath = "codex-b";
|
||||
await publishCodexPath(plugin, "codex-b");
|
||||
result.resolve("stale-result");
|
||||
|
||||
await expect(operation).rejects.toThrow("Codex app-server query context changed while loading shared queries.");
|
||||
await expect(operation).rejects.toThrow("Codex app-server resource context changed while loading.");
|
||||
});
|
||||
|
||||
it("publishes a persisted context and its new resource lease atomically", async () => {
|
||||
const plugin = await pluginWithLeaves([]);
|
||||
const firstLease = plugin.runtime.appServerContextLease();
|
||||
const save = deferred<void>();
|
||||
const saveSettings = vi.spyOn(plugin, "saveSettings").mockReturnValue(save.promise);
|
||||
|
||||
const publication = plugin.runtime.settingTabHost().publishSettings({ ...plugin.settings, codexPath: "codex-next" });
|
||||
await Promise.resolve();
|
||||
|
||||
expect(saveSettings).toHaveBeenCalledWith(expect.objectContaining({ codexPath: "codex-next" }));
|
||||
expect(plugin.settings.codexPath).toBe(firstLease.context.codexPath);
|
||||
expect(plugin.runtime.appServerContextLease()).toBe(firstLease);
|
||||
|
||||
save.resolve(undefined);
|
||||
await publication;
|
||||
|
||||
expect(plugin.settings.codexPath).toBe("codex-next");
|
||||
expect(plugin.runtime.appServerContextLease()).toEqual({
|
||||
context: { codexPath: "codex-next", vaultPath: "/vault" },
|
||||
generation: firstLease.generation + 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("coordinates context replacement with every open Threads view", async () => {
|
||||
|
|
@ -781,13 +805,8 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => {
|
|||
const threadsLeaf = leaf();
|
||||
threadsLeaf.view = threadsView;
|
||||
const plugin = await pluginWithLeaves([], { threadsLeaves: [threadsLeaf] });
|
||||
const settingsHost = plugin.runtime.settingTabHost();
|
||||
|
||||
settingsHost.prepareAppServerContextChange();
|
||||
await publishCodexPath(plugin, "codex-next");
|
||||
expect(prepareAppServerContextChange).toHaveBeenCalledOnce();
|
||||
|
||||
plugin.settings.codexPath = "codex-next";
|
||||
settingsHost.refreshOpenViews();
|
||||
expect(refreshSettings).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
|
|
@ -796,7 +815,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => {
|
|||
const closeAll = vi.fn();
|
||||
plugin.runtime.setSelectionRewriteController({ closeAll });
|
||||
|
||||
plugin.runtime.settingTabHost().prepareAppServerContextChange();
|
||||
await publishCodexPath(plugin, "codex-next");
|
||||
|
||||
expect(closeAll).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
|
@ -860,10 +879,16 @@ async function pluginWithLeaves(leaves: ReturnType<typeof leaf>[], options: { th
|
|||
} as never,
|
||||
{} as never,
|
||||
);
|
||||
plugin.settings = { ...DEFAULT_SETTINGS };
|
||||
plugin.vaultPath = "/vault";
|
||||
plugin.runtime.initialize();
|
||||
return plugin;
|
||||
}
|
||||
|
||||
async function publishCodexPath(plugin: CodexPanelPlugin, codexPath: string): Promise<void> {
|
||||
await plugin.runtime.settingTabHost().publishSettings({ ...plugin.settings, codexPath });
|
||||
}
|
||||
|
||||
type TestLeaf = ReturnType<typeof leaf>;
|
||||
|
||||
function leaf(options: { state?: Record<string, unknown> } = {}) {
|
||||
|
|
@ -937,6 +962,7 @@ function chatHostFixture(): CodexChatHost {
|
|||
openSideChat: vi.fn(),
|
||||
},
|
||||
appServerQueries: {
|
||||
contextLease: () => ({ context: { codexPath: settings.codexPath, vaultPath: "/vault" }, generation: 1 }),
|
||||
appServerMetadataSnapshot: vi.fn(() => null),
|
||||
refreshAppServerMetadata: vi.fn(() => Promise.resolve(null)),
|
||||
refreshSkills: vi.fn(() => Promise.resolve(null)),
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { createSettingsAppServerDynamicData } from "../../src/settings/app-serve
|
|||
import type { SettingsDynamicDataAccess } from "../../src/settings/dynamic-data";
|
||||
import { SettingsDynamicSectionsController, type SettingsDynamicSectionsSnapshot } from "../../src/settings/dynamic-sections-controller";
|
||||
import type { CodexPanelSettingTabHost } from "../../src/settings/host";
|
||||
import { DEFAULT_SETTINGS } from "../../src/settings/model";
|
||||
import { type CodexPanelSettings, DEFAULT_SETTINGS } from "../../src/settings/model";
|
||||
import { CodexPanelSettingTab } from "../../src/settings/tab.obsidian";
|
||||
import { notices } from "../mocks/obsidian";
|
||||
import { deferred } from "../support/async";
|
||||
|
|
@ -359,7 +359,6 @@ describe("settings tab", () => {
|
|||
|
||||
it("clears dynamic sections when the Codex executable changes", async () => {
|
||||
const saveSettings = vi.fn().mockResolvedValue(undefined);
|
||||
const notifyContextChanged = vi.fn();
|
||||
const refreshOpenViews = vi.fn();
|
||||
const oldClient = settingsClient({
|
||||
models: [model("gpt-old")],
|
||||
|
|
@ -376,7 +375,7 @@ describe("settings tab", () => {
|
|||
.fn()
|
||||
.mockResolvedValueOnce([panelThread({ id: "thread-old", preview: "Old archived", archived: true })])
|
||||
.mockResolvedValueOnce([panelThread({ id: "thread-new", preview: "New archived", archived: true })]);
|
||||
const tab = newSettingsTab({ saveSettings, notifyContextChanged, refreshOpenViews, fetchModels, refreshModels, refreshArchived });
|
||||
const tab = newSettingsTab({ saveSettings, refreshOpenViews, fetchModels, refreshModels, refreshArchived });
|
||||
|
||||
tab.display();
|
||||
await flushPromises();
|
||||
|
|
@ -391,14 +390,12 @@ describe("settings tab", () => {
|
|||
await flushPromises();
|
||||
|
||||
expect(saveSettings).not.toHaveBeenCalled();
|
||||
expect(notifyContextChanged).not.toHaveBeenCalled();
|
||||
expect(refreshOpenViews).not.toHaveBeenCalled();
|
||||
|
||||
codexInput.dispatchEvent(new FocusEvent("blur"));
|
||||
await flushPromises();
|
||||
|
||||
expect(saveSettings).toHaveBeenCalledOnce();
|
||||
expect(notifyContextChanged).toHaveBeenCalledOnce();
|
||||
expect(refreshOpenViews).toHaveBeenCalledOnce();
|
||||
expect(withShortLivedAppServerClientMock).toHaveBeenCalledTimes(1);
|
||||
expect(tab.containerEl.textContent).not.toContain("gpt-old");
|
||||
|
|
@ -1149,14 +1146,13 @@ function expectRequestTimes(client: SettingsRequestClient, method: string, times
|
|||
|
||||
function newSettingsTab(
|
||||
options: {
|
||||
saveSettings?: CodexPanelSettingTabHost["saveSettings"];
|
||||
saveSettings?: (settings: CodexPanelSettings) => Promise<void>;
|
||||
prepareAppServerContextChange?: () => void;
|
||||
sendShortcut?: "enter" | "mod-enter";
|
||||
modelsSnapshot?: ModelMetadata[];
|
||||
fetchModels?: () => Promise<readonly ModelMetadata[]>;
|
||||
refreshModels?: () => Promise<readonly ModelMetadata[]>;
|
||||
observeModels?: SettingsDynamicDataAccess["observeModelsResult"];
|
||||
notifyContextChanged?: () => void;
|
||||
refreshOpenViews?: () => void;
|
||||
archivedThreads?: Thread[];
|
||||
archivedSnapshot?: Thread[] | null;
|
||||
|
|
@ -1177,14 +1173,13 @@ function newSettingsTab(
|
|||
|
||||
function settingsTabHost(
|
||||
options: {
|
||||
saveSettings?: CodexPanelSettingTabHost["saveSettings"];
|
||||
saveSettings?: (settings: CodexPanelSettings) => Promise<void>;
|
||||
prepareAppServerContextChange?: () => void;
|
||||
sendShortcut?: "enter" | "mod-enter";
|
||||
modelsSnapshot?: ModelMetadata[];
|
||||
fetchModels?: () => Promise<readonly ModelMetadata[]>;
|
||||
refreshModels?: () => Promise<readonly ModelMetadata[]>;
|
||||
observeModels?: SettingsDynamicDataAccess["observeModelsResult"];
|
||||
notifyContextChanged?: () => void;
|
||||
refreshOpenViews?: () => void;
|
||||
archivedThreads?: Thread[];
|
||||
archivedSnapshot?: Thread[] | null;
|
||||
|
|
@ -1214,7 +1209,6 @@ function settingsTabHost(
|
|||
fetchModels: options.fetchModels ?? vi.fn().mockResolvedValue(options.modelsSnapshot ?? []),
|
||||
refreshModels: options.refreshModels ?? vi.fn().mockResolvedValue(options.modelsSnapshot ?? []),
|
||||
observeModelsResult: options.observeModels ?? vi.fn(() => () => undefined),
|
||||
notifyContextChanged: options.notifyContextChanged ?? vi.fn(),
|
||||
};
|
||||
const threadCatalog = {
|
||||
archivedSnapshot: vi.fn(() => options.archivedSnapshot ?? null),
|
||||
|
|
@ -1235,9 +1229,15 @@ function settingsTabHost(
|
|||
appServerQueries,
|
||||
threadCatalog,
|
||||
}),
|
||||
saveSettings: options.saveSettings ?? vi.fn().mockResolvedValue(undefined),
|
||||
prepareAppServerContextChange: options.prepareAppServerContextChange ?? vi.fn(),
|
||||
refreshOpenViews: options.refreshOpenViews ?? vi.fn(),
|
||||
publishSettings: async (nextSettings) => {
|
||||
const previousSettings = { ...settings };
|
||||
await (options.saveSettings ?? vi.fn().mockResolvedValue(undefined))(nextSettings);
|
||||
const appServerContextReplaced = previousSettings.codexPath !== nextSettings.codexPath;
|
||||
if (appServerContextReplaced) options.prepareAppServerContextChange?.();
|
||||
Object.assign(settings, nextSettings);
|
||||
if (appServerContextReplaced || previousSettings.showToolbar !== nextSettings.showToolbar) options.refreshOpenViews?.();
|
||||
return { appServerContextReplaced };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue