refactor(core): trim v2 context epoch scope
This commit is contained in:
parent
d856d92506
commit
8cb02ba9cf
17 changed files with 118 additions and 412 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* () {
|
return Effect.gen(function* () {
|
||||||
const durable = registry.get(event.type)?.sync !== undefined
|
const durable = registry.get(event.type)?.sync !== undefined
|
||||||
if (!durable && options?.commit)
|
if (!durable && commit)
|
||||||
return yield* Effect.die(
|
return yield* Effect.die(
|
||||||
new InvalidSyncEventError({
|
new InvalidSyncEventError({
|
||||||
type: event.type,
|
type: event.type,
|
||||||
|
|
@ -392,7 +392,7 @@ export const layerWith = (options?: LayerOptions) =>
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (durable) {
|
if (durable) {
|
||||||
const committed = yield* commitSyncEvent(event as Payload, undefined, options?.commit)
|
const committed = yield* commitSyncEvent(event as Payload, undefined, commit)
|
||||||
if (committed) {
|
if (committed) {
|
||||||
event = { ...event, seq: committed.seq }
|
event = { ...event, seq: committed.seq }
|
||||||
yield* Effect.forEach(syncHandlers, (sync) => observe(event as Payload, "sync", sync), { discard: true })
|
yield* Effect.forEach(syncHandlers, (sync) => observe(event as Payload, "sync", sync), { discard: true })
|
||||||
|
|
@ -446,7 +446,7 @@ export const layerWith = (options?: LayerOptions) =>
|
||||||
...(location ? { location } : {}),
|
...(location ? { location } : {}),
|
||||||
data,
|
data,
|
||||||
} as Payload<D>,
|
} as Payload<D>,
|
||||||
options,
|
options?.commit,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
||||||
Catalog.locationLayer,
|
Catalog.locationLayer,
|
||||||
CommandV2.locationLayer,
|
CommandV2.locationLayer,
|
||||||
AgentV2.locationLayer,
|
AgentV2.locationLayer,
|
||||||
PluginBoot.locationLayer.pipe(Layer.provide(systemContext)),
|
PluginBoot.locationLayer,
|
||||||
FileSystem.locationLayer,
|
FileSystem.locationLayer,
|
||||||
Watcher.locationLayer,
|
Watcher.locationLayer,
|
||||||
Pty.locationLayer,
|
Pty.locationLayer,
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ import { EnvPlugin } from "./env"
|
||||||
import { ModelsDevPlugin } from "./models-dev"
|
import { ModelsDevPlugin } from "./models-dev"
|
||||||
import { ProviderPlugins } from "./provider"
|
import { ProviderPlugins } from "./provider"
|
||||||
import { SkillV2 } from "../skill"
|
import { SkillV2 } from "../skill"
|
||||||
import { SystemContextRegistry } from "../system-context-registry"
|
|
||||||
|
|
||||||
type Plugin = {
|
type Plugin = {
|
||||||
id: PluginV2.ID
|
id: PluginV2.ID
|
||||||
|
|
@ -43,7 +42,6 @@ type Plugin = {
|
||||||
| Config.Service
|
| Config.Service
|
||||||
| ModelsDev.Service
|
| ModelsDev.Service
|
||||||
| SkillV2.Service
|
| SkillV2.Service
|
||||||
| SystemContextRegistry.Service
|
|
||||||
>
|
>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,7 +67,6 @@ export const layer = Layer.effect(
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const global = yield* Global.Service
|
const global = yield* Global.Service
|
||||||
const skill = yield* SkillV2.Service
|
const skill = yield* SkillV2.Service
|
||||||
const systemContext = yield* SystemContextRegistry.Service
|
|
||||||
const done = yield* Deferred.make<void>()
|
const done = yield* Deferred.make<void>()
|
||||||
|
|
||||||
const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) {
|
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(Global.Service, global),
|
||||||
Effect.provideService(SkillV2.Service, skill),
|
Effect.provideService(SkillV2.Service, skill),
|
||||||
Effect.provideService(PluginV2.Service, plugin),
|
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) {
|
const getContext = Effect.fn("SessionRunner.getContext")(function* (sessionID: SessionSchema.ID) {
|
||||||
return yield* store.context(sessionID)
|
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* (
|
const failInterruptedTools = Effect.fn("SessionRunner.failInterruptedTools")(function* (
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
) {
|
) {
|
||||||
|
|
@ -152,7 +145,7 @@ export const layer = Layer.effect(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const system = initialized ?? (yield* SessionContextEpoch.prepare(db, events, systemContext, session.id))
|
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({
|
const request = LLM.request({
|
||||||
model,
|
model,
|
||||||
system: system.baseline.length > 0 ? [SystemPart.make(system.baseline)] : [],
|
system: system.baseline.length > 0 ? [SystemPart.make(system.baseline)] : [],
|
||||||
|
|
@ -251,7 +244,6 @@ export const layer = Layer.effect(
|
||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
readonly force?: boolean
|
readonly force?: boolean
|
||||||
}) {
|
}) {
|
||||||
const session = yield* getSession(input.sessionID)
|
|
||||||
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
||||||
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
|
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
|
||||||
if (input.force !== true && !hasSteer && !hasQueue) return
|
if (input.force !== true && !hasSteer && !hasQueue) return
|
||||||
|
|
@ -261,7 +253,7 @@ export const layer = Layer.effect(
|
||||||
while (openActivity) {
|
while (openActivity) {
|
||||||
let needsContinuation = true
|
let needsContinuation = true
|
||||||
for (let step = 0; step < MAX_STEPS; step++) {
|
for (let step = 0; step < MAX_STEPS; step++) {
|
||||||
needsContinuation = yield* runTurn(session.id, promotion)
|
needsContinuation = yield* runTurn(input.sessionID, promotion)
|
||||||
promotion = "steer"
|
promotion = "steer"
|
||||||
if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
||||||
if (!needsContinuation) break
|
if (!needsContinuation) break
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
|
@ -18,45 +18,43 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
||||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||||
import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata"
|
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 { Database } from "@opencode-ai/core/database/database"
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
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 makeDb = EffectDrizzleSqlite.makeWithDefaults()
|
||||||
const it = testEffect(SqliteClient.layer({ filename: ":memory:", disableWAL: true }))
|
|
||||||
|
|
||||||
describe("DatabaseMigration", () => {
|
describe("DatabaseMigration", () => {
|
||||||
it.effect("serializes concurrent embedded initialization for one database path", () =>
|
test("serializes concurrent embedded initialization for one database path", async () => {
|
||||||
Effect.promise(async () => {
|
await using tmp = await tmpdir()
|
||||||
await using tmp = await tmpdir()
|
const filename = path.join(tmp.path, "embedded.sqlite")
|
||||||
const filename = path.join(tmp.path, "embedded.sqlite")
|
const layers = [Database.layerFromPath(filename), Database.layerFromPath(filename)]
|
||||||
const layers = [Database.layerFromPath(filename), Database.layerFromPath(filename)]
|
|
||||||
|
|
||||||
await Effect.runPromise(
|
await Effect.runPromise(
|
||||||
Effect.all(
|
Effect.all(
|
||||||
layers.map((layer) => Effect.scoped(Layer.build(layer))),
|
layers.map((layer) => Effect.scoped(Layer.build(layer))),
|
||||||
{ concurrency: "unbounded" },
|
{ 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,
|
|
||||||
)
|
)
|
||||||
|
})
|
||||||
|
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", () =>
|
test("applies tracked migrations to an empty database", async () => {
|
||||||
Effect.gen(function* () {
|
await run(
|
||||||
|
Effect.gen(function* () {
|
||||||
const db = yield* makeDb
|
const db = yield* makeDb
|
||||||
yield* DatabaseMigration.apply(db)
|
yield* DatabaseMigration.apply(db)
|
||||||
|
|
||||||
|
|
@ -84,11 +82,13 @@ describe("DatabaseMigration", () => {
|
||||||
{ name: "session_message_session_time_created_id_idx" },
|
{ name: "session_message_session_time_created_id_idx" },
|
||||||
{ name: "session_message_session_type_seq_idx" },
|
{ name: "session_message_session_type_seq_idx" },
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it.effect("resets beta history and rebuilds event-sourced Session input storage", () =>
|
test("resets beta history and rebuilds event-sourced Session input storage", async () => {
|
||||||
Effect.gen(function* () {
|
await run(
|
||||||
|
Effect.gen(function* () {
|
||||||
const db = yield* makeDb
|
const db = yield* makeDb
|
||||||
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, workspace_id text)`)
|
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)`)
|
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_promoted_seq_idx", unique: 1 }),
|
||||||
expect.objectContaining({ name: "session_input_session_admitted_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", () =>
|
test("resets incompatible projected Session messages before adding sequence order", async () => {
|
||||||
Effect.gen(function* () {
|
await run(
|
||||||
|
Effect.gen(function* () {
|
||||||
const db = yield* makeDb
|
const db = yield* makeDb
|
||||||
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
|
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
|
||||||
yield* db.run(
|
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, '{}')`,
|
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 })
|
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", () =>
|
test("runs session usage backfill in order with schema changes", async () => {
|
||||||
Effect.gen(function* () {
|
await run(
|
||||||
|
Effect.gen(function* () {
|
||||||
const db = yield* makeDb
|
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 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)`)
|
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_read: 5,
|
||||||
tokens_cache_write: 6,
|
tokens_cache_write: 6,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it.effect("normalizes Windows storage paths and leaves POSIX paths untouched", () =>
|
test("normalizes Windows storage paths and leaves POSIX paths untouched", async () => {
|
||||||
Effect.gen(function* () {
|
await run(
|
||||||
|
Effect.gen(function* () {
|
||||||
const db = yield* makeDb
|
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 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)`)
|
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",
|
directory: "/home/me/we\\ird",
|
||||||
path: "src\\weird",
|
path: "src\\weird",
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it.effect("maps native Windows paths through database columns", () => {
|
test("maps native Windows paths through database columns", async () => {
|
||||||
if (process.platform !== "win32") return Effect.void
|
if (process.platform !== "win32") return
|
||||||
return Effect.gen(function* () {
|
await run(
|
||||||
|
Effect.gen(function* () {
|
||||||
const db = yield* makeDb
|
const db = yield* makeDb
|
||||||
yield* DatabaseMigration.apply(db)
|
yield* DatabaseMigration.apply(db)
|
||||||
const projectID = ProjectV2.ID.make("codec_project")
|
const projectID = ProjectV2.ID.make("codec_project")
|
||||||
|
|
@ -395,11 +403,13 @@ describe("DatabaseMigration", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
Effect.runSync(db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get()),
|
Effect.runSync(db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get()),
|
||||||
).toThrow()
|
).toThrow()
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it.effect("imports existing drizzle migration state", () =>
|
test("imports existing drizzle migration state", async () => {
|
||||||
Effect.gen(function* () {
|
await run(
|
||||||
|
Effect.gen(function* () {
|
||||||
const db = yield* makeDb
|
const db = yield* makeDb
|
||||||
yield* db.run(
|
yield* db.run(
|
||||||
sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`,
|
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, [])
|
yield* DatabaseMigration.applyOnly(db, [])
|
||||||
|
|
||||||
expect(yield* db.get(sql`SELECT id FROM migration`)).toEqual({ id: "20260127222353_familiar_lady_ursula" })
|
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", () =>
|
test("does not replay a migrated session metadata column", async () => {
|
||||||
Effect.gen(function* () {
|
await run(
|
||||||
|
Effect.gen(function* () {
|
||||||
const db = yield* makeDb
|
const db = yield* makeDb
|
||||||
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, metadata text)`)
|
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, metadata text)`)
|
||||||
yield* db.run(
|
yield* db.run(
|
||||||
|
|
@ -430,11 +442,13 @@ describe("DatabaseMigration", () => {
|
||||||
yield* DatabaseMigration.applyOnly(db, [sessionMetadataMigration])
|
yield* DatabaseMigration.applyOnly(db, [sessionMetadataMigration])
|
||||||
|
|
||||||
expect(yield* db.all(sql`SELECT id FROM migration`)).toEqual([{ id: "20260511173437_session-metadata" }])
|
expect(yield* db.all(sql`SELECT id FROM migration`)).toEqual([{ id: "20260511173437_session-metadata" }])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it.effect("accepts the temporary replacement session metadata migration id", () =>
|
test("accepts the temporary replacement session metadata migration id", async () => {
|
||||||
Effect.gen(function* () {
|
await run(
|
||||||
|
Effect.gen(function* () {
|
||||||
const db = yield* makeDb
|
const db = yield* makeDb
|
||||||
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, metadata text)`)
|
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)`)
|
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: "20260511173437_session-metadata" },
|
||||||
{ id: "20260530232709_lovely_romulus" },
|
{ id: "20260530232709_lovely_romulus" },
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it.effect("skips drizzle import when migration table already has state", () =>
|
test("skips drizzle import when migration table already has state", async () => {
|
||||||
Effect.gen(function* () {
|
await run(
|
||||||
|
Effect.gen(function* () {
|
||||||
const db = yield* makeDb
|
const db = yield* makeDb
|
||||||
yield* db.run(sql`CREATE TABLE migration (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`)
|
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)`)
|
yield* db.run(sql`INSERT INTO migration (id, time_completed) VALUES ('existing', 1)`)
|
||||||
|
|
@ -465,6 +481,7 @@ describe("DatabaseMigration", () => {
|
||||||
yield* DatabaseMigration.applyOnly(db, [])
|
yield* DatabaseMigration.applyOnly(db, [])
|
||||||
|
|
||||||
expect(yield* db.all(sql`SELECT id FROM migration ORDER BY id`)).toEqual([{ id: "existing" }])
|
expect(yield* db.all(sql`SELECT id FROM migration ORDER BY id`)).toEqual([{ id: "existing" }])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
"headers": {
|
"headers": {
|
||||||
"content-type": "application/json"
|
"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": {
|
"response": {
|
||||||
"status": 200,
|
"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 { Message, Model } from "@opencode-ai/llm"
|
||||||
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
|
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
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 { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
|
||||||
import { SessionV2 } from "@opencode-ai/core/session"
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||||
import { DateTime, Effect } from "effect"
|
import { DateTime } from "effect"
|
||||||
import { it } from "./lib/effect"
|
|
||||||
|
|
||||||
const created = DateTime.makeUnsafe(0)
|
const created = DateTime.makeUnsafe(0)
|
||||||
const id = (value: string) => SessionMessage.ID.make(`msg_${value}`)
|
const id = (value: string) => SessionMessage.ID.make(`msg_${value}`)
|
||||||
const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route })
|
const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route })
|
||||||
|
|
||||||
describe("toLLMMessages", () => {
|
describe("toLLMMessages", () => {
|
||||||
it.effect("maps every top-level V2 Session message type", () =>
|
test("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 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 reference = new ReferenceAttachment({ name: "docs", kind: "local", uri: "file:///docs" })
|
||||||
const messages = toLLMMessages(
|
const messages = toLLMMessages(
|
||||||
|
|
@ -34,6 +32,12 @@ describe("toLLMMessages", () => {
|
||||||
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||||
time: { created },
|
time: { created },
|
||||||
}),
|
}),
|
||||||
|
new SessionMessage.System({
|
||||||
|
id: id("system"),
|
||||||
|
type: "system",
|
||||||
|
text: "Updated context\n\nOther context",
|
||||||
|
time: { created },
|
||||||
|
}),
|
||||||
new SessionMessage.User({
|
new SessionMessage.User({
|
||||||
id: id("user"),
|
id: id("user"),
|
||||||
type: "user",
|
type: "user",
|
||||||
|
|
@ -69,8 +73,9 @@ describe("toLLMMessages", () => {
|
||||||
model,
|
model,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(messages.map((message) => message.role)).toEqual(["user", "user", "user", "user"])
|
expect(messages.map((message) => message.role)).toEqual(["system", "user", "user", "user", "user"])
|
||||||
expect(messages[0]).toEqual(
|
expect(messages[0]).toEqual(Message.system("Updated context\n\nOther context"))
|
||||||
|
expect(messages[1]).toEqual(
|
||||||
Message.make({
|
Message.make({
|
||||||
id: id("user"),
|
id: id("user"),
|
||||||
role: "user",
|
role: "user",
|
||||||
|
|
@ -81,34 +86,14 @@ describe("toLLMMessages", () => {
|
||||||
metadata: { agents: [{ name: "build" }], references: [reference] },
|
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: "Synthetic context" }],
|
||||||
[{ type: "text", text: "Shell command: pwd\n\n/project" }],
|
[{ type: "text", text: "Shell command: pwd\n\n/project" }],
|
||||||
[{ type: "text", text: "Summary of earlier conversation:\nEarlier work" }],
|
[{ type: "text", text: "Summary of earlier conversation:\nEarlier work" }],
|
||||||
])
|
])
|
||||||
}),
|
})
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("maps durable Session system messages into chronological system messages", () =>
|
test("expands assistant tool calls and settled outcomes into canonical tool 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(() => {
|
|
||||||
const messages = toLLMMessages(
|
const messages = toLLMMessages(
|
||||||
[
|
[
|
||||||
new SessionMessage.Assistant({
|
new SessionMessage.Assistant({
|
||||||
|
|
@ -263,11 +248,9 @@ describe("toLLMMessages", () => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
}),
|
})
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("restores OpenAI encrypted reasoning metadata", () =>
|
test("restores OpenAI encrypted reasoning metadata", () => {
|
||||||
Effect.sync(() => {
|
|
||||||
const messages = toLLMMessages(
|
const messages = toLLMMessages(
|
||||||
[
|
[
|
||||||
new SessionMessage.Assistant({
|
new SessionMessage.Assistant({
|
||||||
|
|
@ -296,11 +279,9 @@ describe("toLLMMessages", () => {
|
||||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
}),
|
})
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("drops provider-native continuation metadata after a model switch", () =>
|
test("drops provider-native continuation metadata after a model switch", () => {
|
||||||
Effect.sync(() => {
|
|
||||||
const messages = toLLMMessages(
|
const messages = toLLMMessages(
|
||||||
[
|
[
|
||||||
new SessionMessage.Assistant({
|
new SessionMessage.Assistant({
|
||||||
|
|
@ -398,6 +379,5 @@ describe("toLLMMessages", () => {
|
||||||
providerMetadata: undefined,
|
providerMetadata: undefined,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
}),
|
})
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,10 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
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 { SystemContextRegistry } from "@opencode-ai/core/system-context-registry"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
import { Effect, Layer, Schema } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
|
|
@ -57,26 +56,7 @@ const model = OpenAIChat.route
|
||||||
})
|
})
|
||||||
.model({ id: "gpt-4o-mini" })
|
.model({ id: "gpt-4o-mini" })
|
||||||
const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
|
const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
|
||||||
const systemContextKey = SystemContext.Key.make("test/context")
|
const systemContext = SystemContextRegistry.layer
|
||||||
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 runner = SessionRunnerLLM.defaultLayer.pipe(
|
const runner = SessionRunnerLLM.defaultLayer.pipe(
|
||||||
Layer.provide(database),
|
Layer.provide(database),
|
||||||
Layer.provide(store),
|
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", () =>
|
it.effect("replaces the baseline lazily after completed compaction without reopening replacement on replay", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* setup
|
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",
|
prefix: "openai-chat",
|
||||||
model: openAIChat,
|
model: openAIChat,
|
||||||
requires: ["OPENAI_API_KEY"],
|
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",
|
name: "OpenAI Responses gpt-5.5",
|
||||||
|
|
@ -83,7 +83,6 @@ describeRecordedGoldenScenarios([
|
||||||
tags: ["flagship"],
|
tags: ["flagship"],
|
||||||
scenarios: [
|
scenarios: [
|
||||||
{ id: "text", temperature: false },
|
{ id: "text", temperature: false },
|
||||||
{ id: "chronological-system-update", temperature: false },
|
|
||||||
{ id: "reasoning", temperature: false },
|
{ id: "reasoning", temperature: false },
|
||||||
{ id: "reasoning-continuation", temperature: false },
|
{ id: "reasoning-continuation", temperature: false },
|
||||||
{ id: "tool-call", temperature: false },
|
{ id: "tool-call", temperature: false },
|
||||||
|
|
@ -105,7 +104,7 @@ describeRecordedGoldenScenarios([
|
||||||
model: anthropicHaiku,
|
model: anthropicHaiku,
|
||||||
requires: ["ANTHROPIC_API_KEY"],
|
requires: ["ANTHROPIC_API_KEY"],
|
||||||
options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) },
|
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",
|
name: "Anthropic Opus 4.7",
|
||||||
|
|
@ -126,7 +125,6 @@ describeRecordedGoldenScenarios([
|
||||||
requires: ["GOOGLE_GENERATIVE_AI_API_KEY"],
|
requires: ["GOOGLE_GENERATIVE_AI_API_KEY"],
|
||||||
scenarios: [
|
scenarios: [
|
||||||
{ id: "text", maxTokens: 80 },
|
{ id: "text", maxTokens: 80 },
|
||||||
"chronological-system-update",
|
|
||||||
"tool-call",
|
"tool-call",
|
||||||
{ id: "image", maxTokens: 160 },
|
{ 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) =>
|
const runToolCallScenario = (context: GoldenScenarioContext) =>
|
||||||
runGeneratedConversation(context, [
|
runGeneratedConversation(context, [
|
||||||
user("Call get_weather with city exactly Paris."),
|
user("Call get_weather with city exactly Paris."),
|
||||||
|
|
@ -467,11 +445,6 @@ const runToolLoopScenario = (context: GoldenScenarioContext) =>
|
||||||
|
|
||||||
const goldenScenarios = {
|
const goldenScenarios = {
|
||||||
text: { title: "streams text", tags: ["text", "golden"], run: runTextScenario },
|
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-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 },
|
"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 },
|
image: { title: "reads image text", tags: ["media", "image", "vision", "golden"], run: runImageScenario },
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import {
|
||||||
ContentPart,
|
ContentPart,
|
||||||
LLMEvent,
|
LLMEvent,
|
||||||
LLMRequest,
|
LLMRequest,
|
||||||
Message,
|
|
||||||
Model,
|
Model,
|
||||||
ModelID,
|
ModelID,
|
||||||
ProviderID,
|
ProviderID,
|
||||||
|
|
@ -52,17 +51,6 @@ describe("llm schema", () => {
|
||||||
expect(decoded.model.route.id).toBe("openai-responses")
|
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", () => {
|
test("rejects invalid event type", () => {
|
||||||
expect(() => decodeLLMEvent({ type: "bogus" })).toThrow()
|
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 () => {
|
test("sync v2 replaces stale cached rows while preserving in-flight live rows", async () => {
|
||||||
const events = createEventSource()
|
const events = createEventSource()
|
||||||
const response = Promise.withResolvers<Response>()
|
const response = Promise.withResolvers<Response>()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue