refactor(core): simplify v2 system context epochs
This commit is contained in:
parent
39740e75da
commit
00c4114911
30 changed files with 997 additions and 828 deletions
|
|
@ -190,6 +190,53 @@ describe("EventV2", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("commits local operational state inside a new synchronized event transaction", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const received = new Array<string>()
|
||||
yield* events.project(SyncMessage, () => Effect.sync(() => received.push("projector")))
|
||||
|
||||
yield* events.publish(
|
||||
SyncMessage,
|
||||
{ id: "one", text: "hello" },
|
||||
{ commit: (seq) => Effect.sync(() => received.push(`commit:${seq}`)) },
|
||||
)
|
||||
|
||||
expect(received).toEqual(["projector", "commit:0"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rolls back the synchronized event and projector when the local commit fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
const aggregateID = EventV2.ID.create()
|
||||
yield* db.run("CREATE TABLE IF NOT EXISTS event_commit_probe (value text NOT NULL)")
|
||||
yield* db.run("DELETE FROM event_commit_probe")
|
||||
yield* events.project(SyncMessage, () =>
|
||||
db.run("INSERT INTO event_commit_probe (value) VALUES ('projected')").pipe(Effect.orDie, Effect.asVoid),
|
||||
)
|
||||
|
||||
const exit = yield* events
|
||||
.publish(SyncMessage, { id: aggregateID, text: "hello" }, { commit: () => Effect.die("commit failed") })
|
||||
.pipe(Effect.exit)
|
||||
|
||||
expect(String(exit)).toContain("commit failed")
|
||||
expect(yield* db.all("SELECT value FROM event_commit_probe")).toEqual([])
|
||||
expect(yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).all()).toEqual([])
|
||||
expect(yield* db.select().from(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).all()).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects local commit hooks on live-only events", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const exit = yield* events.publish(Message, { text: "hello" }, { commit: () => Effect.void }).pipe(Effect.exit)
|
||||
|
||||
expect(String(exit)).toContain("Local commit hooks require a synchronized event")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("runs projectors before publishing to streams", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Message, Model } from "@opencode-ai/llm"
|
||||
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
|
@ -8,15 +8,15 @@ import { AgentAttachment, FileAttachment, ReferenceAttachment } from "@opencode-
|
|||
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||
import { DateTime } from "effect"
|
||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||
import { DateTime, Effect } from "effect"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
const created = DateTime.makeUnsafe(0)
|
||||
const id = (value: string) => SessionMessage.ID.make(`msg_${value}`)
|
||||
const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route })
|
||||
|
||||
describe("toLLMMessages", () => {
|
||||
test("maps every top-level V2 Session message type", () => {
|
||||
it.effect("maps every top-level V2 Session message type", () => Effect.sync(() => {
|
||||
const file = new FileAttachment({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" })
|
||||
const reference = new ReferenceAttachment({ name: "docs", kind: "local", uri: "file:///docs" })
|
||||
const messages = toLLMMessages(
|
||||
|
|
@ -85,31 +85,22 @@ describe("toLLMMessages", () => {
|
|||
[{ type: "text", text: "Shell command: pwd\n\n/project" }],
|
||||
[{ type: "text", text: "Summary of earlier conversation:\nEarlier work" }],
|
||||
])
|
||||
})
|
||||
}))
|
||||
|
||||
test("maps hidden Session context updates into chronological system messages", () => {
|
||||
it.effect("maps durable Session system messages into chronological system messages", () => Effect.sync(() => {
|
||||
expect(
|
||||
toLLMMessages(
|
||||
[
|
||||
{
|
||||
type: "system-context",
|
||||
parts: [
|
||||
{ key: SystemContext.Key.make("test/context"), text: "Updated context" },
|
||||
{ key: SystemContext.Key.make("test/other"), text: "Other context" },
|
||||
],
|
||||
},
|
||||
new SessionMessage.System({ id: id("system"), type: "system", text: "Updated context\n\nOther context", time: { created } }),
|
||||
],
|
||||
model,
|
||||
),
|
||||
).toEqual([
|
||||
Message.system([
|
||||
{ type: "text", text: "Updated context" },
|
||||
{ type: "text", text: "Other context" },
|
||||
]),
|
||||
Message.system("Updated context\n\nOther context"),
|
||||
])
|
||||
})
|
||||
}))
|
||||
|
||||
test("expands assistant tool calls and settled outcomes into canonical tool messages", () => {
|
||||
it.effect("expands assistant tool calls and settled outcomes into canonical tool messages", () => Effect.sync(() => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
new SessionMessage.Assistant({
|
||||
|
|
@ -264,9 +255,9 @@ describe("toLLMMessages", () => {
|
|||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
}))
|
||||
|
||||
test("restores OpenAI encrypted reasoning metadata", () => {
|
||||
it.effect("restores OpenAI encrypted reasoning metadata", () => Effect.sync(() => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
new SessionMessage.Assistant({
|
||||
|
|
@ -295,9 +286,9 @@ describe("toLLMMessages", () => {
|
|||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
])
|
||||
})
|
||||
}))
|
||||
|
||||
test("drops provider-native continuation metadata after a model switch", () => {
|
||||
it.effect("drops provider-native continuation metadata after a model switch", () => Effect.sync(() => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
new SessionMessage.Assistant({
|
||||
|
|
@ -395,5 +386,5 @@ describe("toLLMMessages", () => {
|
|||
providerMetadata: undefined,
|
||||
},
|
||||
])
|
||||
})
|
||||
}))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -21,10 +21,9 @@ import { SessionTable } from "@opencode-ai/core/session/sql"
|
|||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionSystemContext } from "@opencode-ai/core/session-system-context"
|
||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import path from "node:path"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
|
|
@ -62,17 +61,16 @@ const systemContext = Layer.succeed(
|
|||
SessionSystemContext.Service,
|
||||
SessionSystemContext.Service.of({
|
||||
load: () =>
|
||||
Effect.succeed({
|
||||
entries: [
|
||||
{
|
||||
_tag: "Available" as const,
|
||||
key: SystemContext.Key.make("test/context"),
|
||||
baseline: "Recorded context",
|
||||
update: "Recorded context",
|
||||
hash: Hash.sha256("Recorded context"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
Effect.succeed(
|
||||
SystemContext.make({
|
||||
key: SystemContext.Key.make("test/context"),
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
load: Effect.succeed("Recorded context"),
|
||||
baseline: String,
|
||||
update: (_previous, current) => current,
|
||||
removed: () => "Recorded context removed",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
const runner = SessionRunnerLLM.defaultLayer.pipe(
|
||||
|
|
@ -167,7 +165,6 @@ describe("SessionRunnerLLM recorded", () => {
|
|||
).toEqual([
|
||||
"session.next.prompt.admitted.1",
|
||||
"session.next.prompt.promoted.1",
|
||||
"session.next.context.initialized.1",
|
||||
"session.next.step.started.1",
|
||||
"session.next.text.started.1",
|
||||
"session.next.text.ended.1",
|
||||
|
|
|
|||
|
|
@ -32,11 +32,10 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
|||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
|
||||
import { NativeTool } from "@opencode-ai/core/tool/native"
|
||||
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionContextEpochTable, SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionSystemContext } from "@opencode-ai/core/session-system-context"
|
||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { Cause, DateTime, Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
|
|
@ -150,21 +149,22 @@ const systemContext = Layer.succeed(
|
|||
SessionSystemContext.Service,
|
||||
SessionSystemContext.Service.of({
|
||||
load: () =>
|
||||
Effect.sync(() => ({
|
||||
entries: systemRemoved
|
||||
? []
|
||||
: systemUnavailable
|
||||
? [{ _tag: "Unavailable" as const, key: systemContextKey }]
|
||||
Effect.succeed(
|
||||
SystemContext.combine(
|
||||
systemRemoved
|
||||
? []
|
||||
: [
|
||||
{
|
||||
_tag: "Available" as const,
|
||||
SystemContext.make({
|
||||
key: systemContextKey,
|
||||
baseline: systemBaseline,
|
||||
update: systemBaseline,
|
||||
hash: Hash.sha256(systemBaseline),
|
||||
},
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
load: Effect.sync(() => (systemUnavailable ? SystemContext.unavailable : systemBaseline)),
|
||||
baseline: String,
|
||||
update: (_previous, current) => current,
|
||||
removed: () => "System context source removed: test/context",
|
||||
}),
|
||||
],
|
||||
})),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
const runner = SessionRunnerLLM.layer.pipe(
|
||||
|
|
@ -568,16 +568,8 @@ describe("SessionRunnerLLM", () => {
|
|||
])
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
|
||||
expect(requests[1]?.messages.at(-1)?.content).toEqual([{ type: "text", text: "Changed context" }])
|
||||
expect(yield* session.messages({ sessionID })).toHaveLength(2)
|
||||
expect(yield* session.messages({ sessionID })).toHaveLength(3)
|
||||
const { db } = yield* Database.Service
|
||||
expect(
|
||||
yield* db
|
||||
.select({ id: EventTable.id })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.type, "session.next.context.initialized.1"))
|
||||
.all()
|
||||
.pipe(Effect.orDie),
|
||||
).toHaveLength(1)
|
||||
expect(
|
||||
yield* db
|
||||
.select({ id: EventTable.id })
|
||||
|
|
@ -587,11 +579,11 @@ describe("SessionRunnerLLM", () => {
|
|||
.pipe(Effect.orDie),
|
||||
).toHaveLength(1)
|
||||
yield* replaySessionProjection(sessionID)
|
||||
expect(yield* session.messages({ sessionID })).toHaveLength(2)
|
||||
expect(yield* session.messages({ sessionID })).toHaveLength(3)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("admits removed context as a hidden chronological tombstone", () =>
|
||||
it.effect("admits removed context as a chronological System message", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
|
|
@ -606,13 +598,13 @@ describe("SessionRunnerLLM", () => {
|
|||
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
|
||||
expect(requests[1]?.messages.at(-1)?.content).toEqual([
|
||||
{ type: "text", text: "System context component removed: test/context" },
|
||||
{ type: "text", text: "System context source removed: test/context" },
|
||||
])
|
||||
expect(yield* session.messages({ sessionID })).toHaveLength(2)
|
||||
expect(yield* session.messages({ sessionID })).toHaveLength(3)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces the baseline lazily after a model switch and drops prior hidden updates", () =>
|
||||
it.effect("replaces the baseline lazily after a model switch and drops prior System updates", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
|
|
@ -641,27 +633,16 @@ describe("SessionRunnerLLM", () => {
|
|||
])
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
|
||||
expect(requests[2]?.messages.map((message) => message.role)).toEqual(["user", "user", "user"])
|
||||
const { db } = yield* Database.Service
|
||||
expect(
|
||||
yield* db
|
||||
.select({ id: EventTable.id })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.type, "session.next.context.replaced.1"))
|
||||
.all()
|
||||
.pipe(Effect.orDie),
|
||||
).toHaveLength(1)
|
||||
expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([
|
||||
"user",
|
||||
"user",
|
||||
"model-switched",
|
||||
"user",
|
||||
])
|
||||
yield* replaySessionProjection(sessionID)
|
||||
expect(yield* session.messages({ sessionID })).toHaveLength(4)
|
||||
expect(yield* session.messages({ sessionID })).toHaveLength(5)
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fourth" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
expect(
|
||||
yield* db
|
||||
.select({ id: EventTable.id })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.type, "session.next.context.replaced.1"))
|
||||
.all()
|
||||
.pipe(Effect.orDie),
|
||||
).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -693,15 +674,39 @@ describe("SessionRunnerLLM", () => {
|
|||
["Initial context"],
|
||||
["Replacement context"],
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("advances a pending replacement to the latest invalidation boundary", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
model: { id: ModelV2.ID.make("replacement-1"), providerID: ProviderV2.ID.make("fake") },
|
||||
})
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(2),
|
||||
model: { id: ModelV2.ID.make("replacement-2"), providerID: ProviderV2.ID.make("fake") },
|
||||
})
|
||||
const latest = yield* events.sequence(sessionID)
|
||||
|
||||
expect(
|
||||
yield* db
|
||||
.select({ id: EventTable.id })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.type, "session.next.context.replaced.1"))
|
||||
.all()
|
||||
.select({ replacementSeq: SessionContextEpochTable.replacement_seq })
|
||||
.from(SessionContextEpochTable)
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie),
|
||||
).toHaveLength(1)
|
||||
).toEqual({ replacementSeq: latest })
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -792,26 +797,41 @@ describe("SessionRunnerLLM", () => {
|
|||
["Initial context"],
|
||||
["Replacement context"],
|
||||
])
|
||||
const { db } = yield* Database.Service
|
||||
expect(
|
||||
yield* db
|
||||
.select({ id: EventTable.id })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.type, "session.next.context.replaced.1"))
|
||||
.all()
|
||||
.pipe(Effect.orDie),
|
||||
).toHaveLength(1)
|
||||
yield* replaySessionProjection(sessionID)
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
expect(
|
||||
yield* db
|
||||
.select({ id: EventTable.id })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.type, "session.next.context.replaced.1"))
|
||||
.all()
|
||||
.pipe(Effect.orDie),
|
||||
).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves effective System updates while compaction replacement is blocked", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
systemBaseline = "Changed context"
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
yield* events.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
reason: "manual",
|
||||
})
|
||||
yield* events.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(2),
|
||||
text: "summary",
|
||||
})
|
||||
systemUnavailable = true
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Initial context"])
|
||||
expect(requests.at(-1)?.messages.some((message) => message.role === "system" && message.content[0]?.type === "text" && message.content[0].text === "Changed context")).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -30,41 +30,48 @@ describe("SessionSystemContext", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* TestClock.setTime(timestamp)
|
||||
const context = yield* SessionSystemContext.Service
|
||||
const initialized = SystemContext.initialize(yield* context.load())
|
||||
const initialized = yield* SystemContext.initialize(yield* context.load())
|
||||
|
||||
expect(initialized.baseline).toEqual([
|
||||
{
|
||||
key: SystemContext.Key.make("core/environment"),
|
||||
text: [
|
||||
"Here is some useful information about the environment you are running in:",
|
||||
"<env>",
|
||||
` Working directory: ${directory}`,
|
||||
` Workspace root folder: ${projectDirectory}`,
|
||||
" Is directory a git repo: yes",
|
||||
` Platform: ${process.platform}`,
|
||||
"</env>",
|
||||
].join("\n"),
|
||||
},
|
||||
{ key: SystemContext.Key.make("core/date"), text: `Today's date: ${localDate(timestamp)}` },
|
||||
])
|
||||
expect(initialized.baseline).toBe(
|
||||
[
|
||||
"Here is some useful information about the environment you are running in:",
|
||||
"<env>",
|
||||
` Working directory: ${directory}`,
|
||||
` Workspace root folder: ${projectDirectory}`,
|
||||
" Is directory a git repo: yes",
|
||||
` Platform: ${process.platform}`,
|
||||
"</env>",
|
||||
"",
|
||||
`Today's date: ${localDate(timestamp)}`,
|
||||
].join("\n"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("refreshes the date without repeating unchanged environment context", () =>
|
||||
it.effect("reconciles the date without repeating unchanged environment context", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* TestClock.setTime(timestamp)
|
||||
const context = yield* SessionSystemContext.Service
|
||||
const initialized = SystemContext.initialize(yield* context.load())
|
||||
const initialized = yield* SystemContext.initialize(yield* context.load())
|
||||
|
||||
yield* TestClock.setTime(timestamp + 24 * 60 * 60 * 1000)
|
||||
const refreshed = SystemContext.refresh(yield* context.load(), initialized.checkpoint)
|
||||
const refreshed = yield* SystemContext.reconcile(yield* context.load(), initialized.snapshot)
|
||||
|
||||
expect(refreshed.changes).toEqual([
|
||||
{
|
||||
key: SystemContext.Key.make("core/date"),
|
||||
text: `Today's date is now: ${localDate(timestamp + 24 * 60 * 60 * 1000)}`,
|
||||
},
|
||||
])
|
||||
expect(refreshed).toMatchObject({
|
||||
_tag: "Updated",
|
||||
text: `Today's date is now: ${localDate(timestamp + 24 * 60 * 60 * 1000)}`,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not update again within the same local calendar day", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* TestClock.setTime(timestamp)
|
||||
const context = yield* SessionSystemContext.Service
|
||||
const initialized = yield* SystemContext.initialize(yield* context.load())
|
||||
|
||||
yield* TestClock.setTime(timestamp + 60 * 60 * 1000)
|
||||
expect(yield* SystemContext.reconcile(yield* context.load(), initialized.snapshot)).toEqual({ _tag: "Unchanged" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,213 +1,300 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Cause, Effect, Exit, Schema } from "effect"
|
||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
const key = SystemContext.Key.make
|
||||
const stringContext = (input: {
|
||||
key: string
|
||||
value: string | SystemContext.Unavailable
|
||||
baseline?: (value: string) => string
|
||||
update?: (previous: string, current: string) => string
|
||||
removed?: (value: string) => string
|
||||
}) =>
|
||||
SystemContext.make({
|
||||
key: key(input.key),
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
load: Effect.succeed(input.value),
|
||||
baseline: input.baseline ?? String,
|
||||
update: input.update ?? ((_previous, current) => current),
|
||||
removed: input.removed,
|
||||
})
|
||||
|
||||
describe("SystemContext", () => {
|
||||
test("loads one coherent sample and initializes a deterministic baseline", async () => {
|
||||
let loads = 0
|
||||
const context = SystemContext.struct({
|
||||
date: SystemContext.value({
|
||||
it.effect("stores the canonical JSON encoding of the loaded value", () =>
|
||||
Effect.gen(function* () {
|
||||
const context = SystemContext.make({
|
||||
key: key("core/date"),
|
||||
codec: Schema.toCodecJson(Schema.DateFromString),
|
||||
load: Effect.succeed(new Date("2026-06-03T12:00:00.000Z")),
|
||||
baseline: (date) => date.toISOString(),
|
||||
update: (_previous, date) => date.toISOString(),
|
||||
removed: () => "Date removed",
|
||||
})
|
||||
|
||||
expect((yield* SystemContext.initialize(context)).snapshot["core/date"].value).toBe("2026-06-03T12:00:00.000Z")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("loads once and initializes a baseline with a structured snapshot", () =>
|
||||
Effect.gen(function* () {
|
||||
let loads = 0
|
||||
const context = SystemContext.combine([
|
||||
SystemContext.make({
|
||||
key: key("core/date"),
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
load: Effect.sync(() => {
|
||||
loads++
|
||||
return "2026-06-03"
|
||||
}),
|
||||
baseline: (date) => `Today's date is ${date}.`,
|
||||
update: (previous, current) => `The date changed from ${previous} to ${current}.`,
|
||||
removed: () => "The date was removed.",
|
||||
}),
|
||||
stringContext({ key: "core/location", value: "/repo", baseline: (value) => `Directory: ${value}` }),
|
||||
])
|
||||
|
||||
expect(yield* SystemContext.initialize(context)).toEqual({
|
||||
baseline: "Today's date is 2026-06-03.\n\nDirectory: /repo",
|
||||
snapshot: {
|
||||
"core/date": { value: "2026-06-03", removed: "The date was removed." },
|
||||
"core/location": { value: "/repo" },
|
||||
},
|
||||
})
|
||||
expect(loads).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("renders updates only after a structured value changes", () =>
|
||||
Effect.gen(function* () {
|
||||
const previous = {
|
||||
"core/date": { value: "2026-06-03", removed: "The date was removed." },
|
||||
"core/location": { value: "/repo", removed: "Removed: /repo" },
|
||||
}
|
||||
const changed = SystemContext.combine([
|
||||
stringContext({
|
||||
key: "core/date",
|
||||
value: "2026-06-04",
|
||||
update: (before, current) => `The date changed from ${before} to ${current}.`,
|
||||
removed: () => "The date was removed.",
|
||||
}),
|
||||
stringContext({ key: "core/location", value: "/repo" }),
|
||||
])
|
||||
|
||||
expect(yield* SystemContext.reconcile(changed, previous)).toEqual({
|
||||
_tag: "Updated",
|
||||
text: "The date changed from 2026-06-03 to 2026-06-04.",
|
||||
snapshot: {
|
||||
"core/date": { value: "2026-06-04", removed: "The date was removed." },
|
||||
"core/location": { value: "/repo", removed: "Removed: /repo" },
|
||||
},
|
||||
})
|
||||
|
||||
expect(
|
||||
yield* SystemContext.reconcile(
|
||||
SystemContext.combine([
|
||||
stringContext({ key: "core/date", value: "2026-06-03", removed: () => "The date was removed." }),
|
||||
stringContext({ key: "core/location", value: "/repo" }),
|
||||
]),
|
||||
previous,
|
||||
),
|
||||
).toEqual({ _tag: "Unchanged" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the baseline for a newly added source", () =>
|
||||
Effect.gen(function* () {
|
||||
const context = stringContext({
|
||||
key: "core/skills",
|
||||
value: "effect",
|
||||
baseline: (skill) => `Available skill: ${skill}`,
|
||||
})
|
||||
|
||||
expect(yield* SystemContext.reconcile(context, {})).toEqual({
|
||||
_tag: "Updated",
|
||||
text: "Available skill: effect",
|
||||
snapshot: { "core/skills": { value: "effect" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains admitted snapshots while a source is temporarily unavailable", () =>
|
||||
Effect.gen(function* () {
|
||||
const previous = { "core/remote": { value: "instructions", removed: "Instructions removed" } }
|
||||
const context = stringContext({ key: "core/remote", value: SystemContext.unavailable })
|
||||
|
||||
expect(yield* SystemContext.reconcile(context, previous)).toEqual({ _tag: "Unchanged" })
|
||||
expect(yield* SystemContext.replace(context, previous)).toEqual({ _tag: "ReplacementBlocked" })
|
||||
expect(yield* SystemContext.replace(context, {})).toMatchObject({ _tag: "Replaced" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits unavailable sources from an initial baseline", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* SystemContext.initialize(stringContext({ key: "core/remote", value: SystemContext.unavailable }))).toEqual({
|
||||
baseline: "",
|
||||
snapshot: {},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits the previously stored removal message", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* SystemContext.reconcile(SystemContext.empty, {
|
||||
"core/instructions": { value: "contents", removed: "Instructions removed; stop applying them." },
|
||||
}),
|
||||
).toEqual({
|
||||
_tag: "Updated",
|
||||
text: "Instructions removed; stop applying them.",
|
||||
snapshot: {},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("requests replacement when a source without removal text disappears", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* SystemContext.reconcile(SystemContext.empty, { "core/date": { value: "2026-06-04" } })).toMatchObject({
|
||||
_tag: "Replaced",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("renders multiple removals in stable key order", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* SystemContext.reconcile(SystemContext.empty, {
|
||||
"core/z": { value: "z", removed: "Removed z" },
|
||||
"core/a": { value: "a", removed: "Removed a" },
|
||||
}),
|
||||
).toMatchObject({ _tag: "Updated", text: "Removed a\n\nRemoved z" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects empty model-visible renderings", () =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* SystemContext.initialize(
|
||||
stringContext({ key: "core/empty", value: "value", baseline: () => "" }),
|
||||
).pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("rendered an empty baseline")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("requests replacement when a stored value no longer decodes", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* SystemContext.reconcile(stringContext({ key: "core/date", value: "2026-06-04" }), {
|
||||
"core/date": { value: 42, removed: "Date removed" },
|
||||
}),
|
||||
).toMatchObject({ _tag: "Replaced" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces from one coherent source observation", () =>
|
||||
Effect.gen(function* () {
|
||||
let loads = 0
|
||||
const context = SystemContext.make({
|
||||
key: key("core/date"),
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
load: Effect.sync(() => {
|
||||
loads++
|
||||
return { baseline: "Today's date is 2026-06-03.", update: "The current date is 2026-06-03." }
|
||||
return "2026-06-04"
|
||||
}),
|
||||
}),
|
||||
location: SystemContext.value({
|
||||
key: key("core/location"),
|
||||
load: Effect.succeed({ baseline: "Working directory: /repo", update: "The working directory is /repo." }),
|
||||
}),
|
||||
})
|
||||
baseline: String,
|
||||
update: (_previous, current) => current,
|
||||
})
|
||||
|
||||
const initialized = SystemContext.initialize(await Effect.runPromise(SystemContext.load(context)))
|
||||
expect(yield* SystemContext.reconcile(context, { "core/date": { value: 42 } })).toMatchObject({
|
||||
_tag: "Replaced",
|
||||
generation: { baseline: "2026-06-04" },
|
||||
})
|
||||
expect(loads).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
expect(loads).toBe(1)
|
||||
expect(initialized).toEqual({
|
||||
baseline: [
|
||||
{ key: key("core/date"), text: "Today's date is 2026-06-03." },
|
||||
{ key: key("core/location"), text: "Working directory: /repo" },
|
||||
],
|
||||
checkpoint: {
|
||||
"core/date": Hash.sha256("The current date is 2026-06-03."),
|
||||
"core/location": Hash.sha256("The working directory is /repo."),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("emits changed and newly registered components in declaration order", async () => {
|
||||
const context = SystemContext.struct({
|
||||
date: SystemContext.value({
|
||||
key: key("core/date"),
|
||||
load: Effect.succeed({ baseline: "Today's date is 2026-06-04.", update: "The current date is 2026-06-04." }),
|
||||
}),
|
||||
location: SystemContext.value({
|
||||
key: key("core/location"),
|
||||
load: Effect.succeed({ baseline: "Working directory: /repo", update: "The working directory is /repo." }),
|
||||
}),
|
||||
skills: SystemContext.value({
|
||||
key: key("core/skills"),
|
||||
load: Effect.succeed({ baseline: "Available skills: effect", update: "Available skills: effect" }),
|
||||
}),
|
||||
})
|
||||
|
||||
const refreshed = SystemContext.refresh(await Effect.runPromise(SystemContext.load(context)), {
|
||||
"core/date": Hash.sha256("The current date is 2026-06-03."),
|
||||
"core/location": Hash.sha256("The working directory is /repo."),
|
||||
})
|
||||
|
||||
expect(refreshed).toEqual({
|
||||
changes: [
|
||||
{ key: key("core/date"), text: "The current date is 2026-06-04." },
|
||||
{ key: key("core/skills"), text: "Available skills: effect" },
|
||||
],
|
||||
checkpoint: {
|
||||
"core/date": Hash.sha256("The current date is 2026-06-04."),
|
||||
"core/location": Hash.sha256("The working directory is /repo."),
|
||||
"core/skills": Hash.sha256("Available skills: effect"),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("omits unavailable initial context and admits it after its first successful load", async () => {
|
||||
let available = false
|
||||
const context = SystemContext.struct({
|
||||
remote: SystemContext.value({
|
||||
key: key("core/remote-instructions"),
|
||||
load: Effect.sync(() =>
|
||||
available
|
||||
? { baseline: "Remote instructions: available", update: "Remote instructions are now available." }
|
||||
: SystemContext.unavailable,
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
const initialized = SystemContext.initialize(await Effect.runPromise(SystemContext.load(context)))
|
||||
available = true
|
||||
const refreshed = SystemContext.refresh(
|
||||
await Effect.runPromise(SystemContext.load(context)),
|
||||
initialized.checkpoint,
|
||||
)
|
||||
|
||||
expect(initialized).toEqual({ baseline: [], checkpoint: {} })
|
||||
expect(refreshed.changes).toEqual([
|
||||
{ key: key("core/remote-instructions"), text: "Remote instructions are now available." },
|
||||
])
|
||||
})
|
||||
|
||||
test("retains an existing checkpoint while context is unavailable", async () => {
|
||||
const previous = { "core/remote-instructions": Hash.sha256("Remote instructions: old") }
|
||||
const context = SystemContext.struct({
|
||||
remote: SystemContext.value({
|
||||
key: key("core/remote-instructions"),
|
||||
load: Effect.succeed(SystemContext.unavailable),
|
||||
}),
|
||||
})
|
||||
|
||||
const refreshed = SystemContext.refresh(await Effect.runPromise(SystemContext.load(context)), previous)
|
||||
|
||||
expect(refreshed).toEqual({ changes: [], checkpoint: previous })
|
||||
})
|
||||
|
||||
test("blocks replacement while admitted context is unavailable", async () => {
|
||||
const previous = { "core/remote-instructions": Hash.sha256("Remote instructions: old") }
|
||||
const snapshot = await Effect.runPromise(
|
||||
SystemContext.load(
|
||||
SystemContext.struct({
|
||||
remote: SystemContext.value({
|
||||
key: key("core/remote-instructions"),
|
||||
load: Effect.succeed(SystemContext.unavailable),
|
||||
}),
|
||||
it.effect("does not render discarded updates while replacing", () =>
|
||||
Effect.gen(function* () {
|
||||
let updates = 0
|
||||
const context = SystemContext.combine([
|
||||
stringContext({
|
||||
key: "core/date",
|
||||
value: "2026-06-04",
|
||||
update: () => {
|
||||
updates++
|
||||
return "updated"
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
stringContext({ key: "core/location", value: "/repo" }),
|
||||
])
|
||||
|
||||
expect(SystemContext.replacementBlocked(snapshot, previous)).toBe(true)
|
||||
expect(SystemContext.replacementBlocked(snapshot, {})).toBe(false)
|
||||
})
|
||||
expect(
|
||||
yield* SystemContext.reconcile(context, {
|
||||
"core/date": { value: "2026-06-03" },
|
||||
"core/location": { value: 42 },
|
||||
}),
|
||||
).toMatchObject({ _tag: "Replaced" })
|
||||
expect(updates).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
test("emits tombstones and drops checkpoints for removed components", async () => {
|
||||
const context = SystemContext.struct({
|
||||
date: SystemContext.value({
|
||||
key: key("core/date"),
|
||||
load: Effect.succeed({ baseline: "Today's date is 2026-06-03.", update: "The current date is 2026-06-03." }),
|
||||
}),
|
||||
})
|
||||
it.effect("blocks an incompatible replacement while another admitted source is unavailable", () =>
|
||||
Effect.gen(function* () {
|
||||
const previous = {
|
||||
"core/date": { value: 42, removed: "Date removed" },
|
||||
"core/remote": { value: "instructions", removed: "Instructions removed" },
|
||||
}
|
||||
const context = SystemContext.combine([
|
||||
stringContext({ key: "core/date", value: "2026-06-04" }),
|
||||
stringContext({ key: "core/remote", value: SystemContext.unavailable }),
|
||||
])
|
||||
|
||||
const refreshed = SystemContext.refresh(await Effect.runPromise(SystemContext.load(context)), {
|
||||
"core/date": Hash.sha256("The current date is 2026-06-03."),
|
||||
"plugin/removed": Hash.sha256("Removed plugin context"),
|
||||
})
|
||||
expect(yield* SystemContext.reconcile(context, previous)).toEqual({ _tag: "ReplacementBlocked" })
|
||||
expect(yield* SystemContext.replace(context, previous)).toEqual({ _tag: "ReplacementBlocked" })
|
||||
}),
|
||||
)
|
||||
|
||||
expect(refreshed).toEqual({
|
||||
changes: [{ key: key("plugin/removed"), text: "System context component removed: plugin/removed" }],
|
||||
checkpoint: { "core/date": Hash.sha256("The current date is 2026-06-03.") },
|
||||
})
|
||||
})
|
||||
it.effect("rejects duplicate source keys", () =>
|
||||
Effect.sync(() => {
|
||||
expect(() =>
|
||||
SystemContext.combine([
|
||||
stringContext({ key: "core/date", value: "one" }),
|
||||
stringContext({ key: "core/date", value: "two" }),
|
||||
]),
|
||||
).toThrow(new SystemContext.DuplicateKeyError({ key: key("core/date") }))
|
||||
}),
|
||||
)
|
||||
|
||||
test("ignores inherited checkpoint properties", async () => {
|
||||
const context = SystemContext.struct({
|
||||
date: SystemContext.value({
|
||||
key: key("core/date"),
|
||||
load: Effect.succeed({ baseline: "Today's date is 2026-06-03.", update: "The current date is 2026-06-03." }),
|
||||
}),
|
||||
})
|
||||
const previous = Object.create({
|
||||
"core/date": Hash.sha256("The current date is 2026-06-03."),
|
||||
}) as SystemContext.Checkpoint
|
||||
it.effect("combines contexts in order", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
(yield* SystemContext.initialize(
|
||||
SystemContext.combine([
|
||||
stringContext({ key: "core/date", value: "date" }),
|
||||
stringContext({ key: "core/location", value: "location" }),
|
||||
]),
|
||||
)).baseline,
|
||||
).toBe("date\n\nlocation")
|
||||
}),
|
||||
)
|
||||
|
||||
const refreshed = SystemContext.refresh(await Effect.runPromise(SystemContext.load(context)), previous)
|
||||
it.effect("requires namespaced source keys", () =>
|
||||
Effect.sync(() => {
|
||||
const decodeKey = Schema.decodeUnknownSync(SystemContext.Key)
|
||||
|
||||
expect(refreshed.changes).toEqual([{ key: key("core/date"), text: "The current date is 2026-06-03." }])
|
||||
expect(Object.hasOwn(refreshed.checkpoint, "core/date")).toBe(true)
|
||||
})
|
||||
expect(decodeKey("core/date")).toBe(key("core/date"))
|
||||
expect(() => decodeKey("date")).toThrow()
|
||||
}),
|
||||
)
|
||||
|
||||
test("preserves unexpected loader failures", async () => {
|
||||
const context = SystemContext.struct({
|
||||
broken: SystemContext.value({
|
||||
key: key("plugin/broken"),
|
||||
load: Effect.fail("broken loader"),
|
||||
}),
|
||||
})
|
||||
it.effect("requires namespaced durable snapshot keys", () =>
|
||||
Effect.sync(() => {
|
||||
const decodeSnapshot = Schema.decodeUnknownSync(SystemContext.Snapshot)
|
||||
|
||||
await expect(Effect.runPromise(SystemContext.load(context))).rejects.toBe("broken loader")
|
||||
})
|
||||
|
||||
test("rejects duplicate component keys", () => {
|
||||
expect(() =>
|
||||
SystemContext.struct({
|
||||
one: SystemContext.value({ key: key("core/date"), load: Effect.succeed({ baseline: "one", update: "one" }) }),
|
||||
two: SystemContext.value({ key: key("core/date"), load: Effect.succeed({ baseline: "two", update: "two" }) }),
|
||||
}),
|
||||
).toThrow(new SystemContext.DuplicateKeyError({ key: key("core/date") }))
|
||||
})
|
||||
|
||||
test("rejects duplicate component keys at the interpreter boundary", async () => {
|
||||
const component = SystemContext.value({
|
||||
key: key("core/date"),
|
||||
load: Effect.succeed({ baseline: "date", update: "date" }),
|
||||
})
|
||||
const context: SystemContext.SystemContext = { components: [component, component] }
|
||||
|
||||
await expect(Effect.runPromise(SystemContext.load(context))).rejects.toBeInstanceOf(SystemContext.DuplicateKeyError)
|
||||
})
|
||||
|
||||
test("requires namespaced component keys", () => {
|
||||
const decode = Schema.decodeUnknownSync(SystemContext.Key)
|
||||
|
||||
expect(decode("core/date")).toBe(key("core/date"))
|
||||
expect(() => decode("date")).toThrow()
|
||||
expect(() => decode("core/")).toThrow()
|
||||
})
|
||||
|
||||
test("requires namespaced checkpoint keys", () => {
|
||||
const decode = Schema.decodeUnknownSync(SystemContext.CheckpointSchema)
|
||||
const valid = JSON.parse('{"core/date":"hash"}')
|
||||
const invalid = JSON.parse('{"date":"hash"}')
|
||||
|
||||
expect(decode(valid)).toEqual(valid)
|
||||
expect(() => decode(invalid)).toThrow()
|
||||
})
|
||||
expect(Object.keys(decodeSnapshot({ "core/date": { value: "date" } }))).toEqual(["core/date"])
|
||||
expect(() => decodeSnapshot({ date: { value: "date" } })).toThrow()
|
||||
expect(() => decodeSnapshot({ "core/date": { value: "date", removed: "" } })).toThrow()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue