diff --git a/packages/opencode/src/memory/memory.ts b/packages/opencode/src/memory/memory.ts index aff81c970..d5402dac6 100644 --- a/packages/opencode/src/memory/memory.ts +++ b/packages/opencode/src/memory/memory.ts @@ -3,8 +3,9 @@ export * as Memory from "./memory" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { KeyedMutex } from "@opencode-ai/core/effect/keyed-mutex" import { SessionV1 } from "@opencode-ai/core/v1/session" -import { Context, Duration, Effect, Layer, Option, Ref, Schema } from "effect" +import { Context, Duration, Effect, Layer, Option, Ref, Schema, Semaphore } from "effect" import { stringify } from "yaml" +import { Config } from "@/config/config" import { Provider } from "@/provider/provider" import { Project } from "@/project/project" import { InstanceState } from "@/effect/instance-state" @@ -61,65 +62,63 @@ export class ControllerError extends Schema.TaggedErrorClass()( export const layer: Layer.Layer< Service, never, - Provider.Service | Project.Service | MemoryConfig.Service | MemoryModel.Service | MemoryStore.Service + Config.Service | Provider.Service | Project.Service | MemoryConfig.Service | MemoryModel.Service | MemoryStore.Service > = Layer.effect( Service, Effect.gen(function* () { + const config = yield* Config.Service const provider = yield* Provider.Service const project = yield* Project.Service const configStore = yield* MemoryConfig.Service const modelCalls = yield* MemoryModel.Service const store = yield* MemoryStore.Service const globalStarted = yield* Ref.make(false) + const initializationLock = Semaphore.makeUnsafe(1) const locks = KeyedMutex.makeUnsafe() const state = yield* InstanceState.make(() => Effect.succeed({ sessions: new Map() })) - const models = Effect.fn("Memory.models")(function* () { + const availableModels = Effect.fn("Memory.availableModels")(function* () { const providers = yield* provider.list() - return Object.values(providers) - .flatMap((info) => + return new Set( + Object.values(providers).flatMap((info) => Object.values(info.models) .filter((model) => model.capabilities.input.text && model.capabilities.output.text) - .map((model) => ({ - id: `${model.providerID}/${model.id}`, - name: model.name, - input_cost: model.cost.input, - output_cost: model.cost.output, - context_limit: model.limit.context, - output_limit: model.limit.output, - })), - ) - .sort((a, b) => a.input_cost + a.output_cost - (b.input_cost + b.output_cost) || a.id.localeCompare(b.id)) + .map((model) => `${model.providerID}/${model.id}`), + ), + ) + }) + + const selectBootstrapModel = Effect.fn("Memory.selectBootstrapModel")(function* ( + available: Effect.Success>, + conversationModel?: string, + ) { + const settings = yield* config.get() + if (settings.small_model && available.has(settings.small_model)) return settings.small_model + const compactionModel = settings.agent?.compaction?.model + if (compactionModel && available.has(compactionModel)) return compactionModel + const defaultModel = yield* provider.defaultModel().pipe(Effect.option) + const fallback = Option.isSome(defaultModel) + ? `${defaultModel.value.providerID}/${defaultModel.value.modelID}` + : undefined + if (fallback && available.has(fallback)) return fallback + if (conversationModel && available.has(conversationModel)) return conversationModel + return yield* new ControllerError({ message: "No configured text models for MEMORY" }) }) const selectConfiguration = Effect.fn("Memory.selectConfiguration")(function* ( - candidates: Effect.Success>, + available: Effect.Success>, current?: MemorySchema.Config, + conversationModel?: string, ) { - if (candidates.length === 0) - return yield* new ControllerError({ message: "No configured text models for MEMORY" }) - const bootstrap = yield* provider.defaultModel() - const model = yield* provider.getModel(bootstrap.providerID, bootstrap.modelID) - const output = yield* modelCalls.generate({ - model, - system: MemoryPrompts.INIT_SYSTEM, - prompt: JSON.stringify({ candidates }), - schema: MemorySchema.InitResponse, - maxOutputTokens: 512, - }) - const decoded = Schema.decodeUnknownOption(MemorySchema.InitResponse)(output) - if (Option.isNone(decoded)) - return yield* new ControllerError({ message: "MEMORY initializer returned invalid output" }) - if (!candidates.some((candidate) => candidate.id === decoded.value.model)) - return yield* new ControllerError({ message: "MEMORY initializer selected an unavailable model" }) - if (current) return MemorySchema.updateConfig(current, { model: decoded.value.model }) + const selected = yield* selectBootstrapModel(available, conversationModel) + if (current) return MemorySchema.updateConfig(current, { model: selected }) return { schema_version: MemorySchema.SCHEMA_VERSION, enabled: true, - model: decoded.value.model, - topic_limit: decoded.value.topic_limit, - topic_limit_floor: decoded.value.topic_limit, - turn_interval: decoded.value.turn_interval, + model: selected, + topic_limit: 10, + topic_limit_floor: 10, + turn_interval: 5, injection: { max_topics: MemorySchema.MAX_INJECTION_TOPICS, max_tokens: MemorySchema.MAX_INJECTION_TOKENS, @@ -127,28 +126,36 @@ export const layer: Layer.Layer< } satisfies MemorySchema.Config }) - const ensureConfiguredModel = Effect.fn("Memory.ensureConfiguredModel")(function* (config: MemorySchema.Config) { - const candidates = yield* models() - if (candidates.some((candidate) => candidate.id === config.model)) return config + const ensureConfiguredModel = Effect.fn("Memory.ensureConfiguredModel")(function* ( + config: MemorySchema.Config, + conversationModel?: string, + ) { + const available = yield* availableModels() + if (available.has(config.model)) return config yield* Effect.logWarning("configured MEMORY model is unavailable — selecting a replacement", { model: config.model, }) - return yield* selectConfiguration(candidates, config) + return yield* selectConfiguration(available, config, conversationModel) }) - const initializeGlobal = Effect.fn("Memory.initializeGlobal")(function* () { + const initializeGlobal = Effect.fn("Memory.initializeGlobal")(function* (conversationModel?: string) { const existing = yield* configStore.loadGlobal() const config = existing - ? yield* ensureConfiguredModel(existing.config) - : yield* selectConfiguration(yield* models()) + ? yield* ensureConfiguredModel(existing.config, conversationModel) + : yield* selectConfiguration(yield* availableModels(), undefined, conversationModel) if (existing?.config.model === config.model) return const created = yield* configStore.writeGlobal(config, existing?.path) if (created) yield* Effect.logInfo("global MEMORY config initialized", { model: config.model }) }) - const initUnsafe = Effect.fn("Memory.initUnsafe")(function* () { - if (yield* Ref.getAndSet(globalStarted, true)) return - yield* initializeGlobal().pipe(Effect.onError(() => Ref.set(globalStarted, false))) + const initUnsafe = Effect.fn("Memory.initUnsafe")(function* (conversationModel?: string) { + yield* initializationLock.withPermits(1)( + Effect.gen(function* () { + if (yield* Ref.get(globalStarted)) return + yield* initializeGlobal(conversationModel) + yield* Ref.set(globalStarted, true) + }), + ) }) const init: Interface["init"] = Effect.fn("Memory.init")(() => @@ -297,6 +304,16 @@ export const layer: Layer.Layer< }) { const user = latestRealUser(input.messages) if (!user) return + const currentUser = currentRealUser(input.messages) + const configured = yield* configuration() + if (!configured) { + yield* clearSession(input.sessionID) + return + } + if (currentUser) + yield* initUnsafe(`${currentUser.info.model.providerID}/${currentUser.info.model.modelID}`).pipe( + Effect.catchCause((cause) => Effect.logWarning("global MEMORY init failed", { cause })), + ) const current = yield* active() if (!current) { yield* clearSession(input.sessionID) @@ -507,7 +524,7 @@ export const layer: Layer.Layer< const value = initial.loaded ? initial : yield* Effect.gen(function* () { - yield* initializeGlobal() + yield* initUnsafe() return (yield* configuration()) ?? initial }) if (!value.loaded) return "Memory remains off" as const @@ -547,6 +564,7 @@ export const layer: Layer.Layer< export const defaultLayer: Layer.Layer = Layer.suspend(() => layer.pipe( + Layer.provide(Config.defaultLayer), Layer.provide(Provider.defaultLayer), Layer.provide(Project.defaultLayer), Layer.provide(MemoryConfig.defaultLayer), @@ -556,6 +574,7 @@ export const defaultLayer: Layer.Layer = Layer.suspend(() => ) export const node = LayerNode.make(layer, [ + Config.node, Provider.node, Project.node, MemoryConfig.node, @@ -660,6 +679,16 @@ function maintenanceEvidence(messages: SessionV1.WithParts[]) { function latestRealUser(messages: SessionV1.WithParts[]) { const user = messages.findLast(isRealUser) if (!user) return undefined + return userInput(user) +} + +function currentRealUser(messages: SessionV1.WithParts[]) { + const user = messages.findLast((message) => message.info.role === "user") + if (!user || !isRealUser(user)) return undefined + return userInput(user) +} + +function userInput(user: SessionV1.WithParts & { info: SessionV1.User }) { return { info: user.info, text: cleanText( @@ -671,7 +700,7 @@ function latestRealUser(messages: SessionV1.WithParts[]) { } } -function isRealUser(message: SessionV1.WithParts) { +function isRealUser(message: SessionV1.WithParts): message is SessionV1.WithParts & { info: SessionV1.User } { if (message.info.role !== "user") return false if (message.parts.some((part) => part.type === "compaction")) return false const text = message.parts.filter((part): part is SessionV1.TextPart => part.type === "text" && !part.synthetic) diff --git a/packages/opencode/src/memory/prompts.ts b/packages/opencode/src/memory/prompts.ts index df9ae5e07..af7447d31 100644 --- a/packages/opencode/src/memory/prompts.ts +++ b/packages/opencode/src/memory/prompts.ts @@ -1,14 +1,5 @@ export * as MemoryPrompts from "./prompts" -export const INIT_SYSTEM = `You initialize a lightweight project-memory controller. - -Return only the requested structured object. -- model must exactly match one candidate id from the input. -- Select a low-cost, low-latency text model that can reliably return structured data. -- topic_limit is chosen once in the range 10..100. This lightweight system normally needs the low end. -- turn_interval is chosen once in the range 1..20. Balance freshness against background cost. -- Do not invent a provider, model, field, or fallback.` - export const MATCH_SYSTEM = `Select project-memory topics relevant to the supplied user text. Return only topic ids present in the metadata input, ranked most relevant first. diff --git a/packages/opencode/src/memory/schema.ts b/packages/opencode/src/memory/schema.ts index de8ca755f..cab380297 100644 --- a/packages/opencode/src/memory/schema.ts +++ b/packages/opencode/src/memory/schema.ts @@ -168,12 +168,6 @@ export class MatchResponse extends Schema.Class("MemoryMatchRespo topic_ids: Schema.Array(StableID).check(Schema.isMaxLength(MAX_INJECTION_TOPICS)), }) {} -export class InitResponse extends Schema.Class("MemoryInitResponse")({ - model: Config.fields.model, - topic_limit: Config.fields.topic_limit, - turn_interval: Config.fields.turn_interval, -}) {} - export function topicIndex(topic: Topic): TopicIndex { return { id: topic.id, diff --git a/packages/opencode/test/memory/memory.test.ts b/packages/opencode/test/memory/memory.test.ts index ee623edc8..205a3d3fd 100644 --- a/packages/opencode/test/memory/memory.test.ts +++ b/packages/opencode/test/memory/memory.test.ts @@ -4,6 +4,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Deferred, Duration, Effect, Fiber, Layer } from "effect" import fs from "node:fs/promises" import path from "node:path" +import { Config } from "@/config/config" import { Git } from "@/git" import { MemoryConfig } from "@/memory/config" import { Memory } from "@/memory/memory" @@ -12,6 +13,7 @@ import { MemoryPrompts } from "@/memory/prompts" import { MemorySchema } from "@/memory/schema" import { MemoryStore } from "@/memory/store" import { Project } from "@/project/project" +import { Provider } from "@/provider/provider" import { MessageID, PartID, SessionID } from "@/session/schema" import { Token } from "@/util/token" import { ProviderV2 } from "@opencode-ai/core/provider" @@ -42,6 +44,9 @@ const replacementModel = ProviderTest.model({ const replacementProvider = ProviderTest.fake({ model: replacementModel }) let writtenGlobalConfig: MemorySchema.Config | undefined let writtenProjectConfig: MemorySchema.Config | undefined +const emptyConfigLayer = Layer.mock(Config.Service, { + get: () => Effect.succeed({}), +}) function topic(id = "architecture-boundaries") { return { @@ -82,6 +87,7 @@ const unavailableModelIt = testEffect( Memory.layer.pipe( Layer.provide( Layer.mergeAll( + emptyConfigLayer, replacementProvider.layer, Layer.mock(Project.Service, { get: (id) => @@ -128,6 +134,136 @@ const unavailableModelIt = testEffect( ), ) +function bootstrapFixture() { + const providerID = ProviderV2.ID.make("test") + const models = { + small: ProviderTest.model({ providerID, id: ModelV2.ID.make("small") }), + compaction: ProviderTest.model({ providerID, id: ModelV2.ID.make("compaction") }), + default: ProviderTest.model({ providerID, id: ModelV2.ID.make("default") }), + conversation: ProviderTest.model({ providerID, id: ModelV2.ID.make("conversation") }), + } + const state: { + available: Set + smallModel?: string + compactionModel?: string + defaultModel?: string + project?: MemorySchema.Config + global?: MemorySchema.Config + written?: MemorySchema.Config + modelCalls: number + defaultModelHook?: Effect.Effect + loadHook?: Effect.Effect + } = { + available: new Set(Object.values(models).map((model) => `${model.providerID}/${model.id}`)), + smallModel: "test/small", + compactionModel: "test/compaction", + defaultModel: "test/default", + modelCalls: 0, + } + const layer = Memory.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(Config.Service, { + get: () => + Effect.succeed({ + small_model: state.smallModel, + agent: state.compactionModel ? { compaction: { model: state.compactionModel } } : undefined, + }), + }), + Layer.mock(Provider.Service, { + list: () => + Effect.succeed({ + [providerID]: ProviderTest.info({ + id: providerID, + models: Object.fromEntries( + Object.values(models) + .filter((model) => state.available.has(`${model.providerID}/${model.id}`)) + .map((model) => [model.id, model]), + ), + }), + }), + getModel: (candidateProviderID, candidateModelID) => { + const model = Object.values(models).find( + (item) => item.providerID === candidateProviderID && item.id === candidateModelID, + ) + if (model && state.available.has(`${model.providerID}/${model.id}`)) return Effect.succeed(model) + return Effect.die(new Error(`Unknown test model: ${candidateProviderID}/${candidateModelID}`)) + }, + defaultModel: () => + Effect.gen(function* () { + if (state.defaultModelHook) yield* state.defaultModelHook + if (state.defaultModel) return Provider.parseModel(state.defaultModel) + return yield* new Provider.NoProvidersError() + }), + }), + Layer.mock(Project.Service, { + get: (id) => + Effect.succeed({ + id, + worktree: "/unused", + vcs: "git" as const, + time: { created: 0, updated: 0, initialized: 1 }, + sandboxes: [], + }), + }), + Layer.mock(MemoryConfig.Service, { + load: () => + Effect.gen(function* () { + if (state.loadHook) yield* state.loadHook + if (state.project) + return { config: state.project, path: "/project/.opencode/memory.jsonc", level: "project" as const } + return state.global + ? { config: state.global, path: "/global/memory.jsonc", level: "global" as const } + : undefined + }), + loadGlobal: () => + Effect.succeed( + state.global + ? { config: state.global, path: "/global/memory.jsonc", level: "global" as const } + : undefined, + ), + writeGlobal: (next) => + Effect.sync(() => { + state.written = next + state.global = next + return true + }), + writeProject: () => Effect.void, + }), + Layer.mock(MemoryModel.Service, { + generate: () => + Effect.sync(() => { + state.modelCalls++ + throw new Error("bootstrap must not call a model") + }), + }), + Layer.mock(MemoryStore.Service, { + readTopics: () => Effect.succeed([]), + ensureGitExclude: () => Effect.void, + writeTopics: () => Effect.void, + }), + ), + ), + ) + return { + state, + models, + reset: () => { + state.available = new Set(Object.values(models).map((model) => `${model.providerID}/${model.id}`)) + state.smallModel = "test/small" + state.compactionModel = "test/compaction" + state.defaultModel = "test/default" + state.project = undefined + state.global = undefined + state.written = undefined + state.modelCalls = 0 + state.defaultModelHook = undefined + state.loadHook = undefined + }, + it: testEffect(layer), + } +} + function recallFixture() { const model = ProviderTest.model({ providerID: ProviderV2.ID.make("test"), @@ -155,6 +291,7 @@ function recallFixture() { const layer = Memory.layer.pipe( Layer.provide( Layer.mergeAll( + emptyConfigLayer, provider.layer, Layer.mock(Project.Service, { get: (id) => @@ -1201,6 +1338,313 @@ describe("memory hidden model", () => { ) }) +describe("memory bootstrap", () => { + const bootstrap = bootstrapFixture() + + bootstrap.it.instance( + "creates global configuration from small_model without an initializer model call", + () => + Effect.gen(function* () { + bootstrap.reset() + const memory = yield* Memory.Service + + yield* memory.init() + + expect(bootstrap.state.written).toEqual({ + schema_version: 1, + enabled: true, + model: "test/small", + topic_limit: 10, + topic_limit_floor: 10, + turn_interval: 5, + injection: { max_topics: 3, max_tokens: 1_200 }, + }) + expect(bootstrap.state.modelCalls).toBe(0) + }), + { git: true }, + ) + + bootstrap.it.instance( + "falls through an unavailable small_model to configured agent.compaction.model", + () => + Effect.gen(function* () { + bootstrap.reset() + bootstrap.state.smallModel = "test/removed" + const memory = yield* Memory.Service + + yield* memory.init() + + expect(bootstrap.state.written?.model).toBe("test/compaction") + expect(bootstrap.state.modelCalls).toBe(0) + }), + { git: true }, + ) + + bootstrap.it.instance( + "falls through unavailable configured models to the system default model", + () => + Effect.gen(function* () { + bootstrap.reset() + bootstrap.state.smallModel = undefined + bootstrap.state.compactionModel = "test/removed" + const memory = yield* Memory.Service + + yield* memory.init() + + expect(bootstrap.state.written?.model).toBe("test/default") + expect(bootstrap.state.modelCalls).toBe(0) + }), + { git: true }, + ) + + bootstrap.it.instance( + "preserves an existing configuration whose model remains available", + () => + Effect.gen(function* () { + bootstrap.reset() + bootstrap.state.global = { + ...config, + model: "test/conversation", + topic_limit: 42, + topic_limit_floor: 42, + turn_interval: 9, + } + const memory = yield* Memory.Service + + yield* memory.init() + + expect(bootstrap.state.written).toBeUndefined() + expect(bootstrap.state.global).toMatchObject({ + model: "test/conversation", + topic_limit: 42, + topic_limit_floor: 42, + turn_interval: 9, + }) + expect(bootstrap.state.modelCalls).toBe(0) + }), + { git: true }, + ) + + bootstrap.it.instance( + "repairs a stale global model with the ordered resolver and preserves its settings", + () => + Effect.gen(function* () { + bootstrap.reset() + bootstrap.state.smallModel = undefined + bootstrap.state.global = { + ...config, + model: "removed/model", + topic_limit: 37, + topic_limit_floor: 37, + turn_interval: 7, + } + const memory = yield* Memory.Service + + yield* memory.init() + + expect(bootstrap.state.written).toMatchObject({ + model: "test/compaction", + topic_limit: 37, + topic_limit_floor: 37, + turn_interval: 7, + }) + expect(bootstrap.state.modelCalls).toBe(0) + }), + { git: true }, + ) + + bootstrap.it.instance( + "defers without startup sources and creates configuration from the first real conversation model", + () => + Effect.gen(function* () { + bootstrap.reset() + bootstrap.state.available = new Set(["test/conversation"]) + bootstrap.state.smallModel = undefined + bootstrap.state.compactionModel = undefined + bootstrap.state.defaultModel = undefined + const memory = yield* Memory.Service + const sessionID = SessionID.make("ses_memory_lazy_bootstrap") + + yield* memory.init() + expect(bootstrap.state.written).toBeUndefined() + + yield* memory.prepare({ + sessionID, + messages: [user(MessageID.ascending(), sessionID, "记住这次对话", false, ModelV2.ID.make("conversation"))], + }) + + expect(bootstrap.state.written).toMatchObject({ + model: "test/conversation", + topic_limit: 10, + topic_limit_floor: 10, + turn_interval: 5, + }) + expect(bootstrap.state.modelCalls).toBe(0) + }), + { git: true }, + ) + + bootstrap.it.instance( + "repairs a stale global model from the first real conversation model after startup defers", + () => + Effect.gen(function* () { + bootstrap.reset() + bootstrap.state.available = new Set(["test/conversation"]) + bootstrap.state.smallModel = undefined + bootstrap.state.compactionModel = undefined + bootstrap.state.defaultModel = undefined + bootstrap.state.global = { + ...config, + model: "removed/model", + topic_limit: 37, + topic_limit_floor: 37, + turn_interval: 7, + } + const memory = yield* Memory.Service + const sessionID = SessionID.make("ses_memory_lazy_stale_repair") + + yield* memory.init() + expect(bootstrap.state.written).toBeUndefined() + + yield* memory.prepare({ + sessionID, + messages: [user(MessageID.ascending(), sessionID, "继续这次对话", false, ModelV2.ID.make("conversation"))], + }) + + expect(bootstrap.state.written).toMatchObject({ + model: "test/conversation", + topic_limit: 37, + topic_limit_floor: 37, + turn_interval: 7, + }) + expect(bootstrap.state.modelCalls).toBe(0) + }), + { git: true }, + ) + + bootstrap.it.instance( + "creates missing global configuration from the first conversation even when project configuration exists", + () => + Effect.gen(function* () { + bootstrap.reset() + bootstrap.state.available = new Set(["test/conversation"]) + bootstrap.state.smallModel = undefined + bootstrap.state.compactionModel = undefined + bootstrap.state.defaultModel = undefined + bootstrap.state.project = { ...config, model: "test/conversation" } + const memory = yield* Memory.Service + const sessionID = SessionID.make("ses_memory_project_config_global_bootstrap") + + yield* memory.init() + expect(bootstrap.state.written).toBeUndefined() + + yield* memory.prepare({ + sessionID, + messages: [ + user( + MessageID.ascending(), + sessionID, + "为其他项目建立默认记忆配置", + false, + ModelV2.ID.make("conversation"), + ), + ], + }) + + expect(bootstrap.state.written).toMatchObject({ + model: "test/conversation", + topic_limit: 10, + topic_limit_floor: 10, + turn_interval: 5, + }) + expect(bootstrap.state.modelCalls).toBe(0) + }), + { git: true }, + ) + + bootstrap.it.instance( + "does not initialize from a historical real user when the current message is synthetic or a command", + () => + Effect.gen(function* () { + bootstrap.reset() + bootstrap.state.available = new Set(["test/conversation"]) + bootstrap.state.smallModel = undefined + bootstrap.state.compactionModel = undefined + bootstrap.state.defaultModel = undefined + const memory = yield* Memory.Service + const sessionID = SessionID.make("ses_memory_ineligible_bootstrap") + const historical = user( + MessageID.ascending(), + sessionID, + "historical real user", + false, + ModelV2.ID.make("conversation"), + ) + + yield* memory.prepare({ + sessionID, + messages: [ + historical, + user(MessageID.ascending(), sessionID, "synthetic continuation", true, ModelV2.ID.make("conversation")), + ], + }) + yield* memory.prepare({ + sessionID, + messages: [ + historical, + user(MessageID.ascending(), sessionID, "/goal write the docs", false, ModelV2.ID.make("conversation")), + ], + }) + + expect(bootstrap.state.written).toBeUndefined() + expect(bootstrap.state.modelCalls).toBe(0) + }), + { git: true }, + ) + + bootstrap.it.instance( + "serializes a first-turn fallback behind an overlapping startup attempt", + () => + Effect.gen(function* () { + bootstrap.reset() + bootstrap.state.available = new Set(["test/conversation"]) + bootstrap.state.smallModel = undefined + bootstrap.state.compactionModel = undefined + bootstrap.state.defaultModel = undefined + const started = yield* Deferred.make() + const release = yield* Deferred.make() + const preparationReady = yield* Deferred.make() + bootstrap.state.defaultModelHook = Effect.gen(function* () { + yield* Deferred.succeed(started, undefined) + yield* Deferred.await(release) + }) + bootstrap.state.loadHook = Deferred.succeed(preparationReady, undefined) + const memory = yield* Memory.Service + const sessionID = SessionID.make("ses_memory_overlapping_bootstrap") + + const startup = yield* memory.init().pipe(Effect.forkChild) + yield* Deferred.await(started) + const preparation = yield* memory + .prepare({ + sessionID, + messages: [ + user(MessageID.ascending(), sessionID, "并发启动后的首轮", false, ModelV2.ID.make("conversation")), + ], + }) + .pipe(Effect.forkChild) + yield* Deferred.await(preparationReady) + expect(bootstrap.state.written).toBeUndefined() + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(startup) + yield* Fiber.join(preparation) + + expect(bootstrap.state.written?.model).toBe("test/conversation") + expect(bootstrap.state.modelCalls).toBe(0) + }), + { git: true }, + ) +}) + describe("memory enablement", () => { unavailableModelIt.instance( "reselects an available model for startup and the only enable command", @@ -1242,9 +1686,14 @@ function assistant( } } -function user(id: MessageID, sessionID: SessionID, text: string, synthetic = false): SessionV1.WithParts { +function user( + id: MessageID, + sessionID: SessionID, + text: string, + synthetic = false, + modelID = ModelV2.ID.make("test-model"), +): SessionV1.WithParts { const providerID = ProviderV2.ID.make("test") - const modelID = ModelV2.ID.make("test-model") return { info: { id,