feat(aircoding): AirCoding V2 baseline — deterministic multi-agent architecture
Forked from OpenCode v1.17.4 with multi-agent system: - 5 agents: aircoding, scheduler, worker, architect, reviewer - Deterministic DAG scheduling engine (coordinator_tick) - Tool whitelists as hard enforcement - AirCoding validation plugin - V1 requirements: C4 docs, ADR, AGENTS.md, debug-log.md - Design documents in docs/
This commit is contained in:
248
packages/opencode/test/session/session.test.ts
Normal file
248
packages/opencode/test/session/session.test.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { SessionProjector } from "@opencode-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 "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { BackgroundJob } from "@/background/job"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
SessionNs.layer.pipe(
|
||||
Layer.provide(Storage.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provideMerge(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(SessionProjector.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })),
|
||||
Layer.provide(BackgroundJob.defaultLayer),
|
||||
),
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
testInstanceStoreLayer,
|
||||
),
|
||||
)
|
||||
|
||||
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()
|
||||
}),
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user