| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253 |
- import { describe, expect } from "bun:test"
- import { SessionV1 } from "@kirincode-ai/core/v1/session"
- import { EventV2 } from "@kirincode-ai/core/event"
- import { SessionProjector } from "@kirincode-ai/core/session/projector"
- import { Deferred, Effect, Exit, Layer } from "effect"
- import { Session as SessionNs } from "@/session/session"
- import { MessageV2 } from "../../src/session/message-v2"
- import { MessageID, PartID, type SessionID } from "../../src/session/schema"
- import { CrossSpawnSpawner } from "@kirincode-ai/core/cross-spawn-spawner"
- import { provideInstance, tmpdirScoped } from "../fixture/fixture"
- import { testEffect } from "../lib/effect"
- import { RuntimeFlags } from "@/effect/runtime-flags"
- import { EventV2Bridge } from "@/event-v2-bridge"
- import { GlobalBus } from "@/bus/global"
- import { AppNodeBuilder } from "@kirincode-ai/core/effect/app-node-builder"
- import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
- import { InstanceStore } from "@/project/instance-store"
- import { InstanceBootstrap } from "@/project/bootstrap"
- const it = testEffect(
- AppNodeBuilder.build(
- LayerNode.group([
- SessionNs.node,
- EventV2Bridge.node,
- SessionProjector.node,
- CrossSpawnSpawner.node,
- InstanceStore.node,
- ]),
- [
- [RuntimeFlags.node, RuntimeFlags.layer({ experimentalWorkspaces: false })],
- [
- InstanceBootstrap.node,
- Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })),
- ],
- ],
- ),
- )
- const awaitDeferred = <T>(deferred: Deferred.Deferred<T>, message: string) =>
- Effect.race(
- Deferred.await(deferred),
- Effect.sleep("2 seconds").pipe(Effect.flatMap(() => Effect.fail(new Error(message)))),
- )
- const remove = (id: SessionID) => SessionNs.use.remove(id)
- describe("session.created event", () => {
- it.instance("should emit session.created event when session is created", () =>
- Effect.gen(function* () {
- const session = yield* SessionNs.Service
- const events = yield* EventV2Bridge.Service
- const received = yield* Deferred.make<SessionNs.Info>()
- const unsub = yield* events.listen((event) => {
- if (event.type === SessionNs.Event.Created.type)
- Deferred.doneUnsafe(
- received,
- Effect.succeed((event.data as typeof SessionNs.Event.Created.data.Type).info as SessionNs.Info),
- )
- return Effect.void
- })
- yield* Effect.addFinalizer(() => unsub)
- const info = yield* session.create({})
- const receivedInfo = yield* awaitDeferred(received, "timed out waiting for session.created")
- expect(receivedInfo.id).toBe(info.id)
- expect(receivedInfo.projectID).toBe(info.projectID)
- expect(receivedInfo.directory).toBe(info.directory)
- expect(receivedInfo.path).toBe(info.path)
- expect(receivedInfo.title).toBe(info.title)
- yield* session.remove(info.id)
- }),
- )
- it.instance("session.created event should be emitted before session.updated", () =>
- Effect.gen(function* () {
- const session = yield* SessionNs.Service
- const source = yield* EventV2Bridge.Service
- 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 unsubscribe = yield* source.listen((event) => {
- if (event.type === SessionNs.Event.Created.type) push("created")
- if (event.type === SessionNs.Event.Updated.type) push("updated")
- return Effect.void
- })
- yield* Effect.addFinalizer(() => unsubscribe)
- const info = yield* session.create({})
- yield* session.setTitle({ sessionID: info.id, title: "updated" })
- const receivedEvents = yield* awaitDeferred(received, "timed out waiting for session created/updated events")
- expect(receivedEvents).toContain("created")
- expect(receivedEvents).toContain("updated")
- expect(receivedEvents.indexOf("created")).toBeLessThan(receivedEvents.indexOf("updated"))
- yield* session.remove(info.id)
- }),
- )
- it.instance("emits legacy global sync payload", () =>
- Effect.gen(function* () {
- const session = yield* SessionNs.Service
- const received = yield* Deferred.make<{ syncEvent: EventV2.SerializedEvent }>()
- const listener = (event: { payload: { type?: string; syncEvent?: EventV2.SerializedEvent } }) => {
- if (event.payload.type === "sync" && event.payload.syncEvent)
- Deferred.doneUnsafe(received, Effect.succeed({ syncEvent: event.payload.syncEvent }))
- }
- GlobalBus.on("event", listener)
- yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", listener)))
- const info = yield* session.create({})
- const event = yield* awaitDeferred(received, "timed out waiting for legacy global sync event")
- expect(event.syncEvent).toMatchObject({
- type: EventV2.versionedType(SessionNs.Event.Created.type, 1),
- seq: 0,
- aggregateID: info.id,
- data: { sessionID: info.id },
- })
- yield* session.remove(info.id)
- }),
- )
- })
- describe("step-finish token propagation via event", () => {
- it.instance(
- "non-zero tokens propagate through PartUpdated event",
- () =>
- Effect.gen(function* () {
- const session = yield* SessionNs.Service
- const events = yield* EventV2Bridge.Service
- const info = yield* session.create({})
- const messageID = MessageID.ascending()
- yield* session.updateMessage({
- id: messageID,
- sessionID: info.id,
- role: "user",
- time: { created: Date.now() },
- agent: "user",
- model: { providerID: "test", modelID: "test" },
- tools: {},
- mode: "",
- } as unknown as SessionV1.Info)
- // Event subscribers receive readonly Schema.Type payloads; `SessionV1.Part`
- // is the mutable domain type. Cast bridges the two — safe because the
- // test only reads the value afterwards.
- const received = yield* Deferred.make<SessionV1.Part>()
- const unsub = yield* events.listen((event) => {
- if (event.type === MessageV2.Event.PartUpdated.type)
- Deferred.doneUnsafe(
- received,
- Effect.succeed((event.data as typeof MessageV2.Event.PartUpdated.data.Type).part as SessionV1.Part),
- )
- return Effect.void
- })
- yield* Effect.addFinalizer(() => unsub)
- const tokens = {
- total: 1500,
- input: 500,
- output: 800,
- reasoning: 200,
- cache: { read: 100, write: 50 },
- }
- const partInput = {
- id: PartID.ascending(),
- messageID,
- sessionID: info.id,
- type: "step-finish" as const,
- reason: "stop",
- cost: 0.005,
- tokens,
- }
- yield* session.updatePart(partInput)
- const receivedPart = yield* awaitDeferred(received, "timed out waiting for message.part.updated")
- expect(receivedPart.type).toBe("step-finish")
- const finish = receivedPart as SessionV1.StepFinishPart
- expect(finish.tokens.input).toBe(500)
- expect(finish.tokens.output).toBe(800)
- expect(finish.tokens.reasoning).toBe(200)
- expect(finish.tokens.total).toBe(1500)
- expect(finish.tokens.cache.read).toBe(100)
- expect(finish.tokens.cache.write).toBe(50)
- expect(finish.cost).toBe(0.005)
- expect(receivedPart).not.toBe(partInput)
- yield* session.remove(info.id)
- }),
- { timeout: 30000 },
- )
- })
- describe("Session", () => {
- it.live("remove works without an instance", () =>
- 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 removeExit = yield* remove(info.id).pipe(Effect.exit)
- expect(Exit.isSuccess(removeExit)).toBe(true)
- const getExit = yield* session.get(info.id).pipe(Effect.exit)
- expect(Exit.isFailure(getExit)).toBe(true)
- }),
- )
- it.instance("persists metadata and copies it on fork by default", () =>
- Effect.gen(function* () {
- const session = yield* SessionNs.Service
- const meta = { source: "sdk", trace: { id: "abc" } }
- const created = yield* Effect.acquireRelease(session.create({ title: "with-meta", metadata: meta }), (info) =>
- session.remove(info.id).pipe(Effect.ignore),
- )
- const saved = yield* session.get(created.id)
- const fork = yield* Effect.acquireRelease(session.fork({ sessionID: created.id }), (info) =>
- session.remove(info.id).pipe(Effect.ignore),
- )
- expect(saved.metadata).toEqual(meta)
- expect(fork.metadata).toEqual(meta)
- expect(fork.metadata).not.toBe(meta)
- }),
- )
- it.instance("omits metadata when not provided", () =>
- Effect.gen(function* () {
- const session = yield* SessionNs.Service
- const created = yield* Effect.acquireRelease(session.create({ title: "empty-meta" }), (info) =>
- session.remove(info.id).pipe(Effect.ignore),
- )
- const saved = yield* session.get(created.id)
- expect(created.metadata).toBeUndefined()
- expect(saved.metadata).toBeUndefined()
- }),
- )
- })
|