fix(core): finalize v2 session context epochs

This commit is contained in:
Kit Langton 2026-06-04 21:17:05 -04:00
commit cd812e2045
33 changed files with 1245 additions and 791 deletions

View file

@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { $ } from "bun"
import { fileURLToPath } from "url"
import path from "path"
@ -7,6 +7,7 @@ import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { Effect, Layer } from "effect"
import { eq, inArray, sql } from "drizzle-orm"
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
import { migrations } from "@opencode-ai/core/database/migration.gen"
import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage"
import normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths"
import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/migration/20260603040000_session_message_projection_order"
@ -17,43 +18,45 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionSchema } from "@opencode-ai/core/session/schema"
import { SessionTable } from "@opencode-ai/core/session/sql"
import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata"
import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
import { Database } from "@opencode-ai/core/database/database"
import { tmpdir } from "./fixture/tmpdir"
const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
Effect.runPromise(
effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Effect.scoped),
)
import { testEffect } from "./lib/effect"
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
const it = testEffect(SqliteClient.layer({ filename: ":memory:", disableWAL: true }))
describe("DatabaseMigration", () => {
test("serializes concurrent embedded initialization for one database path", async () => {
await using tmp = await tmpdir()
const filename = path.join(tmp.path, "embedded.sqlite")
const layers = [Database.layerFromPath(filename), Database.layerFromPath(filename)]
it.effect("serializes concurrent embedded initialization for one database path", () =>
Effect.promise(async () => {
await using tmp = await tmpdir()
const filename = path.join(tmp.path, "embedded.sqlite")
const layers = [Database.layerFromPath(filename), Database.layerFromPath(filename)]
await Effect.runPromise(
Effect.all(
layers.map((layer) => Effect.scoped(Layer.build(layer))),
{ concurrency: "unbounded" },
),
)
})
await Effect.runPromise(
Effect.all(
layers.map((layer) => Effect.scoped(Layer.build(layer))),
{ concurrency: "unbounded" },
),
)
}),
)
if (process.platform === "linux") {
test("declared schema has no ungenerated migrations", async () => {
const result = await $`bun ${fileURLToPath(new URL("../script/migration.ts", import.meta.url))} --check`
.quiet()
.nothrow()
expect(result.exitCode, result.stderr.toString()).toBe(0)
expect(result.stdout.toString()).toContain("No schema changes, nothing to migrate")
}, 30_000)
it.effect(
"declared schema has no ungenerated migrations",
() =>
Effect.promise(async () => {
const result = await $`bun ${fileURLToPath(new URL("../script/migration.ts", import.meta.url))} --check`
.quiet()
.nothrow()
expect(result.exitCode, result.stderr.toString()).toBe(0)
expect(result.stdout.toString()).toContain("No schema changes, nothing to migrate")
}),
30_000,
)
}
test("applies tracked migrations to an empty database", async () => {
await run(
Effect.gen(function* () {
it.effect("applies tracked migrations to an empty database", () =>
Effect.gen(function* () {
const db = yield* makeDb
yield* DatabaseMigration.apply(db)
@ -66,10 +69,7 @@ describe("DatabaseMigration", () => {
expect(
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_context_epoch'`),
).toEqual({ name: "session_context_epoch" })
expect(
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_context_message'`),
).toEqual({ name: "session_context_message" })
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 31 })
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length })
expect(
yield* db.all(
sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_input_session_admitted_seq_idx', 'session_input_session_promoted_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`,
@ -84,13 +84,11 @@ describe("DatabaseMigration", () => {
{ name: "session_message_session_time_created_id_idx" },
{ name: "session_message_session_type_seq_idx" },
])
}),
)
})
}),
)
test("resets beta history and rebuilds event-sourced Session input storage", async () => {
await run(
Effect.gen(function* () {
it.effect("resets beta history and rebuilds event-sourced Session input storage", () =>
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, workspace_id text)`)
yield* db.run(sql`CREATE TABLE workspace (id text PRIMARY KEY)`)
@ -160,13 +158,11 @@ describe("DatabaseMigration", () => {
expect.objectContaining({ name: "session_input_session_promoted_seq_idx", unique: 1 }),
expect.objectContaining({ name: "session_input_session_admitted_seq_idx", unique: 1 }),
])
}),
)
})
}),
)
test("resets incompatible projected Session messages before adding sequence order", async () => {
await run(
Effect.gen(function* () {
it.effect("resets incompatible projected Session messages before adding sequence order", () =>
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
yield* db.run(
@ -215,13 +211,11 @@ describe("DatabaseMigration", () => {
sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('fresh_projection', 'session', 'user', 7, 2, 2, '{}')`,
)
expect(yield* db.get(sql`SELECT id, seq FROM session_message`)).toEqual({ id: "fresh_projection", seq: 7 })
}),
)
})
}),
)
test("runs session usage backfill in order with schema changes", async () => {
await run(
Effect.gen(function* () {
it.effect("runs session usage backfill in order with schema changes", () =>
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, time_updated integer NOT NULL)`)
yield* db.run(sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, data text NOT NULL)`)
@ -244,13 +238,11 @@ describe("DatabaseMigration", () => {
tokens_cache_read: 5,
tokens_cache_write: 6,
})
}),
)
})
}),
)
test("normalizes Windows storage paths and leaves POSIX paths untouched", async () => {
await run(
Effect.gen(function* () {
it.effect("normalizes Windows storage paths and leaves POSIX paths untouched", () =>
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE project (id text PRIMARY KEY, worktree text NOT NULL, sandboxes text NOT NULL)`)
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, directory text NOT NULL, path text)`)
@ -295,14 +287,12 @@ describe("DatabaseMigration", () => {
directory: "/home/me/we\\ird",
path: "src\\weird",
})
}),
)
})
}),
)
test("maps native Windows paths through database columns", async () => {
if (process.platform !== "win32") return
await run(
Effect.gen(function* () {
it.effect("maps native Windows paths through database columns", () => {
if (process.platform !== "win32") return Effect.void
return Effect.gen(function* () {
const db = yield* makeDb
yield* DatabaseMigration.apply(db)
const projectID = ProjectV2.ID.make("codec_project")
@ -405,13 +395,11 @@ describe("DatabaseMigration", () => {
expect(() =>
Effect.runSync(db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get()),
).toThrow()
}),
)
})
})
test("imports existing drizzle migration state", async () => {
await run(
Effect.gen(function* () {
it.effect("imports existing drizzle migration state", () =>
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(
sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`,
@ -424,13 +412,11 @@ describe("DatabaseMigration", () => {
yield* DatabaseMigration.applyOnly(db, [])
expect(yield* db.get(sql`SELECT id FROM migration`)).toEqual({ id: "20260127222353_familiar_lady_ursula" })
}),
)
})
}),
)
test("does not replay a migrated session metadata column", async () => {
await run(
Effect.gen(function* () {
it.effect("does not replay a migrated session metadata column", () =>
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, metadata text)`)
yield* db.run(
@ -444,13 +430,11 @@ describe("DatabaseMigration", () => {
yield* DatabaseMigration.applyOnly(db, [sessionMetadataMigration])
expect(yield* db.all(sql`SELECT id FROM migration`)).toEqual([{ id: "20260511173437_session-metadata" }])
}),
)
})
}),
)
test("accepts the temporary replacement session metadata migration id", async () => {
await run(
Effect.gen(function* () {
it.effect("accepts the temporary replacement session metadata migration id", () =>
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, metadata text)`)
yield* db.run(sql`CREATE TABLE migration (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`)
@ -462,13 +446,11 @@ describe("DatabaseMigration", () => {
{ id: "20260511173437_session-metadata" },
{ id: "20260530232709_lovely_romulus" },
])
}),
)
})
}),
)
test("skips drizzle import when migration table already has state", async () => {
await run(
Effect.gen(function* () {
it.effect("skips drizzle import when migration table already has state", () =>
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE migration (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`)
yield* db.run(sql`INSERT INTO migration (id, time_completed) VALUES ('existing', 1)`)
@ -483,7 +465,6 @@ describe("DatabaseMigration", () => {
yield* DatabaseMigration.applyOnly(db, [])
expect(yield* db.all(sql`SELECT id FROM migration ORDER BY id`)).toEqual([{ id: "existing" }])
}),
)
})
}),
)
})

View file

@ -194,11 +194,12 @@ describe("EventV2", () => {
Effect.gen(function* () {
const events = yield* EventV2.Service
const received = new Array<string>()
const aggregateID = EventV2.ID.create()
yield* events.project(SyncMessage, () => Effect.sync(() => received.push("projector")))
yield* events.publish(
SyncMessage,
{ id: "one", text: "hello" },
{ id: aggregateID, text: "hello" },
{ commit: (seq) => Effect.sync(() => received.push(`commit:${seq}`)) },
)
@ -224,7 +225,9 @@ describe("EventV2", () => {
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([])
expect(
yield* db.select().from(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).all(),
).toEqual([])
}),
)

View file

@ -117,7 +117,9 @@ describe("InstructionContext", () => {
const failingFS = Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) })),
Effect.map((fs) =>
FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) }),
),
),
).pipe(Layer.provide(FSUtil.defaultLayer))
const context = yield* SystemContextRegistry.Service.pipe(
@ -126,10 +128,7 @@ describe("InstructionContext", () => {
Effect.provide(failingFS),
Effect.provide(Global.layerWith({ config: "/global" })),
Effect.provide(
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
),
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))),
),
)
@ -165,10 +164,7 @@ describe("InstructionContext", () => {
Effect.provide(racingFS),
Effect.provide(Global.layerWith({ config: "/global" })),
Effect.provide(
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
),
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))),
),
)

View file

@ -16,375 +16,388 @@ const id = (value: string) => SessionMessage.ID.make(`msg_${value}`)
const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route })
describe("toLLMMessages", () => {
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(
[
new SessionMessage.AgentSwitched({
id: id("agent"),
type: "agent-switched",
agent: "build",
time: { created },
}),
new SessionMessage.ModelSwitched({
id: id("model"),
type: "model-switched",
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
time: { created },
}),
new SessionMessage.User({
id: id("user"),
type: "user",
text: "Inspect this image",
files: [file],
agents: [new AgentAttachment({ name: "build" })],
references: [reference],
time: { created },
}),
new SessionMessage.Synthetic({
id: id("synthetic"),
type: "synthetic",
sessionID: SessionV2.ID.make("ses_translate"),
text: "Synthetic context",
time: { created },
}),
new SessionMessage.Shell({
id: id("shell"),
type: "shell",
callID: "shell-1",
command: "pwd",
output: "/project",
time: { created, completed: created },
}),
new SessionMessage.Compaction({
id: id("compaction"),
type: "compaction",
reason: "auto",
summary: "Earlier work",
time: { created },
}),
],
model,
)
expect(messages.map((message) => message.role)).toEqual(["user", "user", "user", "user"])
expect(messages[0]).toEqual(
Message.make({
id: id("user"),
role: "user",
content: [
{ type: "text", text: "Inspect this image" },
{ type: "media", mediaType: "image/png", data: "data:image/png;base64,aGVsbG8=", filename: "hello.png" },
],
metadata: { agents: [{ name: "build" }], references: [reference] },
}),
)
expect(messages.slice(1).map((message) => message.content)).toEqual([
[{ type: "text", text: "Synthetic context" }],
[{ type: "text", text: "Shell command: pwd\n\n/project" }],
[{ type: "text", text: "Summary of earlier conversation:\nEarlier work" }],
])
}))
it.effect("maps durable Session system messages into chronological system messages", () => Effect.sync(() => {
expect(
toLLMMessages(
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(
[
new SessionMessage.System({ id: id("system"), type: "system", text: "Updated context\n\nOther context", time: { created } }),
new SessionMessage.AgentSwitched({
id: id("agent"),
type: "agent-switched",
agent: "build",
time: { created },
}),
new SessionMessage.ModelSwitched({
id: id("model"),
type: "model-switched",
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
time: { created },
}),
new SessionMessage.User({
id: id("user"),
type: "user",
text: "Inspect this image",
files: [file],
agents: [new AgentAttachment({ name: "build" })],
references: [reference],
time: { created },
}),
new SessionMessage.Synthetic({
id: id("synthetic"),
type: "synthetic",
sessionID: SessionV2.ID.make("ses_translate"),
text: "Synthetic context",
time: { created },
}),
new SessionMessage.Shell({
id: id("shell"),
type: "shell",
callID: "shell-1",
command: "pwd",
output: "/project",
time: { created, completed: created },
}),
new SessionMessage.Compaction({
id: id("compaction"),
type: "compaction",
reason: "auto",
summary: "Earlier work",
time: { created },
}),
],
model,
),
).toEqual([
Message.system("Updated context\n\nOther context"),
])
}))
)
it.effect("expands assistant tool calls and settled outcomes into canonical tool messages", () => Effect.sync(() => {
const messages = toLLMMessages(
[
new SessionMessage.Assistant({
id: id("assistant"),
type: "assistant",
agent: "build",
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
expect(messages.map((message) => message.role)).toEqual(["user", "user", "user", "user"])
expect(messages[0]).toEqual(
Message.make({
id: id("user"),
role: "user",
content: [
new SessionMessage.AssistantText({ type: "text", id: "text-1", text: "Checking" }),
new SessionMessage.AssistantReasoning({
type: "reasoning",
id: "reasoning-1",
text: "Think",
providerMetadata: { anthropic: { signature: "sig_1" } },
}),
new SessionMessage.AssistantTool({
type: "tool",
id: "pending",
name: "read",
state: new SessionMessage.ToolStatePending({ status: "pending", input: '{"path":"README.md"}' }),
{ type: "text", text: "Inspect this image" },
{ type: "media", mediaType: "image/png", data: "data:image/png;base64,aGVsbG8=", filename: "hello.png" },
],
metadata: { agents: [{ name: "build" }], references: [reference] },
}),
)
expect(messages.slice(1).map((message) => message.content)).toEqual([
[{ type: "text", text: "Synthetic context" }],
[{ type: "text", text: "Shell command: pwd\n\n/project" }],
[{ type: "text", text: "Summary of earlier conversation:\nEarlier work" }],
])
}),
)
it.effect("maps durable Session system messages into chronological system messages", () =>
Effect.sync(() => {
expect(
toLLMMessages(
[
new SessionMessage.System({
id: id("system"),
type: "system",
text: "Updated context\n\nOther context",
time: { created },
}),
new SessionMessage.AssistantTool({
type: "tool",
id: "running",
name: "read",
state: new SessionMessage.ToolStateRunning({
status: "running",
input: { path: "README.md" },
content: [],
structured: {},
}),
time: { created },
}),
new SessionMessage.AssistantTool({
type: "tool",
id: "completed",
name: "read",
state: new SessionMessage.ToolStateCompleted({
status: "completed",
input: { path: "README.md" },
content: [
new ToolOutput.TextContent({ type: "text", text: "Hello" }),
new ToolOutput.FileContent({
type: "file",
source: { type: "data", data: "aGVsbG8=" },
mime: "image/png",
name: "hello.png",
}),
],
structured: {},
}),
time: { created, completed: created },
}),
new SessionMessage.AssistantTool({
type: "tool",
id: "hosted",
name: "web_search",
provider: {
executed: true,
metadata: { fake: { continuation: "hosted-call" } },
resultMetadata: { fake: { continuation: "hosted-result" } },
},
state: new SessionMessage.ToolStateCompleted({
status: "completed",
input: { query: "Effect" },
content: [new ToolOutput.TextContent({ type: "text", text: "Found it" })],
structured: {},
}),
time: { created, completed: created },
}),
new SessionMessage.AssistantTool({
type: "tool",
id: "hosted-failed",
name: "write",
provider: { executed: true, metadata: { fake: { continuation: "failed" } } },
state: new SessionMessage.ToolStateError({
status: "error",
input: { path: "README.md" },
content: [],
structured: {},
error: { type: "unknown", message: "Denied" },
}),
time: { created, completed: created },
}),
],
time: { created, completed: created },
}),
],
model,
)
model,
),
).toEqual([Message.system("Updated context\n\nOther context")])
}),
)
expect(messages.map((message) => message.role)).toEqual(["assistant", "tool"])
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Checking" },
{ type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "sig_1" } } },
{ type: "tool-call", id: "pending", name: "read", input: { path: "README.md" } },
{ type: "tool-call", id: "running", name: "read", input: { path: "README.md" } },
{
type: "tool-call",
id: "completed",
name: "read",
input: { path: "README.md" },
},
{
type: "tool-call",
id: "hosted",
name: "web_search",
input: { query: "Effect" },
providerExecuted: true,
providerMetadata: { fake: { continuation: "hosted-call" } },
},
{
type: "tool-result",
id: "hosted",
name: "web_search",
providerExecuted: true,
providerMetadata: { fake: { continuation: "hosted-result" } },
result: { type: "text", value: "Found it" },
},
{
type: "tool-call",
id: "hosted-failed",
name: "write",
input: { path: "README.md" },
providerExecuted: true,
providerMetadata: { fake: { continuation: "failed" } },
},
{
type: "tool-result",
id: "hosted-failed",
name: "write",
providerExecuted: true,
providerMetadata: { fake: { continuation: "failed" } },
result: {
type: "error",
value: { error: { type: "unknown", message: "Denied" }, content: [], structured: {} },
it.effect("expands assistant tool calls and settled outcomes into canonical tool messages", () =>
Effect.sync(() => {
const messages = toLLMMessages(
[
new SessionMessage.Assistant({
id: id("assistant"),
type: "assistant",
agent: "build",
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
content: [
new SessionMessage.AssistantText({ type: "text", id: "text-1", text: "Checking" }),
new SessionMessage.AssistantReasoning({
type: "reasoning",
id: "reasoning-1",
text: "Think",
providerMetadata: { anthropic: { signature: "sig_1" } },
}),
new SessionMessage.AssistantTool({
type: "tool",
id: "pending",
name: "read",
state: new SessionMessage.ToolStatePending({ status: "pending", input: '{"path":"README.md"}' }),
time: { created },
}),
new SessionMessage.AssistantTool({
type: "tool",
id: "running",
name: "read",
state: new SessionMessage.ToolStateRunning({
status: "running",
input: { path: "README.md" },
content: [],
structured: {},
}),
time: { created },
}),
new SessionMessage.AssistantTool({
type: "tool",
id: "completed",
name: "read",
state: new SessionMessage.ToolStateCompleted({
status: "completed",
input: { path: "README.md" },
content: [
new ToolOutput.TextContent({ type: "text", text: "Hello" }),
new ToolOutput.FileContent({
type: "file",
source: { type: "data", data: "aGVsbG8=" },
mime: "image/png",
name: "hello.png",
}),
],
structured: {},
}),
time: { created, completed: created },
}),
new SessionMessage.AssistantTool({
type: "tool",
id: "hosted",
name: "web_search",
provider: {
executed: true,
metadata: { fake: { continuation: "hosted-call" } },
resultMetadata: { fake: { continuation: "hosted-result" } },
},
state: new SessionMessage.ToolStateCompleted({
status: "completed",
input: { query: "Effect" },
content: [new ToolOutput.TextContent({ type: "text", text: "Found it" })],
structured: {},
}),
time: { created, completed: created },
}),
new SessionMessage.AssistantTool({
type: "tool",
id: "hosted-failed",
name: "write",
provider: { executed: true, metadata: { fake: { continuation: "failed" } } },
state: new SessionMessage.ToolStateError({
status: "error",
input: { path: "README.md" },
content: [],
structured: {},
error: { type: "unknown", message: "Denied" },
}),
time: { created, completed: created },
}),
],
time: { created, completed: created },
}),
],
model,
)
expect(messages.map((message) => message.role)).toEqual(["assistant", "tool"])
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Checking" },
{ type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "sig_1" } } },
{ type: "tool-call", id: "pending", name: "read", input: { path: "README.md" } },
{ type: "tool-call", id: "running", name: "read", input: { path: "README.md" } },
{
type: "tool-call",
id: "completed",
name: "read",
input: { path: "README.md" },
},
},
])
expect(messages[1]?.content).toEqual([
{
type: "tool-result",
id: "completed",
name: "read",
result: {
type: "content",
value: [
{ type: "text", text: "Hello" },
{ type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "hello.png" },
],
{
type: "tool-call",
id: "hosted",
name: "web_search",
input: { query: "Effect" },
providerExecuted: true,
providerMetadata: { fake: { continuation: "hosted-call" } },
},
},
])
}))
{
type: "tool-result",
id: "hosted",
name: "web_search",
providerExecuted: true,
providerMetadata: { fake: { continuation: "hosted-result" } },
result: { type: "text", value: "Found it" },
},
{
type: "tool-call",
id: "hosted-failed",
name: "write",
input: { path: "README.md" },
providerExecuted: true,
providerMetadata: { fake: { continuation: "failed" } },
},
{
type: "tool-result",
id: "hosted-failed",
name: "write",
providerExecuted: true,
providerMetadata: { fake: { continuation: "failed" } },
result: {
type: "error",
value: { error: { type: "unknown", message: "Denied" }, content: [], structured: {} },
},
},
])
expect(messages[1]?.content).toEqual([
{
type: "tool-result",
id: "completed",
name: "read",
result: {
type: "content",
value: [
{ type: "text", text: "Hello" },
{ type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "hello.png" },
],
},
},
])
}),
)
it.effect("restores OpenAI encrypted reasoning metadata", () => Effect.sync(() => {
const messages = toLLMMessages(
[
new SessionMessage.Assistant({
id: id("assistant-openai-reasoning"),
type: "assistant",
agent: "build",
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
content: [
new SessionMessage.AssistantReasoning({
type: "reasoning",
id: "reasoning-openai",
text: "Think",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
}),
],
time: { created, completed: created },
}),
],
model,
)
expect(messages[0]?.content).toEqual([
{
type: "reasoning",
text: "Think",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
}))
it.effect("drops provider-native continuation metadata after a model switch", () => Effect.sync(() => {
const messages = toLLMMessages(
[
new SessionMessage.Assistant({
id: id("assistant-old-model"),
type: "assistant",
agent: "build",
model: { id: ModelV2.ID.make("old-model"), providerID: ProviderV2.ID.make("provider") },
content: [
new SessionMessage.AssistantReasoning({
type: "reasoning",
id: "reasoning-old-model",
text: "Visible thought",
providerMetadata: { anthropic: { signature: "sig_old" } },
}),
new SessionMessage.AssistantTool({
type: "tool",
id: "hosted-old-model",
name: "web_search",
provider: {
executed: true,
metadata: { openai: { itemId: "hosted-old-model" } },
resultMetadata: { openai: { itemId: "hosted-old-model" } },
},
state: new SessionMessage.ToolStateCompleted({
status: "completed",
input: { query: "Effect" },
content: [],
structured: {},
result: { type: "json", value: { status: "completed" } },
it.effect("restores OpenAI encrypted reasoning metadata", () =>
Effect.sync(() => {
const messages = toLLMMessages(
[
new SessionMessage.Assistant({
id: id("assistant-openai-reasoning"),
type: "assistant",
agent: "build",
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
content: [
new SessionMessage.AssistantReasoning({
type: "reasoning",
id: "reasoning-openai",
text: "Think",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
}),
time: { created, completed: created },
}),
new SessionMessage.AssistantTool({
type: "tool",
id: "local-old-model",
name: "read",
provider: {
executed: false,
metadata: { fake: { call: "old" } },
resultMetadata: { fake: { result: "old" } },
},
state: new SessionMessage.ToolStateCompleted({
status: "completed",
input: { path: "README.md" },
content: [],
structured: { text: "Hello" },
}),
time: { created, completed: created },
}),
],
time: { created, completed: created },
}),
],
model,
)
],
time: { created, completed: created },
}),
],
model,
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Visible thought" },
{
type: "tool-call",
id: "hosted-old-model",
name: "web_search",
input: { query: "Effect" },
providerExecuted: true,
providerMetadata: undefined,
},
{
type: "tool-result",
id: "hosted-old-model",
name: "web_search",
result: { type: "json", value: { status: "completed" } },
providerExecuted: true,
cache: undefined,
metadata: undefined,
providerMetadata: undefined,
},
{
type: "tool-call",
id: "local-old-model",
name: "read",
input: { path: "README.md" },
providerExecuted: false,
providerMetadata: undefined,
},
])
expect(messages[1]?.content).toEqual([
{
type: "tool-result",
id: "local-old-model",
name: "read",
result: { type: "json", value: { text: "Hello" } },
providerExecuted: false,
cache: undefined,
metadata: undefined,
providerMetadata: undefined,
},
])
}))
expect(messages[0]?.content).toEqual([
{
type: "reasoning",
text: "Think",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
}),
)
it.effect("drops provider-native continuation metadata after a model switch", () =>
Effect.sync(() => {
const messages = toLLMMessages(
[
new SessionMessage.Assistant({
id: id("assistant-old-model"),
type: "assistant",
agent: "build",
model: { id: ModelV2.ID.make("old-model"), providerID: ProviderV2.ID.make("provider") },
content: [
new SessionMessage.AssistantReasoning({
type: "reasoning",
id: "reasoning-old-model",
text: "Visible thought",
providerMetadata: { anthropic: { signature: "sig_old" } },
}),
new SessionMessage.AssistantTool({
type: "tool",
id: "hosted-old-model",
name: "web_search",
provider: {
executed: true,
metadata: { openai: { itemId: "hosted-old-model" } },
resultMetadata: { openai: { itemId: "hosted-old-model" } },
},
state: new SessionMessage.ToolStateCompleted({
status: "completed",
input: { query: "Effect" },
content: [],
structured: {},
result: { type: "json", value: { status: "completed" } },
}),
time: { created, completed: created },
}),
new SessionMessage.AssistantTool({
type: "tool",
id: "local-old-model",
name: "read",
provider: {
executed: false,
metadata: { fake: { call: "old" } },
resultMetadata: { fake: { result: "old" } },
},
state: new SessionMessage.ToolStateCompleted({
status: "completed",
input: { path: "README.md" },
content: [],
structured: { text: "Hello" },
}),
time: { created, completed: created },
}),
],
time: { created, completed: created },
}),
],
model,
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Visible thought" },
{
type: "tool-call",
id: "hosted-old-model",
name: "web_search",
input: { query: "Effect" },
providerExecuted: true,
providerMetadata: undefined,
},
{
type: "tool-result",
id: "hosted-old-model",
name: "web_search",
result: { type: "json", value: { status: "completed" } },
providerExecuted: true,
cache: undefined,
metadata: undefined,
providerMetadata: undefined,
},
{
type: "tool-call",
id: "local-old-model",
name: "read",
input: { path: "README.md" },
providerExecuted: false,
providerMetadata: undefined,
},
])
expect(messages[1]?.content).toEqual([
{
type: "tool-result",
id: "local-old-model",
name: "read",
result: { type: "json", value: { text: "Hello" } },
providerExecuted: false,
cache: undefined,
metadata: undefined,
providerMetadata: undefined,
},
])
}),
)
})

View file

@ -57,14 +57,15 @@ const model = OpenAIChat.route
})
.model({ id: "gpt-4o-mini" })
const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
const systemContextKey = SystemContext.Key.make("test/context")
const systemContext = Layer.effectDiscard(
SystemContextRegistry.Service.pipe(
Effect.flatMap((registry) =>
registry.contribute({
key: "test/context",
key: systemContextKey,
load: Effect.succeed(
SystemContext.make({
key: SystemContext.Key.make("test/context"),
key: systemContextKey,
codec: Schema.toCodecJson(Schema.String),
load: Effect.succeed("Recorded context"),
baseline: String,

View file

@ -32,13 +32,18 @@ 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 { SessionContextEpochTable, 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 { SystemContext } from "@opencode-ai/core/system-context"
import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry"
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"
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
@ -145,11 +150,12 @@ const systemContextKey = SystemContext.Key.make("test/context")
let systemBaseline = "Initial context"
let systemRemoved = false
let systemUnavailable = false
let systemLoadHook = Effect.void
const systemContext = Layer.effectDiscard(
SystemContextRegistry.Service.pipe(
Effect.flatMap((registry) =>
registry.contribute({
key: "test/context",
key: systemContextKey,
load: Effect.sync(() =>
SystemContext.combine(
systemRemoved
@ -158,7 +164,11 @@ const systemContext = Layer.effectDiscard(
SystemContext.make({
key: systemContextKey,
codec: Schema.toCodecJson(Schema.String),
load: Effect.sync(() => (systemUnavailable ? SystemContext.unavailable : systemBaseline)),
load: systemLoadHook.pipe(
Effect.andThen(
Effect.sync(() => (systemUnavailable ? SystemContext.unavailable : systemBaseline)),
),
),
baseline: String,
update: (_previous, current) => current,
removed: () => "System context source removed: test/context",
@ -240,6 +250,7 @@ const setup = Effect.gen(function* () {
systemBaseline = "Initial context"
systemRemoved = false
systemUnavailable = false
systemLoadHook = Effect.void
responses = undefined
streamFailure = undefined
responseStream = undefined
@ -552,6 +563,39 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("retries the first provider turn after system context becomes available", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const { db } = yield* Database.Service
const messageID = SessionMessage.ID.create()
systemUnavailable = true
yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "First" }), resume: false })
requests.length = 0
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(SystemContext.InitializationBlocked)
expect(requests).toHaveLength(0)
expect(yield* SessionInput.hasPending(db, sessionID, "steer")).toBe(true)
expect(
yield* db
.select()
.from(SessionContextEpochTable)
.where(eq(SessionContextEpochTable.session_id, sessionID))
.get(),
).toBeUndefined()
systemUnavailable = false
yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "First" }) })
yield* (yield* SessionRunCoordinator.Service).awaitIdle(sessionID)
expect(requests).toHaveLength(1)
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user"])
}),
)
it.effect("reuses one durable baseline after the context producer changes", () =>
Effect.gen(function* () {
yield* setup
@ -622,6 +666,7 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
})
@ -661,6 +706,7 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
})
@ -692,15 +738,17 @@ describe("SessionRunnerLLM", () => {
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
model: { id: ModelV2.ID.make("replacement-1"), providerID: ProviderV2.ID.make("fake") },
})
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(2),
model: { id: ModelV2.ID.make("replacement-2"), providerID: ProviderV2.ID.make("fake") },
})
const latest = yield* events.sequence(sessionID)
const latest = yield* SessionInput.latestSeq(db, sessionID)
expect(
yield* db
@ -713,6 +761,40 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("retries epoch preparation until observation-time invalidations settle", () =>
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 })
response = []
yield* session.resume(sessionID)
requests.length = 0
systemBaseline = "Changed context"
let invalidations = 0
systemLoadHook = Effect.suspend(() => {
if (invalidations === 4) return Effect.void
invalidations++
return events
.publish(SessionEvent.ModelSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(invalidations),
model: { id: ModelV2.ID.make(`replacement-${invalidations}`), providerID: ProviderV2.ID.make("fake") },
})
.pipe(Effect.asVoid)
})
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
yield* session.resume(sessionID)
expect(invalidations).toBe(4)
expect(requests).toHaveLength(1)
expect(requests[0]?.system.map((part) => part.text)).toEqual(["Changed context"])
}),
)
it.effect("replays retained context projections while replacement is pending", () =>
Effect.gen(function* () {
yield* setup
@ -728,6 +810,7 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
})
@ -752,6 +835,7 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
model: { id: ModelV2.ID.make("replacement-1"), providerID: ProviderV2.ID.make("fake") },
})
@ -760,6 +844,7 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(2),
model: { id: ModelV2.ID.make("replacement-2"), providerID: ProviderV2.ID.make("fake") },
})
@ -784,6 +869,7 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
yield* events.publish(SessionEvent.Compaction.Started, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
reason: "manual",
})
@ -821,6 +907,7 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
yield* events.publish(SessionEvent.Compaction.Started, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
reason: "manual",
})
@ -834,7 +921,16 @@ describe("SessionRunnerLLM", () => {
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)
expect(
requests
.at(-1)
?.messages.some(
(message) =>
message.role === "system" &&
message.content[0]?.type === "text" &&
message.content[0].text === "Changed context",
),
).toBe(true)
}),
)
@ -1022,6 +1118,7 @@ describe("SessionRunnerLLM", () => {
yield* Deferred.await(toolExecutionsStarted)
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
})

View file

@ -5,7 +5,7 @@ import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry
import { testEffect } from "./lib/effect"
const contribution = (key: string, text: string, sourceKey = key) => ({
key,
key: SystemContext.Key.make(key),
load: Effect.succeed(
SystemContext.make({
key: SystemContext.Key.make(sourceKey),
@ -43,7 +43,7 @@ describe("SystemContextRegistry", () => {
const registry = yield* SystemContextRegistry.Service
let loads = 0
yield* registry.contribute({
key: "test/dynamic",
key: SystemContext.Key.make("test/dynamic"),
load: Effect.sync(() => {
loads++
return SystemContext.empty
@ -61,7 +61,7 @@ describe("SystemContextRegistry", () => {
Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service
const failure = new Error("contribution failed")
yield* registry.contribute({ key: "test/failure", load: Effect.die(failure) })
yield* registry.contribute({ key: SystemContext.Key.make("test/failure"), load: Effect.die(failure) })
const exit = yield* registry.load().pipe(Effect.exit)

View file

@ -125,16 +125,21 @@ describe("SystemContext", () => {
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" })
expect(yield* SystemContext.replace(context, {})).toMatchObject({ _tag: "ReplacementReady" })
}),
)
it.effect("omits unavailable sources from an initial baseline", () =>
it.effect("blocks initialization while a source is unavailable", () =>
Effect.gen(function* () {
expect(yield* SystemContext.initialize(stringContext({ key: "core/remote", value: SystemContext.unavailable }))).toEqual({
baseline: "",
snapshot: {},
})
const exit = yield* SystemContext.initialize(
stringContext({ key: "core/remote", value: SystemContext.unavailable }),
).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit))
expect(Cause.squash(exit.cause)).toEqual(
new SystemContext.InitializationBlocked({ keys: [key("core/remote")] }),
)
}),
)
@ -154,8 +159,10 @@ describe("SystemContext", () => {
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",
expect(
yield* SystemContext.reconcile(SystemContext.empty, { "core/date": { value: "2026-06-04" } }),
).toMatchObject({
_tag: "ReplacementReady",
})
}),
)
@ -188,7 +195,7 @@ describe("SystemContext", () => {
yield* SystemContext.reconcile(stringContext({ key: "core/date", value: "2026-06-04" }), {
"core/date": { value: 42, removed: "Date removed" },
}),
).toMatchObject({ _tag: "Replaced" })
).toMatchObject({ _tag: "ReplacementReady" })
}),
)
@ -207,7 +214,7 @@ describe("SystemContext", () => {
})
expect(yield* SystemContext.reconcile(context, { "core/date": { value: 42 } })).toMatchObject({
_tag: "Replaced",
_tag: "ReplacementReady",
generation: { baseline: "2026-06-04" },
})
expect(loads).toBe(1)
@ -234,7 +241,7 @@ describe("SystemContext", () => {
"core/date": { value: "2026-06-03" },
"core/location": { value: 42 },
}),
).toMatchObject({ _tag: "Replaced" })
).toMatchObject({ _tag: "ReplacementReady" })
expect(updates).toBe(0)
}),
)