mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-05 00:09:53 +00:00
refactor(core): trim v2 context epoch scope
This commit is contained in:
parent
d856d92506
commit
8cb02ba9cf
17 changed files with 117 additions and 411 deletions
|
|
@ -381,10 +381,10 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
})
|
||||
}
|
||||
|
||||
function publishEvent<D extends Definition>(event: Payload<D>, options?: PublishOptions) {
|
||||
function publishEvent<D extends Definition>(event: Payload<D>, commit?: PublishOptions["commit"]) {
|
||||
return Effect.gen(function* () {
|
||||
const durable = registry.get(event.type)?.sync !== undefined
|
||||
if (!durable && options?.commit)
|
||||
if (!durable && commit)
|
||||
return yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
type: event.type,
|
||||
|
|
@ -392,7 +392,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
}),
|
||||
)
|
||||
if (durable) {
|
||||
const committed = yield* commitSyncEvent(event as Payload, undefined, options?.commit)
|
||||
const committed = yield* commitSyncEvent(event as Payload, undefined, commit)
|
||||
if (committed) {
|
||||
event = { ...event, seq: committed.seq }
|
||||
yield* Effect.forEach(syncHandlers, (sync) => observe(event as Payload, "sync", sync), { discard: true })
|
||||
|
|
@ -446,7 +446,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
...(location ? { location } : {}),
|
||||
data,
|
||||
} as Payload<D>,
|
||||
options,
|
||||
options?.commit,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
|||
Catalog.locationLayer,
|
||||
CommandV2.locationLayer,
|
||||
AgentV2.locationLayer,
|
||||
PluginBoot.locationLayer.pipe(Layer.provide(systemContext)),
|
||||
PluginBoot.locationLayer,
|
||||
FileSystem.locationLayer,
|
||||
Watcher.locationLayer,
|
||||
Pty.locationLayer,
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ import { EnvPlugin } from "./env"
|
|||
import { ModelsDevPlugin } from "./models-dev"
|
||||
import { ProviderPlugins } from "./provider"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { SystemContextRegistry } from "../system-context-registry"
|
||||
|
||||
type Plugin = {
|
||||
id: PluginV2.ID
|
||||
|
|
@ -43,7 +42,6 @@ type Plugin = {
|
|||
| Config.Service
|
||||
| ModelsDev.Service
|
||||
| SkillV2.Service
|
||||
| SystemContextRegistry.Service
|
||||
>
|
||||
}
|
||||
|
||||
|
|
@ -69,7 +67,6 @@ export const layer = Layer.effect(
|
|||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const systemContext = yield* SystemContextRegistry.Service
|
||||
const done = yield* Deferred.make<void>()
|
||||
|
||||
const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) {
|
||||
|
|
@ -89,7 +86,6 @@ export const layer = Layer.effect(
|
|||
Effect.provideService(Global.Service, global),
|
||||
Effect.provideService(SkillV2.Service, skill),
|
||||
Effect.provideService(PluginV2.Service, plugin),
|
||||
Effect.provideService(SystemContextRegistry.Service, systemContext),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -98,13 +98,6 @@ export const layer = Layer.effect(
|
|||
const getContext = Effect.fn("SessionRunner.getContext")(function* (sessionID: SessionSchema.ID) {
|
||||
return yield* store.context(sessionID)
|
||||
})
|
||||
const getRunnerContext = Effect.fn("SessionRunner.getRunnerContext")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
baselineSeq: number,
|
||||
) {
|
||||
return yield* store.runnerContext(sessionID, baselineSeq)
|
||||
})
|
||||
|
||||
const failInterruptedTools = Effect.fn("SessionRunner.failInterruptedTools")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
|
|
@ -152,7 +145,7 @@ export const layer = Layer.effect(
|
|||
}
|
||||
}
|
||||
const system = initialized ?? (yield* SessionContextEpoch.prepare(db, events, systemContext, session.id))
|
||||
const context = yield* getRunnerContext(session.id, system.baselineSeq)
|
||||
const context = yield* store.runnerContext(session.id, system.baselineSeq)
|
||||
const request = LLM.request({
|
||||
model,
|
||||
system: system.baseline.length > 0 ? [SystemPart.make(system.baseline)] : [],
|
||||
|
|
@ -251,7 +244,6 @@ export const layer = Layer.effect(
|
|||
readonly sessionID: SessionSchema.ID
|
||||
readonly force?: boolean
|
||||
}) {
|
||||
const session = yield* getSession(input.sessionID)
|
||||
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
||||
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
|
||||
if (input.force !== true && !hasSteer && !hasQueue) return
|
||||
|
|
@ -261,7 +253,7 @@ export const layer = Layer.effect(
|
|||
while (openActivity) {
|
||||
let needsContinuation = true
|
||||
for (let step = 0; step < MAX_STEPS; step++) {
|
||||
needsContinuation = yield* runTurn(session.id, promotion)
|
||||
needsContinuation = yield* runTurn(input.sessionID, promotion)
|
||||
promotion = "steer"
|
||||
if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
||||
if (!needsContinuation) break
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import { fileURLToPath } from "url"
|
||||
import path from "path"
|
||||
|
|
@ -18,45 +18,43 @@ 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"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
|
||||
Effect.runPromise(
|
||||
effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Effect.scoped),
|
||||
)
|
||||
|
||||
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
|
||||
const it = testEffect(SqliteClient.layer({ filename: ":memory:", disableWAL: true }))
|
||||
|
||||
describe("DatabaseMigration", () => {
|
||||
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)]
|
||||
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)]
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.all(
|
||||
layers.map((layer) => Effect.scoped(Layer.build(layer))),
|
||||
{ concurrency: "unbounded" },
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
if (process.platform === "linux") {
|
||||
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,
|
||||
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("applies tracked migrations to an empty database", () =>
|
||||
Effect.gen(function* () {
|
||||
test("applies tracked migrations to an empty database", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* DatabaseMigration.apply(db)
|
||||
|
||||
|
|
@ -84,11 +82,13 @@ describe("DatabaseMigration", () => {
|
|||
{ name: "session_message_session_time_created_id_idx" },
|
||||
{ name: "session_message_session_type_seq_idx" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("resets beta history and rebuilds event-sourced Session input storage", () =>
|
||||
Effect.gen(function* () {
|
||||
test("resets beta history and rebuilds event-sourced Session input storage", async () => {
|
||||
await run(
|
||||
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)`)
|
||||
|
|
@ -158,11 +158,13 @@ describe("DatabaseMigration", () => {
|
|||
expect.objectContaining({ name: "session_input_session_promoted_seq_idx", unique: 1 }),
|
||||
expect.objectContaining({ name: "session_input_session_admitted_seq_idx", unique: 1 }),
|
||||
])
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("resets incompatible projected Session messages before adding sequence order", () =>
|
||||
Effect.gen(function* () {
|
||||
test("resets incompatible projected Session messages before adding sequence order", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
|
||||
yield* db.run(
|
||||
|
|
@ -211,11 +213,13 @@ 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 })
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("runs session usage backfill in order with schema changes", () =>
|
||||
Effect.gen(function* () {
|
||||
test("runs session usage backfill in order with schema changes", async () => {
|
||||
await run(
|
||||
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)`)
|
||||
|
|
@ -238,11 +242,13 @@ describe("DatabaseMigration", () => {
|
|||
tokens_cache_read: 5,
|
||||
tokens_cache_write: 6,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("normalizes Windows storage paths and leaves POSIX paths untouched", () =>
|
||||
Effect.gen(function* () {
|
||||
test("normalizes Windows storage paths and leaves POSIX paths untouched", async () => {
|
||||
await run(
|
||||
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)`)
|
||||
|
|
@ -287,12 +293,14 @@ describe("DatabaseMigration", () => {
|
|||
directory: "/home/me/we\\ird",
|
||||
path: "src\\weird",
|
||||
})
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("maps native Windows paths through database columns", () => {
|
||||
if (process.platform !== "win32") return Effect.void
|
||||
return Effect.gen(function* () {
|
||||
test("maps native Windows paths through database columns", async () => {
|
||||
if (process.platform !== "win32") return
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* DatabaseMigration.apply(db)
|
||||
const projectID = ProjectV2.ID.make("codec_project")
|
||||
|
|
@ -395,11 +403,13 @@ describe("DatabaseMigration", () => {
|
|||
expect(() =>
|
||||
Effect.runSync(db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get()),
|
||||
).toThrow()
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("imports existing drizzle migration state", () =>
|
||||
Effect.gen(function* () {
|
||||
test("imports existing drizzle migration state", async () => {
|
||||
await run(
|
||||
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)`,
|
||||
|
|
@ -412,11 +422,13 @@ describe("DatabaseMigration", () => {
|
|||
yield* DatabaseMigration.applyOnly(db, [])
|
||||
|
||||
expect(yield* db.get(sql`SELECT id FROM migration`)).toEqual({ id: "20260127222353_familiar_lady_ursula" })
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("does not replay a migrated session metadata column", () =>
|
||||
Effect.gen(function* () {
|
||||
test("does not replay a migrated session metadata column", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, metadata text)`)
|
||||
yield* db.run(
|
||||
|
|
@ -430,11 +442,13 @@ describe("DatabaseMigration", () => {
|
|||
yield* DatabaseMigration.applyOnly(db, [sessionMetadataMigration])
|
||||
|
||||
expect(yield* db.all(sql`SELECT id FROM migration`)).toEqual([{ id: "20260511173437_session-metadata" }])
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("accepts the temporary replacement session metadata migration id", () =>
|
||||
Effect.gen(function* () {
|
||||
test("accepts the temporary replacement session metadata migration id", async () => {
|
||||
await run(
|
||||
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)`)
|
||||
|
|
@ -446,11 +460,13 @@ describe("DatabaseMigration", () => {
|
|||
{ id: "20260511173437_session-metadata" },
|
||||
{ id: "20260530232709_lovely_romulus" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("skips drizzle import when migration table already has state", () =>
|
||||
Effect.gen(function* () {
|
||||
test("skips drizzle import when migration table already has state", async () => {
|
||||
await run(
|
||||
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)`)
|
||||
|
|
@ -465,6 +481,7 @@ describe("DatabaseMigration", () => {
|
|||
yield* DatabaseMigration.applyOnly(db, [])
|
||||
|
||||
expect(yield* db.all(sql`SELECT id FROM migration ORDER BY id`)).toEqual([{ id: "existing" }])
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Recorded context\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}"
|
||||
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { describe, expect, test } 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,16 +8,14 @@ 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, Effect } from "effect"
|
||||
import { it } from "./lib/effect"
|
||||
import { DateTime } from "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", () => {
|
||||
it.effect("maps every top-level V2 Session message type", () =>
|
||||
Effect.sync(() => {
|
||||
test("maps every top-level V2 Session message type", () => {
|
||||
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(
|
||||
|
|
@ -34,6 +32,12 @@ describe("toLLMMessages", () => {
|
|||
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||
time: { created },
|
||||
}),
|
||||
new SessionMessage.System({
|
||||
id: id("system"),
|
||||
type: "system",
|
||||
text: "Updated context\n\nOther context",
|
||||
time: { created },
|
||||
}),
|
||||
new SessionMessage.User({
|
||||
id: id("user"),
|
||||
type: "user",
|
||||
|
|
@ -69,8 +73,9 @@ describe("toLLMMessages", () => {
|
|||
model,
|
||||
)
|
||||
|
||||
expect(messages.map((message) => message.role)).toEqual(["user", "user", "user", "user"])
|
||||
expect(messages[0]).toEqual(
|
||||
expect(messages.map((message) => message.role)).toEqual(["system", "user", "user", "user", "user"])
|
||||
expect(messages[0]).toEqual(Message.system("Updated context\n\nOther context"))
|
||||
expect(messages[1]).toEqual(
|
||||
Message.make({
|
||||
id: id("user"),
|
||||
role: "user",
|
||||
|
|
@ -81,34 +86,14 @@ describe("toLLMMessages", () => {
|
|||
metadata: { agents: [{ name: "build" }], references: [reference] },
|
||||
}),
|
||||
)
|
||||
expect(messages.slice(1).map((message) => message.content)).toEqual([
|
||||
expect(messages.slice(2).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 },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
),
|
||||
).toEqual([Message.system("Updated context\n\nOther context")])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("expands assistant tool calls and settled outcomes into canonical tool messages", () =>
|
||||
Effect.sync(() => {
|
||||
test("expands assistant tool calls and settled outcomes into canonical tool messages", () => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
new SessionMessage.Assistant({
|
||||
|
|
@ -263,11 +248,9 @@ describe("toLLMMessages", () => {
|
|||
},
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("restores OpenAI encrypted reasoning metadata", () =>
|
||||
Effect.sync(() => {
|
||||
test("restores OpenAI encrypted reasoning metadata", () => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
new SessionMessage.Assistant({
|
||||
|
|
@ -296,11 +279,9 @@ describe("toLLMMessages", () => {
|
|||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("drops provider-native continuation metadata after a model switch", () =>
|
||||
Effect.sync(() => {
|
||||
test("drops provider-native continuation metadata after a model switch", () => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
new SessionMessage.Assistant({
|
||||
|
|
@ -398,6 +379,5 @@ describe("toLLMMessages", () => {
|
|||
providerMetadata: undefined,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -19,11 +19,10 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
|||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { 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 { describe, expect } from "bun:test"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import path from "node:path"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
|
|
@ -57,26 +56,7 @@ 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: systemContextKey,
|
||||
load: Effect.succeed(
|
||||
SystemContext.make({
|
||||
key: systemContextKey,
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
load: Effect.succeed("Recorded context"),
|
||||
baseline: String,
|
||||
update: (_previous, current) => current,
|
||||
removed: () => "Recorded context removed",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
).pipe(Layer.provideMerge(SystemContextRegistry.layer))
|
||||
const systemContext = SystemContextRegistry.layer
|
||||
const runner = SessionRunnerLLM.defaultLayer.pipe(
|
||||
Layer.provide(database),
|
||||
Layer.provide(store),
|
||||
|
|
|
|||
|
|
@ -823,40 +823,6 @@ describe("SessionRunnerLLM", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("replays retained context projections after multiple replacements", () =>
|
||||
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)
|
||||
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") },
|
||||
})
|
||||
systemBaseline = "Replacement context 1"
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
|
||||
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") },
|
||||
})
|
||||
systemBaseline = "Replacement context 2"
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
yield* replaySessionProjection(sessionID)
|
||||
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Replacement context 2"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces the baseline lazily after completed compaction without reopening replacement on replay", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"name": "anthropic-messages/anthropic-haiku-4-5-chronological-system-update",
|
||||
"recordedAt": "2026-06-04T19:14:47.473Z",
|
||||
"provider": "anthropic",
|
||||
"route": "anthropic-messages",
|
||||
"transport": "http",
|
||||
"model": "claude-haiku-4-5-20251001",
|
||||
"tags": ["prefix:anthropic-messages", "provider:anthropic", "system", "chronological-system-update", "golden"]
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"Follow the latest instruction exactly. Reply only with the requested word.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use the latest instruction for your reply.\"},{\"type\":\"text\",\"text\":\"<system-update>\\nFor this reply, respond exactly with: UPDATED\\n</system-update>\"}]}],\"stream\":true,\"max_tokens\":80,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01QxsUPda7mfZ9zGSgw4JD54\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":49,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":5,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"UPDATED\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":49,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":5} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"name": "gemini/gemini-2-5-flash-chronological-system-update",
|
||||
"recordedAt": "2026-06-04T19:14:48.065Z",
|
||||
"provider": "google",
|
||||
"route": "gemini",
|
||||
"transport": "http",
|
||||
"model": "gemini-2.5-flash",
|
||||
"tags": ["prefix:gemini", "provider:google", "system", "chronological-system-update", "golden"]
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Use the latest instruction for your reply.\"},{\"text\":\"<system-update>\\nFor this reply, respond exactly with: UPDATED\\n</system-update>\"}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"Follow the latest instruction exactly. Reply only with the requested word.\"}]},\"generationConfig\":{\"maxOutputTokens\":80,\"temperature\":0,\"thinkingConfig\":{\"thinkingBudget\":0}}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"UPDATED\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 44,\"candidatesTokenCount\": 2,\"totalTokenCount\": 46,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 44}],\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-2.5-flash\",\"responseId\": \"p84hasmqJL2I-sAPtZnUkA0\"}\r\n\r\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"name": "openai-chat/openai-chat-gpt-4o-mini-chronological-system-update",
|
||||
"recordedAt": "2026-06-04T19:14:43.750Z",
|
||||
"provider": "openai",
|
||||
"route": "openai-chat",
|
||||
"transport": "http",
|
||||
"model": "gpt-4o-mini",
|
||||
"tags": ["prefix:openai-chat", "provider:openai", "system", "chronological-system-update", "golden"]
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.openai.com/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Follow the latest instruction exactly. Reply only with the requested word.\"},{\"role\":\"user\",\"content\":\"Use the latest instruction for your reply.\\n<system-update>\\nFor this reply, respond exactly with: UPDATED\\n</system-update>\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"max_tokens\":80,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"chatcmpl-Dn7oZjOtsXXzajgbNQ17HQXOPABbd\",\"object\":\"chat.completion.chunk\",\"created\":1780600483,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_e0de90b008\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"uIKJFKJXo\"}\n\ndata: {\"id\":\"chatcmpl-Dn7oZjOtsXXzajgbNQ17HQXOPABbd\",\"object\":\"chat.completion.chunk\",\"created\":1780600483,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_e0de90b008\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"UPDATED\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"6HB3\"}\n\ndata: {\"id\":\"chatcmpl-Dn7oZjOtsXXzajgbNQ17HQXOPABbd\",\"object\":\"chat.completion.chunk\",\"created\":1780600483,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_e0de90b008\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":null,\"obfuscation\":\"UlWDE\"}\n\ndata: {\"id\":\"chatcmpl-Dn7oZjOtsXXzajgbNQ17HQXOPABbd\",\"object\":\"chat.completion.chunk\",\"created\":1780600483,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_e0de90b008\",\"choices\":[],\"usage\":{\"prompt_tokens\":50,\"completion_tokens\":1,\"total_tokens\":51,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"w348ZVmuZE6\"}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -73,7 +73,7 @@ describeRecordedGoldenScenarios([
|
|||
prefix: "openai-chat",
|
||||
model: openAIChat,
|
||||
requires: ["OPENAI_API_KEY"],
|
||||
scenarios: ["text", "chronological-system-update", "tool-call", "tool-loop"],
|
||||
scenarios: ["text", "tool-call", "tool-loop"],
|
||||
},
|
||||
{
|
||||
name: "OpenAI Responses gpt-5.5",
|
||||
|
|
@ -83,7 +83,6 @@ describeRecordedGoldenScenarios([
|
|||
tags: ["flagship"],
|
||||
scenarios: [
|
||||
{ id: "text", temperature: false },
|
||||
{ id: "chronological-system-update", temperature: false },
|
||||
{ id: "reasoning", temperature: false },
|
||||
{ id: "reasoning-continuation", temperature: false },
|
||||
{ id: "tool-call", temperature: false },
|
||||
|
|
@ -105,7 +104,7 @@ describeRecordedGoldenScenarios([
|
|||
model: anthropicHaiku,
|
||||
requires: ["ANTHROPIC_API_KEY"],
|
||||
options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) },
|
||||
scenarios: ["text", "chronological-system-update", "tool-call"],
|
||||
scenarios: ["text", "tool-call"],
|
||||
},
|
||||
{
|
||||
name: "Anthropic Opus 4.7",
|
||||
|
|
@ -126,7 +125,6 @@ describeRecordedGoldenScenarios([
|
|||
requires: ["GOOGLE_GENERATIVE_AI_API_KEY"],
|
||||
scenarios: [
|
||||
{ id: "text", maxTokens: 80 },
|
||||
"chronological-system-update",
|
||||
"tool-call",
|
||||
{ id: "image", maxTokens: 160 },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -332,28 +332,6 @@ const runTextScenario = (context: GoldenScenarioContext) =>
|
|||
}),
|
||||
])
|
||||
|
||||
export const runChronologicalSystemUpdateScenario = (context: GoldenScenarioContext) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* generate(
|
||||
LLM.request({
|
||||
id: `${context.id}_chronological_system_update`,
|
||||
model: context.model,
|
||||
system: "Follow the latest instruction exactly. Reply only with the requested word.",
|
||||
cache: "none",
|
||||
messages: [
|
||||
Message.user("Use the latest instruction for your reply."),
|
||||
Message.system("For this reply, respond exactly with: UPDATED"),
|
||||
],
|
||||
providerOptions:
|
||||
context.model.route.id === "gemini" ? { gemini: { thinkingConfig: { thinkingBudget: 0 } } } : undefined,
|
||||
generation: generation(context, context.maxTokens ?? 80),
|
||||
}),
|
||||
)
|
||||
|
||||
expectFinish(response.events, "stop")
|
||||
expect(response.text.trim()).toMatch(/^UPDATED[.!]?$/i)
|
||||
})
|
||||
|
||||
const runToolCallScenario = (context: GoldenScenarioContext) =>
|
||||
runGeneratedConversation(context, [
|
||||
user("Call get_weather with city exactly Paris."),
|
||||
|
|
@ -467,11 +445,6 @@ const runToolLoopScenario = (context: GoldenScenarioContext) =>
|
|||
|
||||
const goldenScenarios = {
|
||||
text: { title: "streams text", tags: ["text", "golden"], run: runTextScenario },
|
||||
"chronological-system-update": {
|
||||
title: "uses chronological system update",
|
||||
tags: ["system", "chronological-system-update", "golden"],
|
||||
run: runChronologicalSystemUpdateScenario,
|
||||
},
|
||||
"tool-call": { title: "streams tool call", tags: ["tool", "tool-call", "golden"], run: runToolCallScenario },
|
||||
"tool-loop": { title: "drives a tool loop", tags: ["tool", "tool-loop", "golden"], run: runToolLoopScenario },
|
||||
image: { title: "reads image text", tags: ["media", "image", "vision", "golden"], run: runImageScenario },
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
ContentPart,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
Message,
|
||||
Model,
|
||||
ModelID,
|
||||
ProviderID,
|
||||
|
|
@ -52,17 +51,6 @@ describe("llm schema", () => {
|
|||
expect(decoded.model.route.id).toBe("openai-responses")
|
||||
})
|
||||
|
||||
test("decodes chronological system messages", () => {
|
||||
const decoded = decodeLLMRequest({
|
||||
model,
|
||||
system: [],
|
||||
messages: [{ role: "system", content: [{ type: "text", text: "Operator update." }] }],
|
||||
tools: [],
|
||||
})
|
||||
|
||||
expect(decoded.messages[0]).toMatchObject({ role: "system", content: [{ type: "text", text: "Operator update." }] })
|
||||
})
|
||||
|
||||
test("rejects invalid event type", () => {
|
||||
expect(() => decodeLLMEvent({ type: "bogus" })).toThrow()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -358,54 +358,6 @@ test("sync v2 preserves live events while snapshot hydration is in flight", asyn
|
|||
}
|
||||
})
|
||||
|
||||
test("sync v2 deduplicates a buffered context update already present in the hydrated snapshot", async () => {
|
||||
const events = createEventSource()
|
||||
const response = Promise.withResolvers<Response>()
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/session/session-1/message") return response.promise
|
||||
return undefined
|
||||
})
|
||||
let sync!: ReturnType<typeof useSyncV2>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
sync = useSyncV2()
|
||||
onMount(ready)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<SyncProviderV2>
|
||||
<Probe />
|
||||
</SyncProviderV2>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
))
|
||||
|
||||
try {
|
||||
await mounted
|
||||
const hydration = sync.session.message.sync("session-1")
|
||||
emitTwice(events, {
|
||||
id: "evt_context_1",
|
||||
type: "session.next.context.updated",
|
||||
properties: { sessionID: "session-1", messageID: "msg_context_1", timestamp: 1, text: "Updated context" },
|
||||
})
|
||||
response.resolve(
|
||||
json({ data: [{ id: "msg_context_1", type: "system", text: "Updated context", time: { created: 1 } }] }),
|
||||
)
|
||||
await hydration
|
||||
|
||||
expect(sync.session.message.fromSession("session-1")).toHaveLength(1)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("sync v2 replaces stale cached rows while preserving in-flight live rows", async () => {
|
||||
const events = createEventSource()
|
||||
const response = Promise.withResolvers<Response>()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue