diff --git a/src/runtime/shared-app-server-state.ts b/src/runtime/shared-app-server-state.ts index 27de5948..239fecbc 100644 --- a/src/runtime/shared-app-server-state.ts +++ b/src/runtime/shared-app-server-state.ts @@ -32,34 +32,62 @@ export function createSharedAppServerState(): SharedAppServerState { export function applySharedThreadList(state: SharedAppServerState, threads: readonly Thread[]): SharedAppServerState { return { ...state, - threads: { kind: "loaded", data: threads }, + threads: { kind: "loaded", data: cloneThreads(threads) }, }; } export function applySharedAppServerMetadata(state: SharedAppServerState, metadata: SharedAppServerMetadata): SharedAppServerState { + const clonedMetadata = cloneSharedAppServerMetadata(metadata); return { ...state, - appServerMetadata: { kind: "loaded", data: metadata }, - availableModels: metadata.availableModels, + appServerMetadata: { kind: "loaded", data: clonedMetadata }, + availableModels: cloneModels(clonedMetadata.availableModels), }; } export function applySharedModels(state: SharedAppServerState, models: readonly Model[]): SharedAppServerState { + const clonedModels = cloneModels(models); return { ...state, appServerMetadata: state.appServerMetadata.kind === "loaded" - ? { kind: "loaded", data: { ...state.appServerMetadata.data, availableModels: models } } + ? { kind: "loaded", data: { ...state.appServerMetadata.data, availableModels: cloneModels(clonedModels) } } : state.appServerMetadata, - availableModels: models, + availableModels: clonedModels, }; } export function cachedSharedThreadList(state: SharedAppServerState): readonly Thread[] | null { - return state.threads.kind === "loaded" ? state.threads.data : null; + return state.threads.kind === "loaded" ? cloneThreads(state.threads.data) : null; } export function cachedSharedAppServerMetadata(state: SharedAppServerState): SharedAppServerMetadata | null { - if (state.appServerMetadata.kind === "loaded") return state.appServerMetadata.data; + if (state.appServerMetadata.kind === "loaded") return cloneSharedAppServerMetadata(state.appServerMetadata.data); return null; } + +function cloneSharedAppServerMetadata(metadata: SharedAppServerMetadata): SharedAppServerMetadata { + return { + ...metadata, + availableModels: cloneModels(metadata.availableModels), + availableSkills: metadata.availableSkills.map((skill) => ({ ...skill })), + appServerDiagnostics: { + probes: { ...metadata.appServerDiagnostics.probes }, + mcpServers: metadata.appServerDiagnostics.mcpServers.map((server) => ({ ...server })), + }, + }; +} + +function cloneThreads(threads: readonly Thread[]): Thread[] { + return threads.map((thread) => ({ ...thread, turns: [...thread.turns] })); +} + +function cloneModels(models: readonly Model[]): Model[] { + return models.map((model) => ({ + ...model, + supportedReasoningEfforts: [...model.supportedReasoningEfforts], + inputModalities: [...model.inputModalities], + additionalSpeedTiers: [...model.additionalSpeedTiers], + serviceTiers: [...model.serviceTiers], + })); +} diff --git a/tests/runtime/runtime-settings.test.ts b/tests/runtime/runtime-settings.test.ts index 3f8db678..173d923e 100644 --- a/tests/runtime/runtime-settings.test.ts +++ b/tests/runtime/runtime-settings.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; import type { ConfigReadResponse } from "../../src/generated/app-server/v2/ConfigReadResponse"; +import type { Model } from "../../src/generated/app-server/v2/Model"; +import type { Thread } from "../../src/generated/app-server/v2/Thread"; +import { createAppServerDiagnostics } from "../../src/app-server/compatibility"; import { compactContextLabel, compactModelLabel, @@ -27,6 +30,14 @@ import { } from "../../src/runtime/state"; import { readRuntimeConfig } from "../../src/runtime/config"; import { contextSummary, effectiveConfigSections, rateLimitSummary } from "../../src/runtime/view"; +import { + applySharedAppServerMetadata, + applySharedModels, + applySharedThreadList, + cachedSharedAppServerMetadata, + cachedSharedThreadList, + createSharedAppServerState, +} from "../../src/runtime/shared-app-server-state"; describe("runtime settings", () => { it("parses model overrides", () => { @@ -35,6 +46,38 @@ describe("runtime settings", () => { expect(parseModelOverride("")).toBeUndefined(); }); + it("keeps shared app-server cache snapshots detached from caller-owned arrays", () => { + const sourceThreads = [threadFixture("thread-1")]; + const threadState = applySharedThreadList(createSharedAppServerState(), sourceThreads); + sourceThreads.push(threadFixture("thread-2")); + + const cachedThreads = cachedSharedThreadList(threadState); + expect(cachedThreads?.map((thread) => thread.id)).toEqual(["thread-1"]); + + const mutableCachedThreads = cachedThreads as Thread[]; + mutableCachedThreads.push(threadFixture("thread-3")); + expect(cachedSharedThreadList(threadState)?.map((thread) => thread.id)).toEqual(["thread-1"]); + + const sourceModels = [modelFixture("gpt-5.5")]; + const modelState = applySharedModels(createSharedAppServerState(), sourceModels); + sourceModels.push(modelFixture("gpt-5.6")); + expect(modelState.availableModels.map((model) => model.model)).toEqual(["gpt-5.5"]); + + const metadataState = applySharedAppServerMetadata(createSharedAppServerState(), { + effectiveConfig: null, + availableModels: sourceModels, + availableSkills: [{ name: "skill", description: "", path: "/tmp/skill", scope: "repo", enabled: true }], + rateLimit: null, + appServerDiagnostics: { + ...createAppServerDiagnostics(), + mcpServers: [{ name: "server", startupStatus: "ready", authStatus: null, toolCount: 1, message: null }], + }, + }); + sourceModels.push(modelFixture("gpt-5.7")); + const cachedMetadata = cachedSharedAppServerMetadata(metadataState); + expect(cachedMetadata?.availableModels.map((model) => model.model)).toEqual(["gpt-5.5", "gpt-5.6"]); + }); + it("parses reasoning effort overrides", () => { expect(parseReasoningEffortOverride("high")).toBe("high"); expect(parseReasoningEffortOverride("default")).toBeNull(); @@ -591,3 +634,48 @@ function configLayer(config: Record, profile: string | null): N disabledReason: null, }; } + +function threadFixture(id: string): Thread { + return { + id, + sessionId: "session", + forkedFromId: null, + preview: "", + ephemeral: false, + modelProvider: "openai", + createdAt: 1, + updatedAt: 1, + status: { type: "idle" }, + path: null, + cwd: "/vault", + cliVersion: "0.0.0", + source: "appServer", + threadSource: null, + agentNickname: null, + agentRole: null, + gitInfo: null, + name: null, + turns: [], + }; +} + +function modelFixture(model: string): Model { + return { + id: model, + model, + upgrade: null, + upgradeInfo: null, + availabilityNux: null, + displayName: model, + description: "", + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: "medium", + inputModalities: [], + supportsPersonality: false, + additionalSpeedTiers: [], + serviceTiers: [], + defaultServiceTier: null, + isDefault: false, + }; +}