Stabilize session event tests (#27117)

This commit is contained in:
Kit Langton
2026-05-12 18:51:18 +00:00
committed by GitHub
parent 3c7569d852
commit 549b146ea6
+132 -144
View File
@@ -1,186 +1,174 @@
import { describe, expect, test } from "bun:test" import { describe, expect } from "bun:test"
import path from "path" import { Deferred, Effect, Exit, Layer } from "effect"
import { Session as SessionNs } from "@/session/session" import { Session as SessionNs } from "@/session/session"
import { Bus } from "../../src/bus" import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { Instance } from "../../src/project/instance" import { Flag } from "@opencode-ai/core/flag/flag"
import { WithInstance } from "../../src/project/with-instance"
import { MessageV2 } from "../../src/session/message-v2" import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { AppRuntime } from "../../src/effect/app-runtime" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { tmpdir } from "../fixture/fixture" import { provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const projectRoot = path.join(__dirname, "../..")
void Log.init({ print: false }) void Log.init({ print: false })
function create(input?: SessionNs.CreateInput) { const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, CrossSpawnSpawner.defaultLayer))
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create(input)))
}
function get(id: SessionID) { const awaitDeferred = <T>(deferred: Deferred.Deferred<T>, message: string) =>
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.get(id))) Effect.race(
} Deferred.await(deferred),
Effect.sleep("2 seconds").pipe(Effect.flatMap(() => Effect.fail(new Error(message)))),
)
function remove(id: SessionID) { const remove = (id: SessionID) => SessionNs.Service.use((svc) => svc.remove(id))
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.remove(id)))
}
function updateMessage<T extends MessageV2.Info>(msg: T) { const subscribeGlobal = (type: string, callback: (event: NonNullable<GlobalEvent["payload"]>) => void) => {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updateMessage(msg))) const listener = (event: GlobalEvent) => {
} if (event.payload?.type === type) callback(event.payload)
}
function updatePart<T extends MessageV2.Part>(part: T) { GlobalBus.on("event", listener)
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updatePart(part))) return () => GlobalBus.off("event", listener)
} }
describe("session.created event", () => { describe("session.created event", () => {
test("should emit session.created event when session is created", async () => { it.instance("should emit session.created event when session is created", () =>
await WithInstance.provide({ Effect.gen(function* () {
directory: projectRoot, const session = yield* SessionNs.Service
fn: async () => { const received = yield* Deferred.make<SessionNs.Info>()
let eventReceived = false
let receivedInfo: SessionNs.Info | undefined
const unsub = Bus.subscribe(SessionNs.Event.Created, (event) => { const unsub = subscribeGlobal(SessionNs.Event.Created.type, (event) => {
eventReceived = true Deferred.doneUnsafe(received, Effect.succeed(event.properties.info as SessionNs.Info))
receivedInfo = event.properties.info as SessionNs.Info })
}) yield* Effect.addFinalizer(() => Effect.sync(unsub))
const info = await create({}) const info = yield* session.create({})
await new Promise((resolve) => setTimeout(resolve, 100)) const receivedInfo = yield* awaitDeferred(received, "timed out waiting for session.created")
unsub()
expect(eventReceived).toBe(true) expect(receivedInfo.id).toBe(info.id)
expect(receivedInfo).toBeDefined() expect(receivedInfo.projectID).toBe(info.projectID)
expect(receivedInfo?.id).toBe(info.id) expect(receivedInfo.directory).toBe(info.directory)
expect(receivedInfo?.projectID).toBe(info.projectID) expect(receivedInfo.path).toBe(info.path)
expect(receivedInfo?.directory).toBe(info.directory) expect(receivedInfo.title).toBe(info.title)
expect(receivedInfo?.path).toBe(info.path)
expect(receivedInfo?.title).toBe(info.title)
await remove(info.id) yield* session.remove(info.id)
}, }),
}) )
})
test("session.created event should be emitted before session.updated", async () => { it.instance("session.created event should be emitted before session.updated", () =>
await WithInstance.provide({ Effect.gen(function* () {
directory: projectRoot, if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return
fn: async () => {
const events: string[] = []
const unsubCreated = Bus.subscribe(SessionNs.Event.Created, () => { const session = yield* SessionNs.Service
events.push("created") const events: string[] = []
}) const received = yield* Deferred.make<string[]>()
const push = (event: string) => {
events.push(event)
if (events.includes("created") && events.includes("updated")) {
Deferred.doneUnsafe(received, Effect.succeed(events))
}
}
const unsubUpdated = Bus.subscribe(SessionNs.Event.Updated, () => { const unsubCreated = subscribeGlobal(SessionNs.Event.Created.type, () => {
events.push("updated") push("created")
}) })
yield* Effect.addFinalizer(() => Effect.sync(unsubCreated))
const info = await create({}) const unsubUpdated = subscribeGlobal(SessionNs.Event.Updated.type, () => {
await new Promise((resolve) => setTimeout(resolve, 100)) push("updated")
unsubCreated() })
unsubUpdated() yield* Effect.addFinalizer(() => Effect.sync(unsubUpdated))
expect(events).toContain("created") const info = yield* session.create({})
expect(events).toContain("updated") const receivedEvents = yield* awaitDeferred(received, "timed out waiting for session created/updated events")
expect(events.indexOf("created")).toBeLessThan(events.indexOf("updated"))
await remove(info.id) expect(receivedEvents).toContain("created")
}, expect(receivedEvents).toContain("updated")
}) expect(receivedEvents.indexOf("created")).toBeLessThan(receivedEvents.indexOf("updated"))
})
yield* session.remove(info.id)
}),
)
}) })
describe("step-finish token propagation via Bus event", () => { describe("step-finish token propagation via Bus event", () => {
test( it.instance(
"non-zero tokens propagate through PartUpdated event", "non-zero tokens propagate through PartUpdated event",
async () => { () =>
await WithInstance.provide({ Effect.gen(function* () {
directory: projectRoot, const session = yield* SessionNs.Service
fn: async () => { const info = yield* session.create({})
const info = await create({})
const messageID = MessageID.ascending() const messageID = MessageID.ascending()
await updateMessage({ yield* session.updateMessage({
id: messageID, id: messageID,
sessionID: info.id, sessionID: info.id,
role: "user", role: "user",
time: { created: Date.now() }, time: { created: Date.now() },
agent: "user", agent: "user",
model: { providerID: "test", modelID: "test" }, model: { providerID: "test", modelID: "test" },
tools: {}, tools: {},
mode: "", mode: "",
} as unknown as MessageV2.Info) } as unknown as MessageV2.Info)
// Bus subscribers receive readonly Schema.Type payloads; `MessageV2.Part` // Bus subscribers receive readonly Schema.Type payloads; `MessageV2.Part`
// is the mutable domain type. Cast bridges the two — safe because the // is the mutable domain type. Cast bridges the two — safe because the
// test only reads the value afterwards. // test only reads the value afterwards.
let received: MessageV2.Part | undefined const received = yield* Deferred.make<MessageV2.Part>()
const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, (event) => { const unsub = subscribeGlobal(MessageV2.Event.PartUpdated.type, (event) => {
received = event.properties.part as MessageV2.Part Deferred.doneUnsafe(received, Effect.succeed(event.properties.part as MessageV2.Part))
}) })
yield* Effect.addFinalizer(() => Effect.sync(unsub))
const tokens = { const tokens = {
total: 1500, total: 1500,
input: 500, input: 500,
output: 800, output: 800,
reasoning: 200, reasoning: 200,
cache: { read: 100, write: 50 }, cache: { read: 100, write: 50 },
} }
const partInput = { const partInput = {
id: PartID.ascending(), id: PartID.ascending(),
messageID, messageID,
sessionID: info.id, sessionID: info.id,
type: "step-finish" as const, type: "step-finish" as const,
reason: "stop", reason: "stop",
cost: 0.005, cost: 0.005,
tokens, tokens,
} }
await updatePart(partInput) yield* session.updatePart(partInput)
await new Promise((resolve) => setTimeout(resolve, 100)) const receivedPart = yield* awaitDeferred(received, "timed out waiting for message.part.updated")
expect(received).toBeDefined() expect(receivedPart.type).toBe("step-finish")
expect(received!.type).toBe("step-finish") const finish = receivedPart as MessageV2.StepFinishPart
const finish = received as MessageV2.StepFinishPart expect(finish.tokens.input).toBe(500)
expect(finish.tokens.input).toBe(500) expect(finish.tokens.output).toBe(800)
expect(finish.tokens.output).toBe(800) expect(finish.tokens.reasoning).toBe(200)
expect(finish.tokens.reasoning).toBe(200) expect(finish.tokens.total).toBe(1500)
expect(finish.tokens.total).toBe(1500) expect(finish.tokens.cache.read).toBe(100)
expect(finish.tokens.cache.read).toBe(100) expect(finish.tokens.cache.write).toBe(50)
expect(finish.tokens.cache.write).toBe(50) expect(finish.cost).toBe(0.005)
expect(finish.cost).toBe(0.005) expect(receivedPart).not.toBe(partInput)
expect(received).not.toBe(partInput)
unsub() yield* session.remove(info.id)
await remove(info.id) }),
},
})
},
{ timeout: 30000 }, { timeout: 30000 },
) )
}) })
describe("Session", () => { describe("Session", () => {
test("remove works without an instance", async () => { it.live("remove works without an instance", () =>
await using tmp = await tmpdir({ git: true }) Effect.gen(function* () {
const session = yield* SessionNs.Service
const dir = yield* tmpdirScoped({ git: true })
const info = yield* provideInstance(dir)(session.create({ title: "remove-without-instance" }))
const info = await WithInstance.provide({ const removeExit = yield* remove(info.id).pipe(Effect.exit)
directory: tmp.path, expect(Exit.isSuccess(removeExit)).toBe(true)
fn: () => create({ title: "remove-without-instance" }),
})
await expect(async () => { const getExit = yield* session.get(info.id).pipe(Effect.exit)
await remove(info.id) expect(Exit.isFailure(getExit)).toBe(true)
}).not.toThrow() }),
)
let missing = false
await get(info.id).catch(() => {
missing = true
})
expect(missing).toBe(true)
})
}) })