chore: merge dev

This commit is contained in:
Dax Raad 2026-06-04 01:24:55 -04:00
commit d3fe65e2f7
312 changed files with 32857 additions and 4562 deletions

View file

@ -1,6 +1,10 @@
import { describe, expect } from "bun:test"
import { Effect, Exit, Scope } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Location } from "@opencode-ai/core/location"
import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const it = testEffect(AgentV2.locationLayer)
@ -98,4 +102,30 @@ describe("AgentV2", () => {
expect(yield* agent.get(id)).toBeUndefined()
}),
)
it.effect("does not ambiently opt built-in agents into bash", () =>
Effect.gen(function* () {
const agent = yield* AgentV2.Service
yield* AgentPlugin.Plugin.effect.pipe(
Effect.provideService(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
),
)
const agents = yield* agent.all()
expect(agents.map((item) => String(item.id)).sort()).toEqual([
"build",
"compaction",
"explore",
"general",
"plan",
"summary",
"title",
])
for (const item of agents) {
expect(item.permissions.some((rule) => rule.action === "bash" && rule.effect !== "deny")).toBe(false)
}
}),
)
})

View file

@ -0,0 +1,103 @@
import { describe, expect } from "bun:test"
import { BackgroundJob } from "@opencode-ai/core/background-job"
import { Deferred, Effect, Exit, Scope } from "effect"
import { it } from "./lib/effect"
describe("BackgroundJob", () => {
it.live("tracks process-local work through explicit observation", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const latch = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "test",
metadata: { durable: false },
run: Deferred.await(latch).pipe(Effect.as("done")),
})
expect(job).toMatchObject({ type: "test", status: "running", metadata: { durable: false } })
expect(yield* jobs.wait({ id: job.id, timeout: 0 })).toMatchObject({
timedOut: true,
info: { status: "running" },
})
yield* Deferred.succeed(latch, undefined)
expect(yield* jobs.wait({ id: job.id })).toMatchObject({
timedOut: false,
info: { status: "completed", output: "done" },
})
}).pipe(Effect.provide(BackgroundJob.layer)),
)
it.live("publishes jobs before starting immediately settling work", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
yield* Effect.forEach(Array.from({ length: 100 }), (_, index) => {
const id = `job_immediate_start_${index}`
return Effect.gen(function* () {
const job = yield* jobs.start({
id,
type: "test",
run: jobs
.get(id)
.pipe(
Effect.flatMap((info) =>
info?.status === "running"
? Effect.succeed(`done-${index}`)
: Effect.fail("job started before publish"),
),
),
})
expect(yield* jobs.wait({ id: job.id })).toMatchObject({
timedOut: false,
info: { status: "completed", output: `done-${index}` },
})
})
})
}).pipe(Effect.provide(BackgroundJob.layer)),
)
it.live("increments pending work before starting immediately settling extensions", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
yield* Effect.forEach(Array.from({ length: 100 }), (_, index) =>
Effect.gen(function* () {
const first = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "test",
run: Deferred.await(first).pipe(Effect.as(`first-${index}`)),
})
expect(yield* jobs.extend({ id: job.id, run: Effect.succeed(`second-${index}`) })).toBe(true)
expect((yield* jobs.get(job.id))?.status).toBe("running")
yield* Deferred.succeed(first, undefined)
expect(yield* jobs.wait({ id: job.id })).toMatchObject({
timedOut: false,
info: { status: "completed", output: `second-${index}` },
})
}),
)
}).pipe(Effect.provide(BackgroundJob.layer)),
)
it.live("interrupts live work without promising settlement after the owning process-local scope closes", () =>
Effect.gen(function* () {
const scope = yield* Scope.make()
const interrupted = yield* Deferred.make<void>()
const jobs = yield* BackgroundJob.make.pipe(Scope.provide(scope))
const job = yield* jobs.start({
type: "test",
run: Effect.never.pipe(Effect.ensuring(Deferred.succeed(interrupted, undefined))),
})
yield* Scope.close(scope, Exit.void)
yield* Deferred.await(interrupted).pipe(Effect.timeout("1 second"))
// The abandoned in-memory registry is not a durable observation channel.
expect((yield* jobs.get(job.id))?.status).toBe("running")
}),
)
})

View file

@ -57,8 +57,14 @@ describe("ConfigSkillPlugin.Plugin", () => {
)
expect(sources).toEqual([
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join("/repo/.opencode", "skill")) }),
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join("/repo/.opencode", "skills")) }),
new SkillV2.DirectorySource({
type: "directory",
path: AbsolutePath.make(path.join("/repo/.opencode", "skill")),
}),
new SkillV2.DirectorySource({
type: "directory",
path: AbsolutePath.make(path.join("/repo/.opencode", "skills")),
}),
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
new SkillV2.DirectorySource({
type: "directory",

View file

@ -1,13 +1,15 @@
import { describe, expect, test } from "bun:test"
import { $ } from "bun"
import { fileURLToPath } from "url"
import path from "path"
import { SqliteClient } from "@effect/sql-sqlite-bun"
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { Effect } from "effect"
import { Effect, Layer } from "effect"
import { eq, inArray, sql } from "drizzle-orm"
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage"
import normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths"
import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/migration/20260603040000_session_message_projection_order"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
@ -15,6 +17,8 @@ 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"
const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
Effect.runPromise(
@ -24,6 +28,18 @@ const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
describe("DatabaseMigration", () => {
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") {
test("declared schema has no ungenerated migrations", async () => {
const result = await $`bun ${fileURLToPath(new URL("../script/migration.ts", import.meta.url))} --check`
@ -43,11 +59,74 @@ describe("DatabaseMigration", () => {
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({
name: "session",
})
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 25 })
expect(
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`),
).toEqual({ name: "session_input" })
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 29 })
expect(
yield* db.all(
sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`,
),
).toEqual([
{ name: "event_aggregate_seq_idx" },
{ name: "event_aggregate_type_seq_idx" },
{ name: "session_input_session_pending_delivery_seq_idx" },
{ name: "session_message_session_seq_idx" },
{ name: "session_message_session_time_created_id_idx" },
{ name: "session_message_session_type_seq_idx" },
])
}),
)
})
test("backfills projected Session message order from durable event sequence", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY, seq integer NOT NULL)`)
yield* db.run(
sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, time_created integer NOT NULL, data text NOT NULL)`,
)
yield* db.run(
sql`CREATE INDEX session_message_session_time_created_id_idx ON session_message (session_id, time_created, id)`,
)
yield* db.run(
sql`CREATE INDEX session_message_session_type_time_created_id_idx ON session_message (session_id, type, time_created, id)`,
)
yield* db.run(sql`INSERT INTO event (id, seq) VALUES ('evt_z', 0), ('evt_a', 1)`)
yield* db.run(
sql`INSERT INTO session_message (id, session_id, type, time_created, data) VALUES ('evt_z', 'session', 'user', 0, '{}'), ('evt_a', 'session', 'user', 0, '{}')`,
)
yield* DatabaseMigration.applyOnly(db, [sessionMessageProjectionOrderMigration])
expect(yield* db.all(sql`SELECT id, seq FROM session_message ORDER BY seq`)).toEqual([
{ id: "evt_z", seq: 0 },
{ id: "evt_a", seq: 1 },
])
}),
)
})
test("fails projected Session message order backfill without a durable event", async () => {
await expect(
run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY, seq integer NOT NULL)`)
yield* db.run(
sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, time_created integer NOT NULL, data text NOT NULL)`,
)
yield* db.run(
sql`INSERT INTO session_message (id, session_id, type, time_created, data) VALUES ('evt_missing', 'session', 'user', 0, '{}')`,
)
yield* DatabaseMigration.applyOnly(db, [sessionMessageProjectionOrderMigration])
}),
),
).rejects.toThrow("Cannot migrate session_message projections without matching durable events")
})
test("runs session usage backfill in order with schema changes", async () => {
await run(
Effect.gen(function* () {

View file

@ -0,0 +1,73 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber } from "effect"
import { KeyedMutex } from "@opencode-ai/core/effect/keyed-mutex"
import { it } from "../lib/effect"
describe("KeyedMutex", () => {
it.effect("serializes effects with the same key", () =>
Effect.gen(function* () {
const mutex = yield* KeyedMutex.make<string>()
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
const first = yield* mutex
.withLock("shared")(
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
)
.pipe(Effect.forkChild)
yield* Deferred.await(firstStarted)
const second = yield* mutex.withLock("shared")(Deferred.succeed(secondStarted, undefined)).pipe(Effect.forkChild)
yield* Effect.yieldNow
expect(yield* Deferred.isDone(secondStarted)).toBe(false)
yield* Deferred.succeed(releaseFirst, undefined)
yield* Fiber.join(first)
yield* Fiber.join(second)
expect(yield* mutex.size).toBe(0)
}),
)
it.effect("allows different keys to proceed independently", () =>
Effect.gen(function* () {
const mutex = yield* KeyedMutex.make<string>()
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
const secondFinished = yield* Deferred.make<void>()
const first = yield* mutex
.withLock("first")(Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))))
.pipe(Effect.forkChild)
yield* Deferred.await(firstStarted)
yield* mutex.withLock("second")(Deferred.succeed(secondFinished, undefined))
expect(yield* Deferred.isDone(secondFinished)).toBe(true)
yield* Deferred.succeed(releaseFirst, undefined)
yield* Fiber.join(first)
expect(yield* mutex.size).toBe(0)
}),
)
it.effect("removes an interrupted waiter without dropping the holder lock", () =>
Effect.gen(function* () {
const mutex = yield* KeyedMutex.make<string>()
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
const first = yield* mutex
.withLock("shared")(
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
)
.pipe(Effect.forkChild)
yield* Deferred.await(firstStarted)
const interrupted = yield* mutex.withLock("shared")(Effect.void).pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* Fiber.interrupt(interrupted)
expect(yield* mutex.size).toBe(1)
yield* Deferred.succeed(releaseFirst, undefined)
yield* Fiber.join(first)
expect(yield* mutex.size).toBe(0)
}),
)
})

View file

@ -1,18 +1,21 @@
import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer, Schema, Stream } from "effect"
import { DateTime, Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { EventV2 } from "@opencode-ai/core/event"
import { Database } from "@opencode-ai/core/database/database"
import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Project } from "@opencode-ai/core/project"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { V2Schema } from "@opencode-ai/core/v2-schema"
import { eq } from "drizzle-orm"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("project"), workspaceID: "workspace" })),
Location.Service.of(
location({ directory: AbsolutePath.make("project"), workspaceID: WorkspaceV2.ID.make("wrk_test") }),
),
)
const eventLayer = Layer.mergeAll(EventV2.defaultLayer, Database.defaultLayer)
const it = testEffect(eventLayer.pipe(Layer.provideMerge(locationLayer)))
@ -68,7 +71,32 @@ const VersionedMessage = EventV2.define({
},
})
const SyncTimestamp = EventV2.define({
type: "test.timestamp",
sync: {
version: 1,
aggregate: "id",
},
schema: {
id: Schema.String,
timestamp: V2Schema.DateTimeUtcFromMillis,
},
})
describe("EventV2", () => {
it.effect("derives stable namespaced external IDs", () =>
Effect.sync(() => {
const input = { namespace: "opencord.agent-input", key: "input-1" }
expect(EventV2.ID.fromExternal(input)).toBe(EventV2.ID.fromExternal(input))
expect(EventV2.ID.fromExternal(input)).toMatch(/^evt_[a-f0-9]{64}$/)
expect(EventV2.ID.fromExternal({ ...input, namespace: "another-app" })).not.toBe(EventV2.ID.fromExternal(input))
expect(EventV2.ID.fromExternal({ namespace: "a:b", key: "c" })).not.toBe(
EventV2.ID.fromExternal({ namespace: "a", key: "b:c" }),
)
}),
)
it.effect("publishes events with the current location", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
@ -83,8 +111,7 @@ describe("EventV2", () => {
expect(event.data).toEqual({ text: "hello" })
expect(event.location).toEqual({
directory: AbsolutePath.make("project"),
workspaceID: "workspace",
project: { id: Project.ID.global, directory: AbsolutePath.make("project") },
workspaceID: WorkspaceV2.ID.make("wrk_test"),
})
}),
)
@ -109,19 +136,6 @@ describe("EventV2", () => {
}),
)
it.effect("publishes sync metadata", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const aggregateID = EventV2.ID.create()
const event = yield* events.publish(SyncMessage, { id: aggregateID, text: "hello" })
expect(event.sync).toEqual({
seq: 0,
aggregateID,
})
}),
)
it.effect("stores definitions in the exported registry", () =>
Effect.sync(() => {
expect(EventV2.registry.get(Message.type)).toBe(Message)
@ -222,6 +236,24 @@ describe("EventV2", () => {
}),
)
it.effect("does not synchronize live-only events", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const synchronized = new Array<string>()
const unsubscribe = yield* events.sync((event) =>
Effect.sync(() => {
synchronized.push(event.type)
}),
)
yield* Effect.addFinalizer(() => unsubscribe)
yield* events.publish(Message, { text: "live only" })
yield* events.publish(SyncMessage, { id: "one", text: "durable" })
expect(synchronized).toEqual([SyncMessage.type])
}),
)
it.effect("inserts sync event rows on publish", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
@ -261,6 +293,120 @@ describe("EventV2", () => {
}),
)
it.effect("replays durable aggregate events after a cursor and tails new events", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const aggregateID = EventV2.ID.create()
yield* events.publish(SyncMessage, { id: aggregateID, text: "zero" })
yield* events.publish(SyncMessage, { id: aggregateID, text: "one" })
const fiber = yield* events
.aggregateEvents({ aggregateID, after: EventV2.Cursor.make(0) })
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* events.publish(SyncMessage, { id: aggregateID, text: "two" })
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.cursor, event.event.data])).toEqual([
[EventV2.Cursor.make(1), { id: aggregateID, text: "one" }],
[EventV2.Cursor.make(2), { id: aggregateID, text: "two" }],
])
}),
)
it.effect("catches durable aggregate events published during replay handoff", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const aggregateID = EventV2.ID.create()
yield* events.publish(SyncMessage, { id: aggregateID, text: "zero" })
const fiber = yield* events
.aggregateEvents({ aggregateID })
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
yield* events.publish(SyncMessage, { id: aggregateID, text: "one" })
expect(
Array.from(yield* Fiber.join(fiber)).map((event) => [
event.cursor,
(event.event.data as { text: string }).text,
]),
).toEqual([
[EventV2.Cursor.make(0), "zero"],
[EventV2.Cursor.make(1), "one"],
])
}),
)
it.effect("retains a durable wake committed while historical replay is paused", () =>
Effect.gen(function* () {
const readStarted = yield* Deferred.make<void>()
const continueRead = yield* Deferred.make<void>()
let pause = true
const database = Database.layerFromPath(":memory:")
const eventLayer = EventV2.layerWith({
beforeAggregateRead: () =>
pause
? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead)))
: Effect.void,
}).pipe(Layer.provide(database))
yield* Effect.gen(function* () {
const events = yield* EventV2.Service
const aggregateID = EventV2.ID.create()
const fiber = yield* events
.aggregateEvents({ aggregateID })
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Deferred.await(readStarted)
pause = false
yield* events.publish(SyncMessage, { id: aggregateID, text: "during handoff" })
yield* Deferred.succeed(continueRead, undefined)
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.cursor, event.event.data])).toEqual([
[EventV2.Cursor.make(0), { id: aggregateID, text: "during handoff" }],
])
}).pipe(Effect.provide(Layer.mergeAll(database, eventLayer)))
}),
)
it.effect("coalesces durable aggregate wakes while draining every committed event", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const aggregateID = EventV2.ID.create()
const count = 64
const fiber = yield* events
.aggregateEvents({ aggregateID })
.pipe(Stream.take(count), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
for (let index = 0; index < count; index++) {
yield* events.publish(SyncMessage, { id: aggregateID, text: String(index) })
}
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.cursor, event.event.data])).toEqual(
Array.from({ length: count }, (_, index) => [
EventV2.Cursor.make(index),
{ id: aggregateID, text: String(index) },
]),
)
}),
)
it.effect("omits live-only events from durable aggregate streams", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const aggregateID = EventV2.ID.create()
const fiber = yield* events
.aggregateEvents({ aggregateID })
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* events.publish(Message, { text: "live only" })
yield* events.publish(SyncMessage, { id: aggregateID, text: "durable" })
expect(Array.from(yield* Fiber.join(fiber)).map((event) => event.event.type)).toEqual([SyncMessage.type])
}),
)
it.effect("uses custom sync aggregate field", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
@ -329,6 +475,51 @@ describe("EventV2", () => {
}),
)
it.effect(
"replay rejects an envelope aggregate that differs from its payload without mutating the payload aggregate",
() =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const { db } = yield* Database.Service
const envelopeAggregateID = EventV2.ID.create()
const payloadAggregateID = EventV2.ID.create()
const received = new Array<EventV2.Payload>()
yield* events.publish(SyncMessage, { id: payloadAggregateID, text: "seed" })
yield* events.project(SyncMessage, (event) =>
Effect.sync(() => {
received.push(event)
}),
)
const exit = yield* events
.replay({
id: EventV2.ID.create(),
type: EventV2.versionedType(SyncMessage.type, 1),
seq: 1,
aggregateID: envelopeAggregateID,
data: { id: payloadAggregateID, text: "replayed" },
})
.pipe(Effect.exit)
const rows = yield* db
.select()
.from(EventTable)
.where(eq(EventTable.aggregate_id, payloadAggregateID))
.all()
.pipe(Effect.orDie)
const sequence = yield* db
.select({ seq: EventSequenceTable.seq })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, payloadAggregateID))
.get()
.pipe(Effect.orDie)
expect(String(exit)).toContain("Aggregate mismatch")
expect(received).toHaveLength(0)
expect(rows).toHaveLength(1)
expect(sequence).toEqual({ seq: 0 })
}),
)
it.effect("replay defects on sequence mismatch", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
@ -355,6 +546,29 @@ describe("EventV2", () => {
}),
)
it.effect("replay decodes synchronized transformed values before projection", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const aggregateID = EventV2.ID.create()
const received = new Array<typeof SyncTimestamp.Type>()
yield* events.project(SyncTimestamp, (event) =>
Effect.sync(() => {
received.push(event)
}),
)
yield* events.replay({
id: EventV2.ID.create(),
type: EventV2.versionedType(SyncTimestamp.type, 1),
seq: 0,
aggregateID,
data: { id: aggregateID, timestamp: 0 },
})
expect(received[0]?.data.timestamp).toEqual(DateTime.makeUnsafe(0))
}),
)
it.effect("replay defects on unknown event type", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
@ -503,11 +717,111 @@ describe("EventV2", () => {
}),
)
it.effect("replay claims an existing unowned sequence before fencing a different owner", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const { db } = yield* Database.Service
const aggregateID = EventV2.ID.create()
yield* events.publish(SyncMessage, { id: aggregateID, text: "local" })
yield* events.replay(
{
id: EventV2.ID.create(),
type: EventV2.versionedType(SyncMessage.type, 1),
seq: 1,
aggregateID,
data: { id: aggregateID, text: "claimed" },
},
{ ownerID: "owner-1" },
)
yield* events.replay(
{
id: EventV2.ID.create(),
type: EventV2.versionedType(SyncMessage.type, 1),
seq: 2,
aggregateID,
data: { id: aggregateID, text: "fenced" },
},
{ ownerID: "owner-2" },
)
const rows = yield* db
.select()
.from(EventTable)
.where(eq(EventTable.aggregate_id, aggregateID))
.all()
.pipe(Effect.orDie)
const sequence = yield* db
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.get()
.pipe(Effect.orDie)
expect(rows.map((row) => row.seq)).toEqual([0, 1])
expect(sequence).toEqual({ seq: 1, ownerID: "owner-1" })
}),
)
it.effect("strict replay rejects an owner conflict instead of silently skipping it", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const aggregateID = EventV2.ID.create()
yield* events.replay(
{
id: EventV2.ID.create(),
type: EventV2.versionedType(SyncMessage.type, 1),
seq: 0,
aggregateID,
data: { id: aggregateID, text: "claimed" },
},
{ ownerID: "owner-1" },
)
const exit = yield* events
.replay(
{
id: EventV2.ID.create(),
type: EventV2.versionedType(SyncMessage.type, 1),
seq: 1,
aggregateID,
data: { id: aggregateID, text: "conflict" },
},
{ ownerID: "owner-2", strictOwner: true },
)
.pipe(Effect.exit)
expect(String(exit)).toContain("Replay owner mismatch")
}),
)
it.effect("publishes accepted replay with its durable sequence and suppresses stale replay", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const received = new Array<EventV2.Payload>()
const aggregateID = EventV2.ID.create()
yield* events.listen((event) => Effect.sync(() => received.push(event)))
const replayed = {
id: EventV2.ID.create(),
type: EventV2.versionedType(SyncMessage.type, 1),
seq: 0,
aggregateID,
data: { id: aggregateID, text: "replayed" },
}
yield* events.replay(replayed, { publish: true })
yield* events.replay(replayed, { publish: true })
expect(received).toMatchObject([{ id: replayed.id, seq: 0, data: replayed.data }])
}),
)
it.effect("replay from a different owner leaves claimed sequence unchanged", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const { db } = yield* Database.Service
const aggregateID = EventV2.ID.create()
const received = new Array<EventV2.Payload>()
yield* events.listen((event) => Effect.sync(() => received.push(event)))
yield* events.replay(
{
@ -527,7 +841,7 @@ describe("EventV2", () => {
aggregateID,
data: { id: aggregateID, text: "ignored" },
},
{ ownerID: "owner-2" },
{ ownerID: "owner-2", publish: true },
)
const rows = yield* db
.select()
@ -544,6 +858,7 @@ describe("EventV2", () => {
expect(rows).toHaveLength(1)
expect(sequence).toEqual({ seq: 0, ownerID: "owner-1" })
expect(received).toHaveLength(0)
}),
)

View file

@ -0,0 +1,357 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer } from "effect"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
function provide(directory: string, filesystem = FSUtil.defaultLayer) {
const activeLocation = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
)
const planning = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
const commits = FileMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(planning))
return Effect.provide(Layer.mergeAll(planning, commits))
}
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
}
describe("FileMutation", () => {
it.live("writes an existing internal file and returns a stable result", () =>
withTmp((directory) =>
Effect.gen(function* () {
const targetPath = path.join(directory, "hello.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "before"))
const plan = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
expect(yield* (yield* FileMutation.Service).write({ plan, content: "after" })).toEqual({
operation: "write",
target: plan.target.canonical,
resource: "hello.txt",
existed: true,
})
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("after")
}).pipe(provide(directory)),
),
)
it.live("writes a prospective internal file and creates parent directories", () =>
withTmp((directory) =>
Effect.gen(function* () {
const plan = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "nested", "hello.txt") })
const result = yield* (yield* FileMutation.Service).write({ plan, content: "hello" })
expect(result).toEqual({
operation: "write",
target: plan.target.canonical,
resource: "src/nested/hello.txt",
existed: false,
})
expect(yield* Effect.promise(() => fs.readFile(result.target, "utf8"))).toBe("hello")
}).pipe(provide(directory)),
),
)
it.live("preserves exactly one BOM for text writes and normalizes created text", () =>
withTmp((directory) =>
Effect.gen(function* () {
const preservedPath = path.join(directory, "preserved.txt")
yield* Effect.promise(() => fs.writeFile(preservedPath, "\uFEFFbefore"))
const preserved = yield* (yield* LocationMutation.Service).resolve({ path: "preserved.txt" })
const created = yield* (yield* LocationMutation.Service).resolve({ path: "created.txt" })
const files = yield* FileMutation.Service
yield* files.writeTextPreservingBom({ plan: preserved, content: "\uFEFFafter" })
yield* files.writeTextPreservingBom({ plan: created, content: "\uFEFF\uFEFF\uFEFFcreated" })
expect(yield* Effect.promise(() => fs.readFile(preservedPath, "utf8"))).toBe("\uFEFFafter")
expect(yield* Effect.promise(() => fs.readFile(created.target.canonical, "utf8"))).toBe("\uFEFFcreated")
}).pipe(provide(directory)),
),
)
it.live("rejects create when a prospective target appears after planning", () =>
withTmp((directory) =>
Effect.gen(function* () {
const targetPath = path.join(directory, "appeared.txt")
const plan = yield* (yield* LocationMutation.Service).resolve({ path: "appeared.txt" })
yield* Effect.promise(() => fs.writeFile(targetPath, "winner"))
expect(
yield* (yield* FileMutation.Service).create({ plan, content: "replacement" }).pipe(Effect.flip),
).toMatchObject({
_tag: "LocationMutation.RevalidationError",
})
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("winner")
}).pipe(provide(directory)),
),
)
it.live("removes an existing internal file", () =>
withTmp((directory) =>
Effect.gen(function* () {
const targetPath = path.join(directory, "remove.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "remove"))
const plan = yield* (yield* LocationMutation.Service).resolve({ path: "remove.txt" })
const result = yield* (yield* FileMutation.Service).remove({ plan })
expect(result).toEqual({
operation: "remove",
target: plan.target.canonical,
resource: "remove.txt",
existed: true,
})
expect(
yield* Effect.promise(() =>
fs.stat(targetPath).then(
() => true,
() => false,
),
),
).toBe(false)
}).pipe(provide(directory)),
),
)
it.live("writes an explicitly planned external target", () =>
withTmp((directory) =>
withTmp((outside) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "external.txt")
const plan = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const result = yield* (yield* FileMutation.Service).write({ plan, content: "external" })
expect(result).toEqual({
operation: "write",
target: plan.target.canonical,
resource: plan.target.resource,
existed: false,
})
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("external")
}).pipe(provide(directory)),
),
),
)
it.live("removes an explicitly planned external target", () =>
withTmp((directory) =>
withTmp((outside) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "external.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "external"))
const plan = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const result = yield* (yield* FileMutation.Service).remove({ plan })
expect(result).toEqual({
operation: "remove",
target: plan.target.canonical,
resource: plan.target.resource,
existed: true,
})
expect(
yield* Effect.promise(() =>
fs.stat(targetPath).then(
() => true,
() => false,
),
),
).toBe(false)
}).pipe(provide(directory)),
),
),
)
it.live("propagates revalidation rejection after an ancestor swap", () =>
withTmp((directory) =>
withTmp((outside) =>
Effect.gen(function* () {
if (process.platform === "win32") return
const parent = path.join(directory, "parent")
yield* Effect.promise(() => fs.mkdir(parent))
const plan = yield* (yield* LocationMutation.Service).resolve({ path: path.join("parent", "new.txt") })
yield* Effect.promise(async () => {
await fs.rmdir(parent)
await fs.symlink(outside, parent)
})
expect(
yield* (yield* FileMutation.Service).write({ plan, content: "escape" }).pipe(Effect.flip),
).toMatchObject({
_tag: "LocationMutation.RevalidationError",
})
expect(
yield* Effect.promise(() =>
fs.stat(path.join(outside, "new.txt")).then(
() => true,
() => false,
),
),
).toBe(false)
}).pipe(provide(directory)),
),
),
)
it.live("serializes concurrent writes to the same canonical target", () =>
withTmp((directory) =>
Effect.gen(function* () {
const targetPath = path.join(directory, "shared.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "initial"))
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
let writes = 0
const filesystem = instrumentWrites((write) =>
Effect.gen(function* () {
writes++
if (writes === 1) {
yield* Deferred.succeed(firstStarted, undefined)
yield* Deferred.await(releaseFirst)
} else {
yield* Deferred.succeed(secondStarted, undefined)
}
yield* write
}),
)
yield* Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const firstPlan = yield* mutation.resolve({ path: "shared.txt" })
const secondPlan = yield* mutation.resolve({ path: "shared.txt" })
const first = yield* files.write({ plan: firstPlan, content: "first" }).pipe(Effect.forkChild)
yield* Deferred.await(firstStarted)
const second = yield* files.write({ plan: secondPlan, content: "second" }).pipe(Effect.forkChild)
yield* Effect.yieldNow
expect(yield* Deferred.isDone(secondStarted)).toBe(false)
yield* Deferred.succeed(releaseFirst, undefined)
yield* Deferred.await(secondStarted)
yield* Fiber.join(first)
yield* Fiber.join(second)
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("second")
}).pipe(provide(directory, filesystem))
}),
),
)
it.live("allows only one concurrent conditional write based on the same bytes", () =>
withTmp((directory) =>
Effect.gen(function* () {
const targetPath = path.join(directory, "shared.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "initial"))
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
let writes = 0
const filesystem = instrumentWrites((write) =>
Effect.gen(function* () {
writes++
if (writes === 1) {
yield* Deferred.succeed(firstStarted, undefined)
yield* Deferred.await(releaseFirst)
}
yield* write
}),
)
yield* Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const plan = yield* mutation.resolve({ path: "shared.txt" })
const expected = new TextEncoder().encode("initial")
const first = yield* files.writeIfUnchanged({ plan, expected, content: "first" }).pipe(Effect.forkChild)
yield* Deferred.await(firstStarted)
const second = yield* files
.writeIfUnchanged({ plan, expected, content: "second" })
.pipe(Effect.flip, Effect.forkChild)
yield* Deferred.succeed(releaseFirst, undefined)
yield* Fiber.join(first)
expect(yield* Fiber.join(second)).toMatchObject({ _tag: "FileMutation.StaleContentError" })
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("first")
expect(writes).toBe(1)
}).pipe(provide(directory, filesystem))
}),
),
)
it.live("rejects a conditional write when target content is already stale", () =>
withTmp((directory) =>
Effect.gen(function* () {
const targetPath = path.join(directory, "stale.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "current"))
const plan = yield* (yield* LocationMutation.Service).resolve({ path: "stale.txt" })
expect(
yield* (yield* FileMutation.Service)
.writeIfUnchanged({ plan, expected: new TextEncoder().encode("older"), content: "replacement" })
.pipe(Effect.flip),
).toMatchObject({ _tag: "FileMutation.StaleContentError", path: plan.target.canonical })
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("current")
}).pipe(provide(directory)),
),
)
it.live("allows distinct canonical targets to proceed independently", () =>
withTmp((directory) =>
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
const secondFinished = yield* Deferred.make<void>()
const secondPath = path.join(directory, "second.txt")
let writes = 0
const filesystem = instrumentWrites((write) =>
++writes === 1
? Deferred.succeed(firstStarted, undefined).pipe(
Effect.andThen(Deferred.await(releaseFirst)),
Effect.andThen(write),
)
: write.pipe(Effect.andThen(Deferred.succeed(secondFinished, undefined))),
)
yield* Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const firstPlan = yield* mutation.resolve({ path: "first.txt" })
const secondPlan = yield* mutation.resolve({ path: "second.txt" })
const first = yield* files.write({ plan: firstPlan, content: "first" }).pipe(Effect.forkChild)
yield* Deferred.await(firstStarted)
const second = yield* files.write({ plan: secondPlan, content: "second" }).pipe(Effect.forkChild)
yield* Deferred.await(secondFinished)
expect(yield* Effect.promise(() => fs.readFile(secondPath, "utf8"))).toBe("second")
yield* Deferred.succeed(releaseFirst, undefined)
yield* Fiber.join(first)
yield* Fiber.join(second)
}).pipe(provide(directory, filesystem))
}),
),
)
})
function instrumentWrites(
run: (write: Effect.Effect<void, FSUtil.Error>, target: string) => Effect.Effect<void, FSUtil.Error>,
) {
return Layer.effect(
FSUtil.Service,
Effect.gen(function* () {
const filesystem = yield* FSUtil.Service
return FSUtil.Service.of({
...filesystem,
writeWithDirs: (target, content, mode) => run(filesystem.writeWithDirs(target, content, mode), target),
})
}),
).pipe(Layer.provide(FSUtil.defaultLayer))
}

View file

@ -354,13 +354,18 @@ describe("FSUtil", () => {
test("contains checks path containment", () => {
expect(FSUtil.contains("/a/b", "/a/b/c")).toBe(true)
expect(FSUtil.contains("/a/b", "/a/b")).toBe(true)
expect(FSUtil.contains("/a/b", "/a/c")).toBe(false)
expect(FSUtil.contains("/a/b", "/a/bad")).toBe(false)
if (process.platform === "win32") expect(FSUtil.contains("C:\\a", "D:\\b")).toBe(false)
})
test("overlaps detects overlapping paths", () => {
expect(FSUtil.overlaps("/a/b", "/a/b/c")).toBe(true)
expect(FSUtil.overlaps("/a/b/c", "/a/b")).toBe(true)
expect(FSUtil.overlaps("/a", "/b")).toBe(false)
expect(FSUtil.overlaps("/a/b", "/a/bad")).toBe(false)
if (process.platform === "win32") expect(FSUtil.overlaps("C:\\a", "D:\\b")).toBe(false)
})
})
})

View file

@ -49,6 +49,17 @@ const withRipgrepConfig = <A, E, R>(value: string, effect: Effect.Effect<A, E, R
)
describe("file.ripgrep", () => {
it.live("exposes a cached managed executable filepath", () =>
Effect.gen(function* () {
const ripgrep = yield* Ripgrep.Service
const first = yield* ripgrep.filepath
const second = yield* ripgrep.filepath
expect(first).toBe(second)
expect((yield* Effect.promise(() => fs.stat(first))).isFile()).toBe(true)
}),
)
it.live("defaults to include hidden", () =>
Effect.gen(function* () {
const dir = yield* tmpdir((dir) =>

View file

@ -3,11 +3,22 @@ import { tmpdir as osTmpdir } from "os"
import path from "path"
export const tmpdir = async () => {
const dir = await fs.mkdtemp(path.join(osTmpdir(), "opencode-core-test-"))
const dir = await fs.realpath(await fs.mkdtemp(path.join(osTmpdir(), "opencode-core-test-")))
return {
path: dir,
async [Symbol.asyncDispose]() {
await fs.rm(dir, { recursive: true, force: true })
await remove(dir)
},
}
}
async function remove(dir: string, retries = 10): Promise<void> {
try {
await fs.rm(dir, { recursive: true, force: true })
} catch (error) {
if (retries === 0 || !error || typeof error !== "object" || !("code" in error) || error.code !== "EBUSY")
throw error
await Bun.sleep(100)
return remove(dir, retries - 1)
}
}

View file

@ -0,0 +1,27 @@
{
"version": 1,
"metadata": {
"name": "session-runner/openai-chat-streams-text",
"recordedAt": "2026-06-02T19:52:25.084Z"
},
"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\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"chatcmpl-DmPRwO9SjY0GJZ3TFFe1Be72wysEG\",\"object\":\"chat.completion.chunk\",\"created\":1780429944,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_40bf7dabb5\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"f3yrdno80\"}\n\ndata: {\"id\":\"chatcmpl-DmPRwO9SjY0GJZ3TFFe1Be72wysEG\",\"object\":\"chat.completion.chunk\",\"created\":1780429944,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_40bf7dabb5\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"fDsGzJ\"}\n\ndata: {\"id\":\"chatcmpl-DmPRwO9SjY0GJZ3TFFe1Be72wysEG\",\"object\":\"chat.completion.chunk\",\"created\":1780429944,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_40bf7dabb5\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"RqaP5kpPNU\"}\n\ndata: {\"id\":\"chatcmpl-DmPRwO9SjY0GJZ3TFFe1Be72wysEG\",\"object\":\"chat.completion.chunk\",\"created\":1780429944,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_40bf7dabb5\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":null,\"obfuscation\":\"B19l5\"}\n\ndata: {\"id\":\"chatcmpl-DmPRwO9SjY0GJZ3TFFe1Be72wysEG\",\"object\":\"chat.completion.chunk\",\"created\":1780429944,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_40bf7dabb5\",\"choices\":[],\"usage\":{\"prompt_tokens\":22,\"completion_tokens\":2,\"total_tokens\":24,\"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\":\"kbiJobM55YE\"}\n\ndata: [DONE]\n\n"
}
}
]
}

View file

@ -1,8 +1,8 @@
import fs from "fs/promises"
import path from "path"
import { fileURLToPath } from "url"
import { describe, expect } from "bun:test"
import { Effect, Exit, Layer } from "effect"
import { describe, expect, test } from "bun:test"
import { Effect, Exit, Layer, Schema } from "effect"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
import { FileSystem } from "@opencode-ai/core/filesystem"
@ -22,12 +22,12 @@ const inertReferences = ProjectReference.Service.of({
containsManagedPath: () => Effect.succeed(false),
})
function provide(directory: string, references = inertReferences) {
function provide(directory: string, references = inertReferences, filesystem = FSUtil.defaultLayer) {
return Effect.provide(
FileSystem.layer.pipe(
Layer.provide(
Layer.mergeAll(
FSUtil.defaultLayer,
filesystem,
Ripgrep.defaultLayer,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
Layer.succeed(ProjectReference.Service, references),
@ -63,6 +63,43 @@ describe("FileSystem", () => {
encoding: "base64",
mime: "application/octet-stream",
})
const binary = yield* service.resolveRead({ path: RelativePath.make("data.bin") })
expect(Exit.isFailure(yield* service.readTextPageResolved(binary).pipe(Effect.exit))).toBe(true)
}).pipe(provide(directory)),
),
)
it.live("pages large UTF-8 text files by line with continuation", () =>
withTmp((directory) =>
Effect.gen(function* () {
const lines = Array.from({ length: 30 }, (_, index) => `line-${index + 1}`.padEnd(2_000, "x"))
yield* Effect.promise(() => fs.writeFile(path.join(directory, "large.txt"), lines.join("\n")))
const service = yield* FileSystem.Service
const target = yield* service.resolveRead({ path: RelativePath.make("large.txt") })
const first = yield* service.readTextPageResolved(target)
expect(first).toMatchObject({
type: "text-page",
offset: 1,
truncated: true,
})
expect(first.next).toBeDefined()
const next = first.next!
expect(yield* service.readTextPageResolved(target, { offset: next, limit: 1 })).toEqual({
type: "text-page",
content: lines[next - 1],
mime: "text/plain",
offset: next,
truncated: true,
next: next + 1,
})
expect(yield* service.readTextPageResolved(target, { offset: 30 })).toEqual({
type: "text-page",
content: lines[29],
mime: "text/plain",
offset: 30,
truncated: false,
})
}).pipe(provide(directory)),
),
)
@ -98,6 +135,163 @@ describe("FileSystem", () => {
),
)
it.live("lists stable bounded pages", () =>
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(directory, "src"))
await fs.writeFile(path.join(directory, "README.md"), "# Test")
})
const service = yield* FileSystem.Service
expect(yield* service.listPage({ limit: 1 })).toMatchObject({
entries: [{ path: "src", type: "directory" }],
truncated: true,
next: 2,
})
expect(yield* service.listPage({ offset: 2, limit: 1 })).toMatchObject({
entries: [{ path: "README.md", type: "file" }],
truncated: false,
})
expect((yield* service.resolveList()).resource).toBe(".")
}).pipe(provide(directory)),
),
)
it.live("materializes only the selected direct children for a page", () =>
withTmp((directory) => {
const realPaths: string[] = []
const filesystem = Layer.effect(
FSUtil.Service,
Effect.gen(function* () {
const service = yield* FSUtil.Service
return FSUtil.Service.of({
...service,
realPath: (target) =>
Effect.sync(() => realPaths.push(target)).pipe(Effect.andThen(service.realPath(target))),
})
}),
).pipe(Layer.provide(FSUtil.defaultLayer))
return Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(directory, "src"))
await fs.writeFile(path.join(directory, "alpha.txt"), "alpha")
await fs.writeFile(path.join(directory, "beta.txt"), "beta")
})
const service = yield* FileSystem.Service
expect(yield* service.listPage({ offset: 2, limit: 1 })).toMatchObject({
entries: [{ path: "alpha.txt", type: "file" }],
truncated: true,
next: 3,
})
expect(realPaths.filter((target) => target !== directory)).toEqual([path.join(directory, "alpha.txt")])
}).pipe(provide(directory, inertReferences, filesystem))
}),
)
it.live("materializes selected page entries with at most 16 concurrent real path lookups", () =>
withTmp((directory) => {
let active = 0
let maximum = 0
const filesystem = Layer.effect(
FSUtil.Service,
Effect.gen(function* () {
const service = yield* FSUtil.Service
return FSUtil.Service.of({
...service,
realPath: (target) =>
target === directory
? service.realPath(target)
: Effect.acquireUseRelease(
Effect.sync(() => {
active++
maximum = Math.max(maximum, active)
}),
() => Effect.sleep("10 millis").pipe(Effect.andThen(service.realPath(target))),
() => Effect.sync(() => active--),
),
})
}),
).pipe(Layer.provide(FSUtil.defaultLayer))
return Effect.gen(function* () {
yield* Effect.promise(() =>
Promise.all(Array.from({ length: 32 }, (_, index) => fs.writeFile(path.join(directory, `${index}.txt`), ""))),
)
const service = yield* FileSystem.Service
expect((yield* service.listPage({ limit: 32 })).entries).toHaveLength(32)
expect(maximum).toBe(16)
}).pipe(provide(directory, inertReferences, filesystem))
}),
)
it.live("caps direct list page service calls at 2000 entries", () =>
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Promise.all(
Array.from({ length: 2_001 }, (_, index) =>
fs.writeFile(path.join(directory, `${index.toString().padStart(4, "0")}.txt`), ""),
),
),
)
const service = yield* FileSystem.Service
const target = yield* service.resolveList()
expect((yield* service.listPageResolved(target, { limit: 2_001 })).entries).toHaveLength(2_000)
}).pipe(provide(directory)),
),
)
test("rejects empty list aliases and page limits over 2000", () => {
const decode = Schema.decodeUnknownSync(FileSystem.ListPageInput)
expect(() => decode({ reference: "" })).toThrow()
expect(() => decode({ limit: 2_001 })).toThrow()
})
it.live("rejects escaping list paths and omits escaping symlink children", () =>
withTmp((directory) =>
Effect.gen(function* () {
if (process.platform === "win32") return
const outside = `${directory}-outside`
yield* Effect.promise(async () => {
await fs.mkdir(outside)
await fs.writeFile(path.join(outside, "secret.txt"), "secret")
await fs.symlink(outside, path.join(directory, "escape"))
})
const service = yield* FileSystem.Service
expect(
Exit.isFailure(yield* service.listPage({ path: RelativePath.make("../outside") }).pipe(Effect.exit)),
).toBe(true)
expect((yield* service.listPage()).entries).toEqual([])
yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
}).pipe(provide(directory)),
),
)
it.live("paginates visible entries after omitting escaping symlink children", () =>
withTmp((directory) =>
Effect.gen(function* () {
if (process.platform === "win32") return
const outside = `${directory}-outside`
yield* Effect.promise(async () => {
await fs.mkdir(outside)
await fs.symlink(outside, path.join(directory, "a-escape"))
await fs.writeFile(path.join(directory, "b-visible.txt"), "visible")
})
const service = yield* FileSystem.Service
expect(yield* service.listPage({ limit: 1 })).toMatchObject({
entries: [{ path: "b-visible.txt", type: "file" }],
truncated: false,
})
yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
}).pipe(provide(directory)),
),
)
it.live("rejects paths outside the location", () =>
withTmp((directory) =>
Effect.gen(function* () {
@ -109,42 +303,6 @@ describe("FileSystem", () => {
),
)
it.live("finds files and directories", () =>
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
yield* Effect.promise(() => fs.writeFile(path.join(directory, "src", "index.ts"), "const needle = true\n"))
const service = yield* FileSystem.Service
expect((yield* service.find({ query: "index", type: "file" })).map((item) => item.path)).toEqual([
RelativePath.make(path.join("src", "index.ts")),
])
expect((yield* service.find({ query: "src", type: "directory" })).map((item) => item.path)).toEqual([
RelativePath.make("src"),
])
}).pipe(provide(directory)),
),
)
it.live("greps file contents", () =>
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.writeFile(path.join(directory, "index.ts"), "const needle = true\n"))
const service = yield* FileSystem.Service
expect(yield* service.grep({ pattern: "needle" })).toEqual([
{
path: RelativePath.make("index.ts"),
lines: "const needle = true\n",
line: 1,
offset: 0,
submatches: [{ text: "needle", start: 6, end: 12 }],
},
])
}).pipe(provide(directory)),
),
)
it.live("reads and lists paths relative to a local project reference", () =>
withTmp((directory) => {
const docs = path.join(directory, "docs")

View file

@ -17,6 +17,8 @@ import { ModelsDev } from "../src/models-dev"
import { Npm } from "../src/npm"
import { Project } from "../src/project"
import { ProjectReference } from "../src/project-reference"
import { LocationSearch } from "../src/location-search"
import { ToolRegistry } from "../src/tool-registry"
const it = testEffect(
LocationServiceMap.layer.pipe(
@ -55,18 +57,48 @@ describe("LocationServiceMap", () => {
Effect.gen(function* () {
yield* PluginBoot.Service.use((boot) => boot.wait())
yield* ProjectReference.Service
yield* LocationSearch.Service
const catalog = yield* Catalog.Service
const transform = yield* catalog.transform()
yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
return yield* catalog.provider.all()
return {
providers: yield* catalog.provider.all(),
tools: yield* (yield* ToolRegistry.Service).definitions(),
}
}).pipe(Effect.scoped, Effect.provide(LocationServiceMap.get({ directory: AbsolutePath.make(directory) })))
expect((yield* update(blocked.path)).some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(
false,
)
expect((yield* update(allowed.path)).some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(
true,
)
const blockedState = yield* update(blocked.path)
expect(blockedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(false)
expect(blockedState.tools.map((tool) => tool.name).sort()).toEqual([
"apply_patch",
"bash",
"edit",
"glob",
"grep",
"question",
"read",
"skill",
"todowrite",
"webfetch",
"websearch",
"write",
])
const allowedState = yield* update(allowed.path)
expect(allowedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(true)
expect(allowedState.tools.map((tool) => tool.name).sort()).toEqual([
"apply_patch",
"bash",
"edit",
"glob",
"grep",
"question",
"read",
"skill",
"todowrite",
"webfetch",
"websearch",
"write",
])
}),
),
),

View file

@ -0,0 +1,234 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect, test } from "bun:test"
import { Effect, Layer, Schema } from "effect"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { tmpdir } from "./fixture/tmpdir"
import { location } from "./fixture/location"
import { it } from "./lib/effect"
function provide(directory: string) {
return Effect.provide(
LocationMutation.layer.pipe(
Layer.provide(
Layer.mergeAll(
FSUtil.defaultLayer,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
),
),
),
)
}
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
}
describe("LocationMutation", () => {
it.live("resolves an active relative existing file target", () =>
withTmp((directory) =>
Effect.gen(function* () {
const targetPath = path.join(directory, "hello.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "hello"))
const plan = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
expect(plan.target).toMatchObject({
canonical: yield* Effect.promise(() => fs.realpath(targetPath)),
exists: true,
resource: "hello.txt",
})
expect(plan.target.externalDirectory).toBeUndefined()
expect(yield* (yield* LocationMutation.Service).revalidate(plan)).toMatchObject({
canonical: plan.target.canonical,
})
}).pipe(provide(directory)),
),
)
it.live("resolves an active relative prospective file target", () =>
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
const plan = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "new.txt") })
const root = yield* Effect.promise(() => fs.realpath(directory))
expect(plan.target).toMatchObject({
canonical: path.join(root, "src", "new.txt"),
exists: false,
resource: "src/new.txt",
})
expect(plan.authority.canonical).toBe(path.join(root, "src"))
expect(yield* (yield* LocationMutation.Service).revalidate(plan)).toMatchObject({
canonical: plan.target.canonical,
})
}).pipe(provide(directory)),
),
)
it.live("rejects a relative lexical escape instead of promoting it to external authority", () =>
withTmp((directory) =>
Effect.gen(function* () {
const error = yield* Effect.flip((yield* LocationMutation.Service).resolve({ path: "../outside.txt" }))
expect(error).toMatchObject({ _tag: "LocationMutation.PathError", reason: "relative_escape" })
}).pipe(provide(directory)),
),
)
it.live("rejects a prospective target below an escaping symlink ancestor", () =>
withTmp((directory) => {
const outside = `${directory}-outside`
return Effect.gen(function* () {
if (process.platform === "win32") return
yield* Effect.promise(async () => {
await fs.mkdir(outside)
await fs.symlink(outside, path.join(directory, "escape"))
})
const error = yield* Effect.flip(
(yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") }),
)
expect(error).toMatchObject({ _tag: "LocationMutation.PathError", reason: "location_escape" })
yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
}).pipe(provide(directory))
}),
)
it.live("accepts an explicit absolute in-location target without external approval", () =>
withTmp((directory) =>
Effect.gen(function* () {
const targetPath = path.join(directory, "new.txt")
const plan = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
expect(plan.target).toMatchObject({
canonical: path.join(yield* Effect.promise(() => fs.realpath(directory)), "new.txt"),
resource: "new.txt",
})
expect(plan.target.externalDirectory).toBeUndefined()
}).pipe(provide(directory)),
),
)
it.live("requires external-directory authorization for an explicit external absolute target", () =>
withTmp((directory) =>
withTmp((outside) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "new.txt")
const plan = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const root = yield* Effect.promise(() => fs.realpath(outside))
expect(plan.target).toMatchObject({
canonical: path.join(root, "new.txt"),
resource: path.join(root, "new.txt").replaceAll("\\", "/"),
})
expect(plan.target.externalDirectory).toMatchObject({
directory: root,
resource: path.join(root, "*").replaceAll("\\", "/"),
})
}).pipe(provide(directory)),
),
),
)
it.live("resolves an existing external file target", () =>
withTmp((directory) =>
withTmp((outside) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "existing.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
const plan = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const root = yield* Effect.promise(() => fs.realpath(outside))
expect(plan.target).toMatchObject({ canonical: path.join(root, "existing.txt"), exists: true })
expect(plan.authority.canonical).toBe(path.join(root, "existing.txt"))
expect(plan.target.externalDirectory?.directory).toBe(root)
}).pipe(provide(directory)),
),
),
)
it.live("anchors prospective external descendants at their stable existing directory", () =>
withTmp((directory) =>
withTmp((outside) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "new", "nested", "file.txt")
const plan = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const root = yield* Effect.promise(() => fs.realpath(outside))
expect(plan.authority.canonical).toBe(root)
expect(plan.target.externalDirectory).toMatchObject({
directory: root,
resource: path.join(root, "*").replaceAll("\\", "/"),
})
}).pipe(provide(directory)),
),
),
)
it.live("rejects a symlink-ancestor swap during post-approval revalidation", () =>
withTmp((directory) =>
withTmp((outside) =>
Effect.gen(function* () {
if (process.platform === "win32") return
const parent = path.join(directory, "parent")
yield* Effect.promise(() => fs.mkdir(parent))
const service = yield* LocationMutation.Service
const plan = yield* service.resolve({ path: path.join("parent", "new.txt") })
yield* Effect.promise(async () => {
await fs.rmdir(parent)
await fs.symlink(outside, parent)
})
const error = yield* Effect.flip(service.revalidate(plan))
expect(error).toMatchObject({ _tag: "LocationMutation.RevalidationError" })
}).pipe(provide(directory)),
),
),
)
it.live("rejects an existing target identity swap during post-approval revalidation", () =>
withTmp((directory) =>
Effect.gen(function* () {
const targetPath = path.join(directory, "existing.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "first"))
const service = yield* LocationMutation.Service
const plan = yield* service.resolve({ path: "existing.txt" })
yield* Effect.promise(async () => {
const replacementPath = path.join(directory, "replacement.txt")
await fs.writeFile(replacementPath, "second")
await fs.rm(targetPath)
await fs.rename(replacementPath, targetPath)
})
const error = yield* Effect.flip(service.revalidate(plan))
expect(error).toMatchObject({
_tag: "LocationMutation.RevalidationError",
reason: "mutation authority changed",
})
}).pipe(provide(directory)),
),
)
it.live("rejects a nearer prospective ancestor introduced after approval", () =>
withTmp((directory) =>
Effect.gen(function* () {
const service = yield* LocationMutation.Service
const plan = yield* service.resolve({ path: path.join("new", "nested", "file.txt") })
yield* Effect.promise(() => fs.mkdir(path.join(directory, "new")))
const error = yield* Effect.flip(service.revalidate(plan))
expect(error).toMatchObject({
_tag: "LocationMutation.RevalidationError",
reason: "mutation authority changed",
})
}).pipe(provide(directory)),
),
)
test("keeps project references outside the mutation input API", () => {
expect(Object.keys(LocationMutation.ResolveInput.fields)).toEqual(["path", "kind"])
expect(Schema.decodeUnknownSync(LocationMutation.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({
path: "README.md",
})
})
})

View file

@ -0,0 +1,285 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect, test } from "bun:test"
import { Cause, Effect, Exit, Layer, Schema } from "effect"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { LocationSearch } from "@opencode-ai/core/location-search"
import { AppProcess } from "@opencode-ai/core/process"
import { Ripgrep as FileSystemRipgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { ProjectReference } from "@opencode-ai/core/project-reference"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { tmpdir } from "./fixture/tmpdir"
import { location } from "./fixture/location"
import { it } from "./lib/effect"
const inertReferences = references({})
function provide(directory: string, projectReferences = inertReferences) {
const dependencies = Layer.mergeAll(
FSUtil.defaultLayer,
FileSystemRipgrep.defaultLayer,
AppProcess.defaultLayer,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
Layer.succeed(ProjectReference.Service, projectReferences),
)
const filesystem = FileSystem.layer.pipe(Layer.provide(dependencies))
const search = LocationSearch.layer.pipe(
Layer.provide(filesystem),
Layer.provide(Ripgrep.layer.pipe(Layer.provide(dependencies))),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(dependencies),
)
return Effect.provide(Layer.merge(filesystem, search))
}
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
}
describe("LocationSearch", () => {
it.live("searches files in the active Location with structured bounded results", () =>
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(directory, "src"))
await fs.writeFile(path.join(directory, "src", "index.ts"), "export const value = 1\n")
await fs.writeFile(path.join(directory, "notes.txt"), "notes\n")
})
const result = yield* (yield* LocationSearch.Service).files({ pattern: "*.ts" })
const canonical = yield* Effect.promise(() => fs.realpath(path.join(directory, "src", "index.ts")))
expect(result).toMatchObject({ truncated: false, partial: false })
expect(result.items).toHaveLength(1)
expect(result.items[0]).toMatchObject({
path: RelativePath.make("src/index.ts"),
canonical,
resource: "src/index.ts",
})
expect(typeof result.items[0].mtime).toBe("number")
}).pipe(provide(directory)),
),
)
it.live("searches files under a relative subdirectory and named local reference", () =>
withTmp((directory) => {
const docs = path.join(directory, "docs")
return Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(directory, "src"))
await fs.mkdir(docs)
await fs.writeFile(path.join(directory, "src", "active.ts"), "active\n")
await fs.writeFile(path.join(docs, "guide.md"), "guide\n")
})
const search = yield* LocationSearch.Service
expect(
(yield* search.files({ pattern: "*.ts", path: RelativePath.make("src") })).items.map((item) => item.path),
).toEqual([RelativePath.make("src/active.ts")])
const guide = yield* Effect.promise(() => fs.realpath(path.join(docs, "guide.md")))
expect((yield* search.files({ pattern: "*.md", reference: "docs" })).items).toMatchObject([
{ path: RelativePath.make("guide.md"), resource: "docs:guide.md", canonical: guide },
])
}).pipe(provide(directory, references({ docs: { name: "docs", kind: "local", path: docs } })))
}),
)
it.live("greps the Location, exact relative files and directories, and include globs", () =>
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(directory, "src"))
await fs.writeFile(path.join(directory, "src", "one.ts"), "needle ts\n")
await fs.writeFile(path.join(directory, "src", "two.txt"), "needle txt\n")
await fs.writeFile(path.join(directory, "root.md"), "needle root\n")
})
const search = yield* LocationSearch.Service
expect((yield* search.grep({ pattern: "needle" })).items.map((item) => item.path).sort()).toEqual([
RelativePath.make("root.md"),
RelativePath.make("src/one.ts"),
RelativePath.make("src/two.txt"),
])
expect(
(yield* search.grep({ pattern: "needle", path: RelativePath.make("src") })).items
.map((item) => item.path)
.sort(),
).toEqual([RelativePath.make("src/one.ts"), RelativePath.make("src/two.txt")])
expect((yield* search.grep({ pattern: "needle", path: RelativePath.make("src/one.ts") })).items).toMatchObject([
{ path: RelativePath.make("src/one.ts"), resource: "src/one.ts", lines: "needle ts\n", line: 1, offset: 0 },
])
expect((yield* search.grep({ pattern: "needle", include: "*.ts" })).items.map((item) => item.path)).toEqual([
RelativePath.make("src/one.ts"),
])
}).pipe(provide(directory)),
),
)
it.live("does not discover hidden files during broad V2 searches", () =>
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(directory, "nested", ".private"), { recursive: true })
await fs.writeFile(path.join(directory, "visible.txt"), "needle visible\n")
await fs.writeFile(path.join(directory, ".env"), "needle root secret\n")
await fs.writeFile(path.join(directory, "nested", "visible.txt"), "needle nested visible\n")
await fs.writeFile(path.join(directory, "nested", ".env"), "needle nested secret\n")
await fs.writeFile(path.join(directory, "nested", ".private", "secret.txt"), "needle hidden directory\n")
})
const search = yield* LocationSearch.Service
expect((yield* search.files({ pattern: "*" })).items.map((item) => item.path).sort()).toEqual([
RelativePath.make("nested/visible.txt"),
RelativePath.make("visible.txt"),
])
expect((yield* search.files({ pattern: ".env" })).items).toEqual([])
expect((yield* search.grep({ pattern: "needle", include: "*" })).items.map((item) => item.path).sort()).toEqual(
[RelativePath.make("nested/visible.txt"), RelativePath.make("visible.txt")],
)
}).pipe(provide(directory)),
),
)
it.live("caps result counts and line previews", () =>
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await Promise.all(
Array.from({ length: 101 }, (_, index) => fs.writeFile(path.join(directory, `${index}.txt`), "needle\n")),
)
await fs.writeFile(
path.join(directory, "long.txt"),
`needle ${"x".repeat(LocationSearch.MAX_LINE_PREVIEW_LENGTH)}\n`,
)
})
const search = yield* LocationSearch.Service
const files = yield* search.files({ pattern: "*.txt", limit: 2 })
const hardCappedFiles = yield* search.files({ pattern: "*.txt", limit: LocationSearch.MAX_RESULT_LIMIT + 1 })
const hardCappedGrep = yield* search.grep({ pattern: "needle", limit: LocationSearch.MAX_RESULT_LIMIT + 1 })
const grep = yield* search.grep({ pattern: "needle", path: RelativePath.make("long.txt") })
expect(files.items).toHaveLength(2)
expect(files.truncated).toBe(true)
expect(hardCappedFiles.items).toHaveLength(LocationSearch.MAX_RESULT_LIMIT)
expect(hardCappedFiles.truncated).toBe(true)
expect(hardCappedGrep.items).toHaveLength(LocationSearch.MAX_RESULT_LIMIT)
expect(hardCappedGrep.truncated).toBe(true)
expect(grep.items[0].lines).toHaveLength(LocationSearch.MAX_LINE_PREVIEW_LENGTH)
expect(grep.items[0].linePreviewTruncated).toBe(true)
}).pipe(provide(directory)),
),
)
it.live("reports invalid regex as a typed failure", () =>
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.writeFile(path.join(directory, "notes.txt"), "notes\n"))
const exit = yield* (yield* LocationSearch.Service).grep({ pattern: "[" }).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Ripgrep.InvalidPatternError)
}).pipe(provide(directory)),
),
)
it.live("rejects oversized ripgrep JSON records before durable projection", () =>
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
fs.writeFile(path.join(directory, "huge.txt"), `needle ${"x".repeat(Ripgrep.MAX_RECORD_BYTES)}\n`),
)
const exit = yield* (yield* LocationSearch.Service).grep({ pattern: "needle" }).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(String(Cause.squash(exit.cause))).toContain("Ripgrep JSON record exceeded")
}).pipe(provide(directory)),
),
)
it.live("rejects lexical and symlink escapes through root resolution", () =>
withTmp((directory) =>
Effect.gen(function* () {
if (process.platform === "win32") return
const outside = `${directory}-outside`
yield* Effect.promise(async () => {
await fs.mkdir(outside)
await fs.writeFile(path.join(outside, "secret.txt"), "secret\n")
await fs.symlink(outside, path.join(directory, "escape"))
})
const search = yield* LocationSearch.Service
expect(
Exit.isFailure(
yield* search.files({ pattern: "*", path: RelativePath.make("../outside") }).pipe(Effect.exit),
),
).toBe(true)
expect(
Exit.isFailure(yield* search.files({ pattern: "*", path: RelativePath.make("escape") }).pipe(Effect.exit)),
).toBe(true)
yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
}).pipe(provide(directory)),
),
)
it.live("rejects an approved root swapped to a symlink before ripgrep traversal", () =>
withTmp((directory) =>
Effect.gen(function* () {
if (process.platform === "win32") return
const source = path.join(directory, "src")
const outside = `${directory}-outside`
yield* Effect.promise(async () => {
await fs.mkdir(source)
await fs.mkdir(outside)
await fs.writeFile(path.join(outside, "secret.txt"), "secret\n")
})
const filesystem = yield* FileSystem.Service
const approved = yield* filesystem.resolveRoot({ path: RelativePath.make("src") })
yield* Effect.promise(async () => {
await fs.rmdir(source)
await fs.symlink(outside, source)
})
expect(
Exit.isFailure(yield* (yield* LocationSearch.Service).files({ pattern: "*" }, approved).pipe(Effect.exit)),
).toBe(true)
yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
}).pipe(provide(directory)),
),
)
it.live("honors a pre-aborted cancellation signal", () =>
withTmp((directory) =>
Effect.gen(function* () {
const controller = new AbortController()
controller.abort()
const exit = yield* (yield* LocationSearch.Service)
.files({ pattern: "*", signal: controller.signal })
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
}).pipe(provide(directory)),
),
)
test("exposes schema-testable search bounds", () => {
const decode = Schema.decodeUnknownSync(LocationSearch.FilesInput)
expect(LocationSearch.DEFAULT_RESULT_LIMIT).toBe(100)
expect(LocationSearch.MAX_RESULT_LIMIT).toBe(100)
expect(LocationSearch.MAX_LINE_PREVIEW_LENGTH).toBe(2_000)
expect(() => decode({ pattern: "*", limit: LocationSearch.MAX_RESULT_LIMIT + 1 })).toThrow()
})
})
function references(entries: Record<string, ProjectReference.Resolved>) {
return ProjectReference.Service.of({
list: () => Effect.succeed(Object.values(entries)),
get: (name) => Effect.succeed(entries[name]),
resolveMention: () => Effect.succeed(undefined),
ensurePath: () => Effect.void,
containsManagedPath: () => Effect.succeed(false),
})
}

View file

@ -3,9 +3,11 @@ import { Effect, Layer } from "effect"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { testEffect } from "./lib/effect"
const ref = { directory: AbsolutePath.make("/repo/packages/app"), workspaceID: "workspace" }
const workspaceID = WorkspaceV2.ID.make("wrk_test")
const ref = { directory: AbsolutePath.make("/repo/packages/app"), workspaceID }
const projectLayer = Layer.succeed(
Project.Service,
Project.Service.of({
@ -27,7 +29,7 @@ describe("Location", () => {
const location = yield* Location.Service
expect(location.directory).toBe(AbsolutePath.make("/repo/packages/app"))
expect(location.workspaceID).toBe("workspace")
expect(location.workspaceID).toBe(workspaceID)
expect(location.project.id).toBe(Project.ID.make("project"))
expect(location.project.directory).toBe(AbsolutePath.make("/repo"))
expect(location.vcs).toEqual({

View file

@ -0,0 +1,16 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { OpenCode } from "@opencode-ai/core/opencode"
import { testEffect } from "./lib/effect"
const it = testEffect(OpenCode.layer)
describe("OpenCode.layer", () => {
it.effect("exposes Sessions through the public embedded API", () =>
Effect.gen(function* () {
const opencode = yield* OpenCode.Service
expect(yield* opencode.sessions.list()).toBeArray()
}),
)
})

View file

@ -0,0 +1,68 @@
import { describe, expect, test } from "bun:test"
import { Patch } from "@opencode-ai/core/patch"
describe("Patch", () => {
test("parses add, update, and delete hunks", () => {
expect(
Patch.parse(
"*** Begin Patch\n*** Add File: add.txt\n+added\n*** Update File: update.txt\n@@ section\n-old\n+new\n*** Delete File: delete.txt\n*** End Patch",
),
).toEqual([
{ type: "add", path: "add.txt", contents: "added" },
{
type: "update",
path: "update.txt",
chunks: [{ oldLines: ["old"], newLines: ["new"], changeContext: "section", endOfFile: undefined }],
movePath: undefined,
},
{ type: "delete", path: "delete.txt" },
])
})
test("strips a heredoc wrapper", () => {
expect(Patch.parse("cat <<'EOF'\n*** Begin Patch\n*** Add File: add.txt\n+added\n*** End Patch\nEOF")).toEqual([
{ type: "add", path: "add.txt", contents: "added" },
])
})
test("derives fuzzy line updates while preserving BOM", () => {
const update = Patch.derive("update.txt", [{ oldLines: [" old "], newLines: ["new"] }], "\uFEFFold\n")
expect(update).toEqual({ content: "new\n", bom: true })
expect(Patch.joinBom(update.content, update.bom)).toBe("\uFEFFnew\n")
})
test("matches EOF-anchored chunks from the end", () => {
expect(
Patch.derive(
"update.txt",
[{ oldLines: ["marker", "end"], newLines: ["marker changed", "end"], endOfFile: true }],
"marker\nmiddle\nmarker\nend\n",
).content,
).toBe("marker\nmiddle\nmarker changed\nend\n")
})
test("parses the EOF marker inside update chunks", () => {
expect(
Patch.parse("*** Begin Patch\n*** Update File: update.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch"),
).toEqual([
{
type: "update",
path: "update.txt",
movePath: undefined,
chunks: [{ oldLines: ["last"], newLines: ["end"], changeContext: undefined, endOfFile: true }],
},
])
})
test("rejects malformed hunk bodies", () => {
expect(() => Patch.parse("*** Begin Patch\n*** Add File: add.txt\nmissing plus\n*** End Patch")).toThrow(
"Invalid add file line",
)
expect(() => Patch.parse("*** Begin Patch\n*** Update File: update.txt\n*** End Patch")).toThrow(
"expected at least one @@ chunk",
)
expect(() => Patch.parse("*** Begin Patch\n*** Delete File: delete.txt\nunexpected body\n*** End Patch")).toThrow(
"Invalid patch line",
)
})
})

View file

@ -12,6 +12,8 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionStore } from "@opencode-ai/core/session/store"
import { eq } from "drizzle-orm"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
@ -22,13 +24,22 @@ const current = Layer.succeed(
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
)
const events = EventV2.layer.pipe(Layer.provide(database))
const sessions = SessionV2.layer.pipe(Layer.provide(database))
const store = SessionStore.layer.pipe(Layer.provide(database))
const sessions = SessionV2.layer.pipe(
Layer.provide(events),
Layer.provide(database),
Layer.provide(store),
Layer.provide(Project.defaultLayer),
Layer.provide(SessionExecution.noopLayer),
)
const saved = PermissionSaved.layer.pipe(Layer.provide(database))
const layer = PermissionV2.locationLayer.pipe(
Layer.provideMerge(database),
Layer.provideMerge(store),
Layer.provideMerge(events),
Layer.provideMerge(current),
Layer.provideMerge(sessions),
Layer.provideMerge(SessionExecution.noopLayer),
Layer.provideMerge(saved),
)
const it = testEffect(layer)
@ -127,6 +138,67 @@ describe("PermissionV2", () => {
}),
)
it.effect("uses build permissions when the Session agent is omitted", () =>
Effect.gen(function* () {
yield* setup()
const { db } = yield* Database.Service
yield* db
.update(SessionTable)
.set({ agent: null })
.where(eq(SessionTable.id, SessionV2.ID.make("ses_test")))
.run()
.pipe(Effect.orDie)
const agents = yield* AgentV2.Service
const update = yield* agents.transform()
yield* update((editor) =>
editor.update(AgentV2.ID.make("build"), (agent) => {
agent.permissions = [{ action: "todowrite", resource: "*", effect: "allow" }]
}),
)
const service = yield* PermissionV2.Service
expect(yield* service.ask(assertion({ action: "todowrite", resources: ["*"] }))).toEqual({
id: PermissionV2.ID.create("per_test"),
effect: "allow",
})
expect(yield* service.list()).toEqual([])
}),
)
it.effect("evaluates bash with the normal configured-rule semantics", () =>
Effect.gen(function* () {
yield* setup([{ action: "*", resource: "*", effect: "allow" }])
const service = yield* PermissionV2.Service
const bash = assertion({ action: "bash", resources: ["pwd"] })
expect(yield* service.ask(bash)).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "allow" })
yield* setRules([])
expect(yield* service.ask(bash)).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "ask" })
expect(yield* service.get(PermissionV2.ID.create("per_test"))).toBeDefined()
}),
)
it.effect("uses saved bash approvals while preserving configured deny precedence", () =>
Effect.gen(function* () {
yield* setup()
const saved = yield* PermissionSaved.Service
yield* saved.add({ projectID: Project.ID.global, action: "bash", resources: ["pwd"] })
const service = yield* PermissionV2.Service
expect(yield* service.ask(assertion({ action: "bash", resources: ["pwd"] }))).toEqual({
id: PermissionV2.ID.create("per_test"),
effect: "allow",
})
expect(yield* service.list()).toEqual([])
yield* setRules([{ action: "bash", resource: "*", effect: "deny" }])
expect(yield* service.ask(assertion({ action: "bash", resources: ["pwd"] }))).toEqual({
id: PermissionV2.ID.create("per_test"),
effect: "deny",
})
}),
)
it.effect("resolves an asked permission once", () =>
Effect.gen(function* () {
yield* setup()

View file

@ -0,0 +1,90 @@
import { describe, expect } from "bun:test"
import { Context, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { EventV2 } from "@opencode-ai/core/event"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { State } from "@opencode-ai/core/state"
import { it } from "./lib/effect"
const events = Layer.mock(EventV2.Service)({
publish: (definition, data) =>
Effect.succeed({
id: EventV2.ID.make("evt_plugin_test"),
type: definition.type,
data,
}),
})
const plugins = PluginV2.layer.pipe(Layer.provide(events))
function state() {
return State.create({
initial: () => ({ values: [] as string[] }),
editor: (draft) => ({
add: (value: string) => draft.values.push(value),
}),
})
}
describe("PluginV2", () => {
it.effect("closes plugin-owned scopes when the registry layer finalizes", () =>
Effect.gen(function* () {
const values = state()
const layerScope = yield* Scope.fork(yield* Scope.Scope)
const plugin = Context.get(yield* Layer.buildWithScope(Layer.fresh(plugins), layerScope), PluginV2.Service)
yield* plugin.add({
id: PluginV2.ID.make("scoped"),
effect: Effect.gen(function* () {
const transform = yield* values.transform()
yield* transform((editor) => editor.add("scoped"))
}),
})
expect(values.get().values).toEqual(["scoped"])
yield* Scope.close(layerScope, Exit.void)
expect(values.get().values).toEqual([])
}),
)
it.effect("serializes same-ID additions and leaves one removable contribution", () =>
Effect.gen(function* () {
const values = state()
const layerScope = yield* Scope.fork(yield* Scope.Scope)
const plugin = Context.get(yield* Layer.buildWithScope(Layer.fresh(plugins), layerScope), PluginV2.Service)
const id = PluginV2.ID.make("shared")
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
const first = yield* plugin
.add({
id,
effect: Effect.gen(function* () {
const transform = yield* values.transform()
yield* transform((editor) => editor.add("first"))
yield* Deferred.succeed(firstStarted, undefined)
yield* Deferred.await(releaseFirst)
}),
})
.pipe(Effect.forkChild)
yield* Deferred.await(firstStarted)
const second = yield* plugin
.add({
id,
effect: Effect.gen(function* () {
const transform = yield* values.transform()
yield* transform((editor) => editor.add("second"))
}),
})
.pipe(Effect.forkChild({ startImmediately: true }))
expect(values.get().values).toEqual(["first"])
yield* Deferred.succeed(releaseFirst, undefined)
yield* Fiber.join(first)
yield* Fiber.join(second)
expect(values.get().values).toEqual(["second"])
yield* plugin.remove(id)
expect(values.get().values).toEqual([])
}),
)
})

View file

@ -1,7 +1,9 @@
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import { realpathSync } from "node:fs"
import { tmpdir } from "node:os"
import { Effect, Exit, Stream } from "effect"
import path from "node:path"
import { Effect, Exit, Fiber, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { AppProcess } from "@opencode-ai/core/process"
import { testEffect } from "../lib/effect"
@ -11,6 +13,18 @@ const it = testEffect(AppProcess.defaultLayer)
const NODE = process.execPath
const cmd = (...args: string[]) => ChildProcess.make(NODE, args)
const waitForFile = (file: string) =>
Effect.promise(async () => {
while (true) {
try {
return await fs.readFile(file, "utf8")
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error
await new Promise<void>((resolve) => setTimeout(resolve, 10))
}
}
})
describe("AppProcess", () => {
describe("run", () => {
it.effect(
@ -118,6 +132,50 @@ describe("AppProcess", () => {
expect(result.command).toBe(`${NODE} -e process.stdout.write('hi')`)
}),
)
if (process.platform !== "win32") {
it.live(
"timeout cleans up the scoped child process",
Effect.acquireUseRelease(
Effect.promise(() => fs.mkdtemp(path.join(tmpdir(), "opencode-process-timeout-"))),
(directory) => {
const ready = path.join(directory, "ready")
const settled = path.join(directory, "settled")
const script = `const fs=require('fs');fs.writeFileSync(${JSON.stringify(ready)},String(process.pid));process.on('SIGTERM',()=>{fs.writeFileSync(${JSON.stringify(settled)},'settled');process.exit(0)});setInterval(()=>{},60000)`
return Effect.gen(function* () {
const svc = yield* AppProcess.Service
const exit = yield* Effect.exit(svc.run(cmd("-e", script), { timeout: "1 second" }))
expect(Exit.isFailure(exit)).toBe(true)
expect(yield* waitForFile(ready)).toMatch(/^\d+$/)
expect(yield* waitForFile(settled)).toBe("settled")
})
},
(directory) => Effect.promise(() => fs.rm(directory, { recursive: true, force: true })),
),
5_000,
)
it.live(
"fiber interruption cleans up the scoped child process after readiness",
Effect.acquireUseRelease(
Effect.promise(() => fs.mkdtemp(path.join(tmpdir(), "opencode-process-interrupt-"))),
(directory) => {
const ready = path.join(directory, "ready")
const settled = path.join(directory, "settled")
const script = `const fs=require('fs');fs.writeFileSync(${JSON.stringify(ready)},String(process.pid));process.on('SIGTERM',()=>{fs.writeFileSync(${JSON.stringify(settled)},'settled');process.exit(0)});setInterval(()=>{},60000)`
return Effect.gen(function* () {
const svc = yield* AppProcess.Service
const fiber = yield* svc.run(cmd("-e", script)).pipe(Effect.forkChild)
expect(yield* waitForFile(ready)).toMatch(/^\d+$/)
yield* Fiber.interrupt(fiber)
expect(yield* waitForFile(settled)).toBe("settled")
})
},
(directory) => Effect.promise(() => fs.rm(directory, { recursive: true, force: true })),
),
5_000,
)
}
})
describe("inherited platform methods", () => {

View file

@ -0,0 +1,115 @@
import { describe, expect } from "bun:test"
import { Context, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { QuestionV2 } from "@opencode-ai/core/question"
import { SessionV2 } from "@opencode-ai/core/session"
import { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:")
const events = EventV2.layer.pipe(Layer.provide(database))
const questions = QuestionV2.layer.pipe(Layer.provide(events))
const it = testEffect(Layer.mergeAll(database, events, questions))
const sessionID = SessionV2.ID.make("ses_question_test")
const question: QuestionV2.Info = {
question: "Which option?",
header: "Option",
options: [{ label: "One", description: "First option" }],
}
const waitForAsk = Effect.fn("QuestionV2Test.waitForAsk")(function* (
service: QuestionV2.Interface,
input: QuestionV2.AskInput,
) {
const events = yield* EventV2.Service
const asked = yield* Deferred.make<QuestionV2.Request>()
const unsubscribe = yield* events.listen((event) =>
event.type === QuestionV2.Event.Asked.type
? Deferred.succeed(asked, event.data as QuestionV2.Request).pipe(Effect.asVoid)
: Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
const fiber = yield* service.ask(input).pipe(Effect.forkScoped)
return { fiber, request: yield* Deferred.await(asked) }
})
describe("QuestionV2", () => {
it.effect("publishes lifecycle events and settles a pending reply", () =>
Effect.gen(function* () {
const service = yield* QuestionV2.Service
const events = yield* EventV2.Service
const published: EventV2.Payload[] = []
const unsubscribe = yield* events.listen((event) =>
Effect.sync(() => {
if (event.type.startsWith("question.v2.")) published.push(event)
}),
)
yield* Effect.addFinalizer(() => unsubscribe)
const { fiber, request } = yield* waitForAsk(service, { sessionID, questions: [question] })
expect(request.id).toMatch(/^que_/)
expect(yield* service.list()).toEqual([request])
yield* service.reply({ requestID: request.id, answers: [["One"]] })
expect(yield* Fiber.join(fiber)).toEqual([["One"]])
expect(yield* service.list()).toEqual([])
expect(published.map((event) => [event.type, event.data])).toEqual([
[QuestionV2.Event.Asked.type, request],
[QuestionV2.Event.Replied.type, { sessionID, requestID: request.id, answers: [["One"]] }],
])
}),
)
it.effect("publishes rejection, fails the ask, and rejects unknown IDs", () =>
Effect.gen(function* () {
const service = yield* QuestionV2.Service
const events = yield* EventV2.Service
const published: EventV2.Payload[] = []
const unsubscribe = yield* events.listen((event) =>
Effect.sync(() => {
if (event.type === QuestionV2.Event.Rejected.type) published.push(event)
}),
)
yield* Effect.addFinalizer(() => unsubscribe)
const { fiber, request } = yield* waitForAsk(service, { sessionID, questions: [question] })
yield* service.reject(request.id)
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(exit.cause.toString()).toContain("QuestionV2.RejectedError")
expect(published.map((event) => event.data)).toEqual([{ sessionID, requestID: request.id }])
const unknown = QuestionV2.ID.ascending("que_unknown")
expect(yield* service.reply({ requestID: unknown, answers: [] }).pipe(Effect.flip)).toEqual(
new QuestionV2.NotFoundError({ requestID: unknown }),
)
expect(yield* service.reject(unknown).pipe(Effect.flip)).toEqual(
new QuestionV2.NotFoundError({ requestID: unknown }),
)
}),
)
it.effect("isolates pending requests by location-layer instance and rejects them on finalization", () =>
Effect.gen(function* () {
const firstScope = yield* Scope.make()
const secondScope = yield* Scope.make()
const first = Context.get(yield* Layer.buildWithScope(Layer.fresh(questions), firstScope), QuestionV2.Service)
const second = Context.get(yield* Layer.buildWithScope(Layer.fresh(questions), secondScope), QuestionV2.Service)
const fiber = yield* first.ask({ sessionID, questions: [question] }).pipe(Effect.forkScoped)
yield* Effect.yieldNow
const request = (yield* first.list())[0]!
expect(yield* second.list()).toEqual([])
expect(yield* second.reply({ requestID: request.id, answers: [["One"]] }).pipe(Effect.flip)).toEqual(
new QuestionV2.NotFoundError({ requestID: request.id }),
)
yield* Scope.close(firstScope, Exit.void)
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(exit.cause.toString()).toContain("QuestionV2.RejectedError")
yield* Scope.close(secondScope, Exit.void)
}),
)
})

View file

@ -0,0 +1,259 @@
import { describe, expect } from "bun:test"
import { Effect, Layer, Stream } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { eq } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Prompt } from "@opencode-ai/core/session/prompt"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionInput } from "@opencode-ai/core/session/input"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:")
const events = EventV2.layer.pipe(Layer.provide(database))
const projects = Layer.succeed(
ProjectV2.Service,
ProjectV2.Service.of({
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
)
const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))
const store = SessionStore.layer.pipe(Layer.provide(database))
const sessions = SessionV2.layer.pipe(
Layer.provide(events),
Layer.provide(database),
Layer.provide(store),
Layer.provide(projects),
Layer.provide(SessionExecution.noopLayer),
)
const it = testEffect(
Layer.mergeAll(database, events, projects, projector, store, SessionExecution.noopLayer, sessions),
)
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const id = SessionV2.ID.create()
describe("SessionV2.create", () => {
it.effect("derives stable namespaced external IDs", () =>
Effect.sync(() => {
const input = { namespace: "opencord.agent-thread", key: "thread-1" }
expect(SessionV2.ID.fromExternal(input)).toBe(SessionV2.ID.fromExternal(input))
expect(SessionV2.ID.fromExternal(input)).toMatch(/^ses_[a-f0-9]{64}$/)
expect(SessionV2.ID.fromExternal({ ...input, namespace: "another-app" })).not.toBe(
SessionV2.ID.fromExternal(input),
)
expect(SessionV2.ID.fromExternal({ namespace: "a:b", key: "c" })).not.toBe(
SessionV2.ID.fromExternal({ namespace: "a", key: "b:c" }),
)
}),
)
it.effect("creates a fresh projected session when the ID is omitted", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const first = yield* session.create({ location })
const second = yield* session.create({ location })
expect(second.id).not.toBe(first.id)
expect(yield* session.list()).toHaveLength(2)
}),
)
it.effect("returns the original session when the ID is retried", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const input = { id, location }
const first = yield* session.create(input)
const retried = yield* session.create(input)
expect(retried).toEqual(first)
expect(yield* session.list()).toEqual([first])
}),
)
it.effect("stores supplied immutable create attributes", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const workspaceID = WorkspaceV2.ID.make("wrk_test")
const model = ModelV2.Ref.make({
id: ModelV2.ID.make("sonnet"),
providerID: ProviderV2.ID.anthropic,
variant: ModelV2.VariantID.make("fast"),
})
expect(
yield* session.create({
location: Location.Ref.make({ directory: location.directory, workspaceID }),
agent: AgentV2.ID.make("build"),
model,
}),
).toMatchObject({ location: { directory: location.directory, workspaceID }, agent: "build", model })
}),
)
it.effect("returns the existing Session when one ID is reused with different create arguments", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const created = yield* session.create({ id, location })
const changed = [
{ id, location: Location.Ref.make({ directory: AbsolutePath.make("/other") }) },
{ id, location, agent: AgentV2.ID.make("build") },
{
id,
location,
model: ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.anthropic }),
},
]
for (const input of changed) {
expect(yield* session.create(input)).toEqual(created)
}
expect(yield* session.list()).toHaveLength(1)
}),
)
it.effect("returns one recorded session to concurrent exact retries", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const input = { id, location }
const created = yield* Effect.all([session.create(input), session.create(input)], { concurrency: "unbounded" })
expect(created[1]).toEqual(created[0])
expect(yield* session.list()).toEqual([created[0]])
}),
)
it.effect("returns the current Session projection after updates", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const { db } = yield* Database.Service
const input = { id, location }
const created = yield* session.create(input)
yield* db.update(SessionTable).set({ agent: "build" }).where(eq(SessionTable.id, id)).run().pipe(Effect.orDie)
expect(yield* session.create(input)).toMatchObject({ id: created.id, agent: "build" })
}),
)
it.effect("returns the current Session projection after projected updates", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const input = { id, location }
const created = yield* session.create(input)
yield* events.publish(SessionV1.Event.Updated, {
sessionID: id,
info: SessionV1.SessionInfo.make({
id,
slug: "updated",
version: "test",
projectID: created.projectID,
directory: created.location.directory,
title: "updated",
agent: "build",
time: { created: 0, updated: 1 },
}),
})
expect(yield* session.create(input)).toMatchObject({ id, agent: "build" })
}),
)
it.effect("persists creation through the existing legacy created event", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const { db } = yield* Database.Service
const created = yield* session.create({ location })
expect(
yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie),
).toMatchObject([{ type: EventV2.versionedType(SessionV1.Event.Created.type, 1) }])
}),
)
it.effect("persists caller-ID creation through the existing created event", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const { db } = yield* Database.Service
const created = yield* session.create({ id, location })
expect(
yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).get().pipe(Effect.orDie),
).toMatchObject({
data: { sessionID: id },
})
}),
)
it.effect("omits legacy creation rows from the V2 Session event stream", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const { db } = yield* Database.Service
const created = yield* session.create({ location })
yield* session.prompt({ sessionID: created.id, prompt: new Prompt({ text: "Hello" }), resume: false })
yield* SessionInput.promoteSteers(db, events, created.id)
expect(
Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(1), Stream.runCollect)),
).toMatchObject([{ cursor: 1, event: { type: "session.next.prompted", data: { prompt: { text: "Hello" } } } }])
}),
)
it.effect("does not mask unrelated created projector defects", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const event = yield* EventV2.Service
const defect = new Error("unrelated projector defect")
yield* event.project(SessionV1.Event.Created, () => Effect.die(defect))
expect(yield* session.create({ id, location }).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
}),
)
it.effect("reports unfinished Session operations as unavailable", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const created = yield* session.create({ location })
const unavailable = (
effect: Effect.Effect<void, SessionV2.NotFoundError | SessionV2.OperationUnavailableError>,
) =>
effect.pipe(
Effect.flip,
Effect.map((error) => (error instanceof SessionV2.OperationUnavailableError ? error.operation : "not-found")),
)
expect(yield* unavailable(session.move({ sessionID: created.id, location }))).toBe("move")
expect(yield* unavailable(session.shell({ sessionID: created.id, command: "pwd" }))).toBe("shell")
expect(yield* unavailable(session.skill({ sessionID: created.id, skill: "review" }))).toBe("skill")
expect(yield* unavailable(session.switchAgent({ sessionID: created.id, agent: "build" }))).toBe("switchAgent")
expect(
yield* unavailable(
session.switchModel({
sessionID: created.id,
model: ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.anthropic }),
}),
),
).toBe("switchModel")
}),
)
})

View file

@ -0,0 +1,458 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Layer, Schema } from "effect"
import { asc, eq } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { ModelV2 } from "@opencode-ai/core/model"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Prompt } from "@opencode-ai/core/session/prompt"
import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionInput } from "@opencode-ai/core/session/input"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
import { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:")
const events = EventV2.layer.pipe(Layer.provide(database))
const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))
const it = testEffect(Layer.mergeAll(database, events, projector))
const sessionID = SessionV2.ID.make("ses_projector_test")
const created = DateTime.makeUnsafe(0)
const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }
const encodeMessage = Schema.encodeSync(SessionMessage.Message)
const assistantRow = (
id: SessionMessage.ID,
seq: number,
time: { created: DateTime.Utc; completed?: DateTime.Utc } = { created },
) => {
const {
id: _,
type,
...data
} = encodeMessage(new SessionMessage.Assistant({ id, type: "assistant", agent: "build", model, content: [], time }))
return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(time.created), data }
}
describe("SessionProjector", () => {
it.effect("orders projected messages and context by durable aggregate sequence", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const events = yield* EventV2.Service
yield* events.publish(
SessionEvent.Prompted,
{ sessionID, timestamp: created, prompt: new Prompt({ text: "first" }), delivery: "steer" },
{ id: SessionMessage.ID.make("evt_z") },
)
yield* events.publish(
SessionEvent.Prompted,
{ sessionID, timestamp: created, prompt: new Prompt({ text: "second" }), delivery: "steer" },
{ id: SessionMessage.ID.make("evt_a") },
)
const sessions = yield* SessionV2.Service
const firstPage = yield* sessions.messages({ sessionID, limit: 1, order: "asc" })
expect(firstPage.map((message) => (message.type === "user" ? message.text : message.type))).toEqual(["first"])
const secondPage = yield* sessions.messages({
sessionID,
limit: 1,
order: "asc",
cursor: { id: firstPage[0]!.id, direction: "next" },
})
expect(secondPage.map((message) => (message.type === "user" ? message.text : message.type))).toEqual(["second"])
expect(
(yield* sessions.messages({
sessionID,
limit: 1,
order: "asc",
cursor: { id: secondPage[0]!.id, direction: "previous" },
})).map((message) => (message.type === "user" ? message.text : message.type)),
).toEqual(["first"])
expect(
(yield* sessions.context(sessionID)).map((message) => (message.type === "user" ? message.text : message.type)),
).toEqual(["first", "second"])
}).pipe(
Effect.provide(
SessionV2.layer.pipe(
Layer.provide(events),
Layer.provide(database),
Layer.provide(Project.defaultLayer),
Layer.provide(SessionStore.layer.pipe(Layer.provide(database))),
Layer.provide(SessionExecution.noopLayer),
),
),
),
)
it.effect("marks an admitted inbox row promoted with the Prompted event sequence", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const events = yield* EventV2.Service
const id = SessionMessage.ID.make("evt_admitted")
yield* SessionInput.admit(db, { id, sessionID, prompt: new Prompt({ text: "promote me" }), delivery: "steer" })
const event = yield* events.publish(
SessionEvent.Prompted,
{ sessionID, timestamp: created, prompt: new Prompt({ text: "promote me" }), delivery: "steer" },
{ id },
)
expect(
yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie),
).toMatchObject({ promoted_seq: event.seq })
}),
)
it.effect("projects durable context messages supported by the updater", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const events = yield* EventV2.Service
yield* events.publish(SessionEvent.AgentSwitched, { sessionID, timestamp: created, agent: "build" })
yield* events.publish(SessionEvent.ModelSwitched, { sessionID, timestamp: created, model })
yield* events.publish(SessionEvent.Synthetic, { sessionID, timestamp: created, text: "synthetic context" })
yield* events.publish(SessionEvent.Shell.Started, {
sessionID,
timestamp: created,
callID: "shell-1",
command: "pwd",
})
yield* events.publish(SessionEvent.Shell.Ended, {
sessionID,
timestamp: DateTime.makeUnsafe(1),
callID: "shell-1",
output: "/project",
})
yield* events.publish(SessionEvent.Compaction.Started, { sessionID, timestamp: created, reason: "manual" })
yield* events.publish(SessionEvent.Compaction.Delta, { sessionID, timestamp: created, text: "partial" })
yield* events.publish(SessionEvent.Compaction.Ended, {
sessionID,
timestamp: DateTime.makeUnsafe(1),
text: "summary",
include: "msg-1",
})
const rows = yield* db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.session_id, sessionID))
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const messages = rows.map((row) =>
Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }),
)
expect(messages.map((message) => message.type)).toEqual([
"agent-switched",
"model-switched",
"synthetic",
"shell",
"compaction",
])
expect(messages.find((message) => message.type === "shell")).toMatchObject({
output: "/project",
time: { completed: DateTime.makeUnsafe(1) },
})
expect(messages.find((message) => message.type === "compaction")).toMatchObject({
summary: "summary",
include: "msg-1",
})
expect(
yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie),
).toMatchObject({
agent: "build",
model,
time_updated: DateTime.toEpochMillis(created),
})
}),
)
it.effect("rejects a Prompted event that conflicts with an admitted inbox row", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const events = yield* EventV2.Service
const id = SessionMessage.ID.make("evt_conflict")
yield* SessionInput.admit(db, { id, sessionID, prompt: new Prompt({ text: "admitted" }), delivery: "steer" })
const exit = yield* events
.publish(
SessionEvent.Prompted,
{ sessionID, timestamp: created, prompt: new Prompt({ text: "different" }), delivery: "steer" },
{ id },
)
.pipe(Effect.exit)
expect(String(exit)).toContain("Prompt projection conflicts with admitted input")
expect(
yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie),
).toMatchObject({ promoted_seq: null })
}),
)
it.effect("rejects a Prompted delivery mode that conflicts with an admitted inbox row", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const events = yield* EventV2.Service
const id = SessionMessage.ID.make("evt_delivery_conflict")
const prompt = new Prompt({ text: "admitted" })
yield* SessionInput.admit(db, { id, sessionID, prompt, delivery: "queue" })
const exit = yield* events
.publish(SessionEvent.Prompted, { sessionID, timestamp: created, prompt, delivery: "steer" }, { id })
.pipe(Effect.exit)
expect(String(exit)).toContain("Prompt projection conflicts with admitted input")
expect(
yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie),
).toMatchObject({ delivery: "queue", promoted_seq: null })
}),
)
it.effect("does not revive a stale incomplete in-memory assistant projection", () =>
Effect.gen(function* () {
const stale = new SessionMessage.Assistant({
id: SessionMessage.ID.make("evt_assistant_stale"),
type: "assistant",
agent: "build",
model,
content: [],
time: { created },
})
const completed = new SessionMessage.Assistant({
id: SessionMessage.ID.make("evt_assistant_completed"),
type: "assistant",
agent: "build",
model,
content: [],
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
})
expect(
yield* SessionMessageUpdater.memory({ messages: [stale, completed] }).getCurrentAssistant(),
).toBeUndefined()
}),
)
it.effect("updates only the newest incomplete assistant projection", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionMessageTable)
.values([
assistantRow(SessionMessage.ID.make("evt_assistant_1"), 0),
assistantRow(SessionMessage.ID.make("evt_assistant_2"), 1),
])
.run()
.pipe(Effect.orDie)
const service = yield* EventV2.Service
yield* service.publish(SessionEvent.Step.Ended, {
sessionID,
timestamp: DateTime.makeUnsafe(1),
assistantMessageID: SessionMessage.ID.make("evt_assistant_2"),
finish: "stop",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
const rows = yield* db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.session_id, sessionID))
.orderBy(asc(SessionMessageTable.id))
.all()
.pipe(Effect.orDie)
const messages = rows.map((row) =>
Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }),
)
expect(messages[0]).not.toHaveProperty("time.completed")
expect(messages[1]).toMatchObject({
type: "assistant",
finish: "stop",
time: { completed: DateTime.makeUnsafe(1) },
})
}),
)
it.effect("does not revive a stale incomplete assistant projection", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionMessageTable)
.values([
assistantRow(SessionMessage.ID.make("evt_assistant_stale"), 0),
assistantRow(SessionMessage.ID.make("evt_assistant_completed"), 1, {
created: DateTime.makeUnsafe(1),
completed: DateTime.makeUnsafe(2),
}),
])
.run()
.pipe(Effect.orDie)
const service = yield* EventV2.Service
yield* service.publish(SessionEvent.Text.Started, {
sessionID,
timestamp: DateTime.makeUnsafe(3),
textID: "text-stale",
})
const rows = yield* db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.session_id, sessionID))
.orderBy(asc(SessionMessageTable.id))
.all()
.pipe(Effect.orDie)
const messages = rows.map((row) =>
Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }),
)
expect(messages).toEqual([
new SessionMessage.Assistant({
id: SessionMessage.ID.make("evt_assistant_completed"),
type: "assistant",
agent: "build",
model,
content: [],
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
}),
new SessionMessage.Assistant({
id: SessionMessage.ID.make("evt_assistant_stale"),
type: "assistant",
agent: "build",
model,
content: [],
time: { created },
}),
])
}),
)
})

View file

@ -0,0 +1,463 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { Prompt } from "@opencode-ai/core/session/prompt"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionInput } from "@opencode-ai/core/session/input"
import { SessionInputTable, SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:")
const events = EventV2.layer.pipe(Layer.provide(database))
const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))
const store = SessionStore.layer.pipe(Layer.provide(database))
const executionCalls: SessionV2.ID[] = []
const wakeCalls: SessionV2.ID[] = []
const execution = Layer.succeed(
SessionExecution.Service,
SessionExecution.Service.of({
resume: (sessionID) =>
Effect.sync(() => {
executionCalls.push(sessionID)
}),
wake: (sessionID) =>
Effect.sync(() => {
wakeCalls.push(sessionID)
}),
}),
)
const sessions = SessionV2.layer.pipe(
Layer.provide(events),
Layer.provide(database),
Layer.provide(store),
Layer.provide(Project.defaultLayer),
Layer.provide(execution),
)
const it = testEffect(Layer.mergeAll(database, events, projector, store, execution, sessions))
const sessionID = SessionV2.ID.make("ses_prompt_test")
const messageID = SessionMessage.ID.create()
const setup = Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
})
const admitted = (id: SessionMessage.ID) => Database.Service.use(({ db }) => SessionInput.find(db, id))
const admittedCount = Database.Service.use(({ db }) =>
db
.select()
.from(SessionInputTable)
.all()
.pipe(
Effect.orDie,
Effect.map((rows) => rows.length),
),
)
describe("SessionV2.prompt", () => {
it.effect("delegates execution continuation through SessionExecution", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
executionCalls.length = 0
wakeCalls.length = 0
yield* session.resume(sessionID)
expect(executionCalls).toEqual([sessionID])
expect(wakeCalls).toEqual([])
}),
)
it.effect("durably admits one user message before transcript promotion", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const message = yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Fix the failing tests" }),
resume: false,
})
expect(message.type).toBe("user")
expect(message.text).toBe("Fix the failing tests")
expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* admitted(message.id)).toMatchObject({
id: message.id,
sessionID,
prompt: { text: "Fix the failing tests" },
delivery: "steer",
})
}),
)
it.effect("streams durable Session events after an aggregate cursor", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const { db } = yield* Database.Service
const fiber = yield* session.events({ sessionID }).pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
yield* SessionInput.promoteSteers(db, events, sessionID)
const streamed = Array.from(yield* Fiber.join(fiber))
expect(
streamed.map((event) => [event.cursor, event.event.type, (event.event.data as { prompt: Prompt }).prompt.text]),
).toEqual([
[EventV2.Cursor.make(0), "session.next.prompted", "First"],
[EventV2.Cursor.make(1), "session.next.prompted", "Second"],
])
expect(
Array.from(
yield* session.events({ sessionID, after: streamed[0]!.cursor }).pipe(Stream.take(1), Stream.runCollect),
).map((event) => [event.cursor, (event.event.data as { prompt: Prompt }).prompt.text]),
).toEqual([[EventV2.Cursor.make(1), "Second"]])
}),
)
it.effect("resumes through a recorded message without appending another prompt", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const message = yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Fix the failing tests" }),
resume: false,
})
executionCalls.length = 0
wakeCalls.length = 0
yield* session.resume(sessionID)
expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* admitted(message.id)).not.toHaveProperty("promotedSeq")
expect(executionCalls).toEqual([sessionID])
expect(wakeCalls).toEqual([])
}),
)
it.effect("records distinct messages when the ID is omitted", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const input = { sessionID, prompt: new Prompt({ text: "Fix the failing tests" }), resume: false }
const first = yield* session.prompt(input)
const second = yield* session.prompt(input)
expect(second.id).not.toBe(first.id)
expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* admittedCount).toBe(2)
}),
)
it.effect("returns the original recorded message when the ID is retried", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const input = {
sessionID,
id: messageID,
prompt: new Prompt({ text: "Fix the failing tests" }),
resume: false,
}
const first = yield* session.prompt(input)
const retried = yield* session.prompt(input)
expect(retried).toEqual(first)
expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* admittedCount).toBe(1)
}),
)
it.effect("wakes execution when an exact prompt retry recovers a committed message", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const input = {
sessionID,
id: messageID,
prompt: new Prompt({ text: "Recover committed prompt" }),
resume: false,
}
const first = yield* session.prompt(input)
wakeCalls.length = 0
const retried = yield* session.prompt({ ...input, resume: true })
expect(retried).toEqual(first)
expect(wakeCalls).toEqual([sessionID])
}),
)
it.effect("rejects reuse of one ID with a different prompt", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({
sessionID,
id: messageID,
prompt: new Prompt({ text: "Fix the failing tests" }),
})
const failure = yield* session
.prompt({
sessionID,
id: messageID,
prompt: new Prompt({ text: "Delete the failing tests" }),
resume: false,
})
.pipe(Effect.flip)
expect(failure._tag).toBe("Session.PromptConflictError")
expect(yield* session.messages({ sessionID })).toHaveLength(0)
expect(yield* admittedCount).toBe(1)
}),
)
it.effect("rejects reuse of one ID with a different delivery mode", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({
id: messageID,
sessionID,
prompt: new Prompt({ text: "Fix the failing tests" }),
resume: false,
})
const failure = yield* session
.prompt({
id: messageID,
sessionID,
prompt: new Prompt({ text: "Fix the failing tests" }),
delivery: "queue",
resume: false,
})
.pipe(Effect.flip)
expect(failure._tag).toBe("Session.PromptConflictError")
}),
)
it.effect("does not match pending inputs when no delivery modes are eligible", () =>
Effect.gen(function* () {
yield* setup
const { db } = yield* Database.Service
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Wait" }), resume: false })
expect(yield* SessionInput.hasPending(db, sessionID, [])).toBe(false)
}),
)
it.effect("returns one recorded message to concurrent exact retries", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const input = {
sessionID,
id: messageID,
prompt: new Prompt({ text: "Fix the failing tests" }),
resume: false,
}
const messages = yield* Effect.all([session.prompt(input), session.prompt(input)], { concurrency: "unbounded" })
expect(messages[1]).toEqual(messages[0])
expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* admittedCount).toBe(1)
}),
)
it.effect("reconciles an existing projected prompt into a promoted inbox record", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const prompt = new Prompt({ text: "Historical prompt" })
yield* events.publish(
SessionEvent.Prompted,
{ sessionID, timestamp: yield* DateTime.now, prompt, delivery: "steer" },
{ id: messageID },
)
const retried = yield* session.prompt({ id: messageID, sessionID, prompt, resume: false })
expect(retried).toMatchObject({ id: messageID, text: "Historical prompt" })
expect(yield* admitted(messageID)).toHaveProperty("promotedSeq")
}),
)
it.effect("reconciles an existing projected queued prompt with its delivery mode", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const prompt = new Prompt({ text: "Historical queued prompt" })
yield* events.publish(
SessionEvent.Prompted,
{ sessionID, timestamp: yield* DateTime.now, prompt, delivery: "queue" },
{ id: messageID },
)
const retried = yield* session.prompt({ id: messageID, sessionID, prompt, delivery: "queue", resume: false })
expect(retried).toMatchObject({ id: messageID, text: "Historical queued prompt" })
expect(yield* admitted(messageID)).toMatchObject({ delivery: "queue" })
}),
)
it.effect("rejects an input ID already used by a durable non-prompt event", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
yield* events.publish(
SessionEvent.Synthetic,
{ sessionID, timestamp: yield* DateTime.now, text: "Collision" },
{ id: messageID },
)
const failure = yield* session
.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Collision" }), resume: false })
.pipe(Effect.flip)
expect(failure._tag).toBe("Session.PromptConflictError")
expect(yield* admitted(messageID)).toBeUndefined()
}),
)
it.effect("rejects a durable event ID reserved by an admitted prompt without poisoning promotion", () =>
Effect.gen(function* () {
yield* setup
const { db } = yield* Database.Service
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const prompt = new Prompt({ text: "Reserved prompt" })
yield* session.prompt({ id: messageID, sessionID, prompt, resume: false })
const failure = yield* events
.publish(
SessionEvent.Synthetic,
{ sessionID, timestamp: yield* DateTime.now, text: "Conflicting synthetic" },
{ id: messageID },
)
.pipe(Effect.catchDefect(Effect.succeed))
expect(failure).toBe("Durable event conflicts with admitted prompt input")
expect(yield* admitted(messageID)).not.toHaveProperty("promotedSeq")
expect(yield* session.messages({ sessionID })).toEqual([])
yield* SessionInput.promoteSteers(db, events, sessionID)
expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 0 })
expect(yield* session.messages({ sessionID })).toMatchObject([
{ id: messageID, type: "user", text: "Reserved prompt" },
])
}),
)
it.effect("rejects reuse of one globally unique message ID across sessions", () =>
Effect.gen(function* () {
yield* setup
const { db } = yield* Database.Service
const session = yield* SessionV2.Service
const other = SessionV2.ID.make("ses_prompt_other")
yield* db
.insert(SessionTable)
.values({
id: other,
project_id: Project.ID.global,
slug: "other",
directory: "/project",
title: "other",
version: "test",
})
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
const prompt = new Prompt({ text: "Fix the failing tests" })
yield* session.prompt({ id: messageID, sessionID, prompt, resume: false })
const failure = yield* session
.prompt({ id: messageID, sessionID: other, prompt, resume: false })
.pipe(Effect.flip)
expect(failure).toMatchObject({ _tag: "Session.PromptConflictError", sessionID: other, messageID })
}),
)
it.effect("starts execution by default after recording the prompt", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
executionCalls.length = 0
wakeCalls.length = 0
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run by default" }) })
expect(executionCalls).toEqual([])
expect(wakeCalls).toEqual([sessionID])
}),
)
it.effect("starts execution when resume is explicitly true", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
executionCalls.length = 0
wakeCalls.length = 0
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run explicitly" }), resume: true })
expect(executionCalls).toEqual([])
expect(wakeCalls).toEqual([sessionID])
}),
)
it.effect("only records the prompt when resume is false", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
executionCalls.length = 0
wakeCalls.length = 0
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Do not run" }), resume: false })
expect(executionCalls).toEqual([])
expect(wakeCalls).toEqual([])
}),
)
})

View file

@ -0,0 +1,384 @@
import { describe, expect } from "bun:test"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
import { testEffect } from "./lib/effect"
const it = testEffect(Layer.empty)
describe("SessionRunCoordinator", () => {
it.effect("joins concurrent resumes for one key", () =>
Effect.scoped(
Effect.gen(function* () {
const gate = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () => Effect.sync(() => runs++).pipe(Effect.andThen(Deferred.await(gate))),
})
const first = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Effect.yieldNow
const second = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Effect.yieldNow
expect(runs).toBe(1)
yield* Deferred.succeed(gate, undefined)
yield* Fiber.join(first)
yield* Fiber.join(second)
expect(runs).toBe(1)
}),
),
)
it.effect("starts a drain when woken while idle", () =>
Effect.scoped(
Effect.gen(function* () {
const drained = yield* Deferred.make<void>()
const coordinator = yield* SessionRunCoordinator.make({ drain: () => Deferred.succeed(drained, undefined) })
yield* coordinator.wake("session")
yield* Deferred.await(drained)
}),
),
)
it.effect("coalesces wakes received during an active run", () =>
Effect.scoped(
Effect.gen(function* () {
const gate = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++runs).pipe(Effect.flatMap((run) => (run === 1 ? Deferred.await(gate) : Effect.void))),
})
const first = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* Effect.all([coordinator.wake("session"), coordinator.wake("session"), coordinator.wake("session")], {
concurrency: "unbounded",
})
yield* Deferred.succeed(gate, undefined)
yield* Fiber.join(first)
expect(runs).toBe(2)
}),
),
)
it.effect("waits for a coalesced ownership chain to become idle", () =>
Effect.scoped(
Effect.gen(function* () {
const firstGate = yield* Deferred.make<void>()
const secondGate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
const idleSettled = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.await(firstGate)
: Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(secondGate))),
),
),
})
yield* coordinator.wake("session")
const idle = yield* coordinator
.awaitIdle("session")
.pipe(Effect.andThen(Deferred.succeed(idleSettled, undefined)), Effect.forkChild)
yield* coordinator.wake("session")
yield* Deferred.succeed(firstGate, undefined)
yield* Deferred.await(secondStarted)
expect(yield* Deferred.isDone(idleSettled)).toBeFalse()
yield* Deferred.succeed(secondGate, undefined)
yield* Fiber.join(idle)
expect(runs).toBe(2)
}),
),
)
it.effect("reports the first defect after a failed chain becomes idle", () =>
Effect.scoped(
Effect.gen(function* () {
const firstGate = yield* Deferred.make<void>()
const secondGate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
const defect = new Error("defect")
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.await(firstGate).pipe(Effect.andThen(Effect.die(defect)))
: Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(secondGate))),
),
),
})
yield* coordinator.wake("session")
const idle = yield* coordinator
.awaitIdle("session")
.pipe(Effect.catchDefect(Effect.succeed), Effect.forkChild({ startImmediately: true }))
yield* coordinator.wake("session")
yield* Deferred.succeed(firstGate, undefined)
yield* Deferred.await(secondStarted)
yield* Deferred.succeed(secondGate, undefined)
expect(yield* Fiber.join(idle)).toBe(defect)
expect(runs).toBe(2)
}),
),
)
it.effect("runs again when woken during the coalesced drain", () =>
Effect.scoped(
Effect.gen(function* () {
const firstGate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
const secondGate = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.await(firstGate)
: run === 2
? Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(secondGate)))
: Effect.void,
),
),
})
const first = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* coordinator.wake("session")
yield* Deferred.succeed(firstGate, undefined)
yield* Deferred.await(secondStarted)
yield* coordinator.wake("session")
yield* Deferred.succeed(secondGate, undefined)
yield* Fiber.join(first)
expect(runs).toBe(3)
}),
),
)
it.effect("starts one successor after a wake races with failure", () =>
Effect.scoped(
Effect.gen(function* () {
const gate = yield* Deferred.make<void>()
const failure = new Error("failed")
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1 ? Deferred.await(gate).pipe(Effect.andThen(Effect.fail(failure))) : Effect.void,
),
),
})
const first = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* coordinator.wake("session")
yield* Deferred.succeed(gate, undefined)
expect(yield* Fiber.join(first).pipe(Effect.flip)).toBe(failure)
yield* Effect.yieldNow
expect(runs).toBe(2)
}),
),
)
it.effect("upgrades an active wake when an explicit run joins it", () =>
Effect.scoped(
Effect.gen(function* () {
const wakeStarted = yield* Deferred.make<void>()
const wakeGate = yield* Deferred.make<void>()
const modes: SessionRunCoordinator.Mode[] = []
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
drain: (_key, mode) =>
Effect.sync(() => modes.push(mode)).pipe(
Effect.andThen(
mode === "wake"
? Deferred.succeed(wakeStarted, undefined).pipe(Effect.andThen(Deferred.await(wakeGate)))
: Effect.void,
),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(wakeStarted)
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Deferred.succeed(wakeGate, undefined)
yield* Fiber.join(run)
expect(modes).toEqual(["wake", "run"])
}),
),
)
it.effect("upgrades a recursive wake drain when an explicit run joins it", () =>
Effect.scoped(
Effect.gen(function* () {
const runGate = yield* Deferred.make<void>()
const wakeStarted = yield* Deferred.make<void>()
const wakeGate = yield* Deferred.make<void>()
const forcedStarted = yield* Deferred.make<void>()
const modes: SessionRunCoordinator.Mode[] = []
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
drain: (_key, mode) =>
Effect.gen(function* () {
modes.push(mode)
if (modes.length === 1) return yield* Deferred.await(runGate)
if (modes.length === 2)
return yield* Deferred.succeed(wakeStarted, undefined).pipe(Effect.andThen(Deferred.await(wakeGate)))
yield* Deferred.succeed(forcedStarted, undefined)
}),
})
const first = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* coordinator.wake("session")
yield* Deferred.succeed(runGate, undefined)
yield* Deferred.await(wakeStarted)
const second = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Deferred.succeed(wakeGate, undefined)
yield* Deferred.await(forcedStarted)
yield* Fiber.join(first)
yield* Fiber.join(second)
expect(modes).toEqual(["run", "wake", "run"])
}),
),
)
it.effect("propagates an upgraded explicit run failure before a successful advisory successor", () =>
Effect.scoped(
Effect.gen(function* () {
const wakeStarted = yield* Deferred.make<void>()
const wakeGate = yield* Deferred.make<void>()
const runStarted = yield* Deferred.make<void>()
const runGate = yield* Deferred.make<void>()
const advisoryStarted = yield* Deferred.make<void>()
const failure = new Error("explicit run failed")
const modes: SessionRunCoordinator.Mode[] = []
const coordinator = yield* SessionRunCoordinator.make<string, void, Error>({
drain: (_key, mode) =>
Effect.sync(() => modes.push(mode)).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.succeed(wakeStarted, undefined).pipe(Effect.andThen(Deferred.await(wakeGate)))
: run === 2
? Deferred.succeed(runStarted, undefined).pipe(
Effect.andThen(Deferred.await(runGate)),
Effect.andThen(Effect.fail(failure)),
)
: Deferred.succeed(advisoryStarted, undefined),
),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(wakeStarted)
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Deferred.succeed(wakeGate, undefined)
yield* Deferred.await(runStarted)
yield* coordinator.wake("session")
yield* Deferred.succeed(runGate, undefined)
yield* Deferred.await(advisoryStarted)
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
expect(modes).toEqual(["wake", "run", "wake"])
}),
),
)
it.effect("settles active callers when its owning scope closes", () =>
Effect.gen(function* () {
const scope = yield* Scope.make()
const started = yield* Deferred.make<void>()
const coordinator = yield* SessionRunCoordinator.make({
drain: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
}).pipe(Scope.provide(scope))
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Deferred.await(started)
const idle = yield* coordinator.awaitIdle("session").pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* Scope.close(scope, Exit.void)
const runExit = yield* Fiber.await(run)
const idleExit = yield* Fiber.await(idle)
expect(Exit.isFailure(runExit) && Cause.hasInterruptsOnly(runExit.cause)).toBeTrue()
expect(Exit.isSuccess(idleExit)).toBeTrue()
}),
)
it.effect("does not start work after its owning scope closes", () =>
Effect.gen(function* () {
const scope = yield* Scope.make()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () => Effect.sync(() => runs++),
}).pipe(Scope.provide(scope))
yield* Scope.close(scope, Exit.void)
yield* coordinator.wake("session")
yield* coordinator.awaitIdle("session")
const runExit = yield* coordinator.run("session").pipe(Effect.exit)
expect(Exit.isFailure(runExit) && Cause.hasInterruptsOnly(runExit.cause)).toBeTrue()
expect(runs).toBe(0)
}),
)
it.effect("does not cancel the owner when one joined waiter is interrupted", () =>
Effect.scoped(
Effect.gen(function* () {
const gate = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () => Effect.sync(() => runs++).pipe(Effect.andThen(Deferred.await(gate))),
})
const first = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Effect.yieldNow
const second = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Fiber.interrupt(second)
yield* Deferred.succeed(gate, undefined)
yield* Fiber.join(first)
expect(runs).toBe(1)
}),
),
)
it.effect("runs different keys concurrently", () =>
Effect.scoped(
Effect.gen(function* () {
const gate = yield* Deferred.make<void>()
const bothStarted = yield* Deferred.make<void>()
let active = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++active).pipe(
Effect.tap(() => (active === 2 ? Deferred.succeed(bothStarted, undefined) : Effect.void)),
Effect.andThen(Deferred.await(gate)),
),
})
const first = yield* coordinator.run("first").pipe(Effect.forkChild)
const second = yield* coordinator.run("second").pipe(Effect.forkChild)
yield* Deferred.await(bothStarted)
yield* Deferred.succeed(gate, undefined)
yield* Fiber.join(first)
yield* Fiber.join(second)
}),
),
)
})

View file

@ -0,0 +1,377 @@
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 { EventV2 } from "@opencode-ai/core/event"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { AgentAttachment, FileAttachment, ReferenceAttachment } from "@opencode-ai/core/session/prompt"
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 } from "effect"
const created = DateTime.makeUnsafe(0)
const id = (value: string) => EventV2.ID.make(`evt_${value}`)
const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route })
describe("toLLMMessages", () => {
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(
[
new SessionMessage.AgentSwitched({
id: id("agent"),
type: "agent-switched",
agent: "build",
time: { created },
}),
new SessionMessage.ModelSwitched({
id: id("model"),
type: "model-switched",
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
time: { created },
}),
new SessionMessage.User({
id: id("user"),
type: "user",
text: "Inspect this image",
files: [file],
agents: [new AgentAttachment({ name: "build" })],
references: [reference],
time: { created },
}),
new SessionMessage.Synthetic({
id: id("synthetic"),
type: "synthetic",
sessionID: SessionV2.ID.make("ses_translate"),
text: "Synthetic context",
time: { created },
}),
new SessionMessage.Shell({
id: id("shell"),
type: "shell",
callID: "shell-1",
command: "pwd",
output: "/project",
time: { created, completed: created },
}),
new SessionMessage.Compaction({
id: id("compaction"),
type: "compaction",
reason: "auto",
summary: "Earlier work",
time: { created },
}),
],
model,
)
expect(messages.map((message) => message.role)).toEqual(["user", "user", "user", "user"])
expect(messages[0]).toEqual(
Message.make({
id: id("user"),
role: "user",
content: [
{ type: "text", text: "Inspect this image" },
{ type: "media", mediaType: "image/png", data: "data:image/png;base64,aGVsbG8=", filename: "hello.png" },
],
metadata: { agents: [{ name: "build" }], references: [reference] },
}),
)
expect(messages.slice(1).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" }],
])
})
test("expands assistant tool calls and settled outcomes into canonical tool messages", () => {
const messages = toLLMMessages(
[
new SessionMessage.Assistant({
id: id("assistant"),
type: "assistant",
agent: "build",
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
content: [
new SessionMessage.AssistantText({ type: "text", id: "text-1", text: "Checking" }),
new SessionMessage.AssistantReasoning({
type: "reasoning",
id: "reasoning-1",
text: "Think",
providerMetadata: { anthropic: { signature: "sig_1" } },
}),
new SessionMessage.AssistantTool({
type: "tool",
id: "pending",
name: "read",
state: new SessionMessage.ToolStatePending({ status: "pending", input: '{"path":"README.md"}' }),
time: { created },
}),
new SessionMessage.AssistantTool({
type: "tool",
id: "running",
name: "read",
state: new SessionMessage.ToolStateRunning({
status: "running",
input: { path: "README.md" },
content: [],
structured: {},
}),
time: { created },
}),
new SessionMessage.AssistantTool({
type: "tool",
id: "completed",
name: "read",
state: new SessionMessage.ToolStateCompleted({
status: "completed",
input: { path: "README.md" },
content: [
new ToolOutput.TextContent({ type: "text", text: "Hello" }),
new ToolOutput.FileContent({
type: "file",
source: { type: "data", data: "aGVsbG8=" },
mime: "image/png",
name: "hello.png",
}),
],
structured: {},
}),
time: { created, completed: created },
}),
new SessionMessage.AssistantTool({
type: "tool",
id: "hosted",
name: "web_search",
provider: {
executed: true,
metadata: { fake: { continuation: "hosted-call" } },
resultMetadata: { fake: { continuation: "hosted-result" } },
},
state: new SessionMessage.ToolStateCompleted({
status: "completed",
input: { query: "Effect" },
content: [new ToolOutput.TextContent({ type: "text", text: "Found it" })],
structured: {},
}),
time: { created, completed: created },
}),
new SessionMessage.AssistantTool({
type: "tool",
id: "hosted-failed",
name: "write",
provider: { executed: true, metadata: { fake: { continuation: "failed" } } },
state: new SessionMessage.ToolStateError({
status: "error",
input: { path: "README.md" },
content: [],
structured: {},
error: { type: "unknown", message: "Denied" },
}),
time: { created, completed: created },
}),
],
time: { created, completed: created },
}),
],
model,
)
expect(messages.map((message) => message.role)).toEqual(["assistant", "tool"])
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Checking" },
{ type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "sig_1" } } },
{ type: "tool-call", id: "pending", name: "read", input: { path: "README.md" } },
{ type: "tool-call", id: "running", name: "read", input: { path: "README.md" } },
{
type: "tool-call",
id: "completed",
name: "read",
input: { path: "README.md" },
},
{
type: "tool-call",
id: "hosted",
name: "web_search",
input: { query: "Effect" },
providerExecuted: true,
providerMetadata: { fake: { continuation: "hosted-call" } },
},
{
type: "tool-result",
id: "hosted",
name: "web_search",
providerExecuted: true,
providerMetadata: { fake: { continuation: "hosted-result" } },
result: { type: "text", value: "Found it" },
},
{
type: "tool-call",
id: "hosted-failed",
name: "write",
input: { path: "README.md" },
providerExecuted: true,
providerMetadata: { fake: { continuation: "failed" } },
},
{
type: "tool-result",
id: "hosted-failed",
name: "write",
providerExecuted: true,
providerMetadata: { fake: { continuation: "failed" } },
result: {
type: "error",
value: { error: { type: "unknown", message: "Denied" }, content: [], structured: {} },
},
},
])
expect(messages[1]?.content).toEqual([
{
type: "tool-result",
id: "completed",
name: "read",
result: {
type: "content",
value: [
{ type: "text", text: "Hello" },
{ type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "hello.png" },
],
},
},
])
})
test("restores OpenAI encrypted reasoning metadata", () => {
const messages = toLLMMessages(
[
new SessionMessage.Assistant({
id: id("assistant-openai-reasoning"),
type: "assistant",
agent: "build",
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
content: [
new SessionMessage.AssistantReasoning({
type: "reasoning",
id: "reasoning-openai",
text: "Think",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
}),
],
time: { created, completed: created },
}),
],
model,
)
expect(messages[0]?.content).toEqual([
{
type: "reasoning",
text: "Think",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
})
test("drops provider-native continuation metadata after a model switch", () => {
const messages = toLLMMessages(
[
new SessionMessage.Assistant({
id: id("assistant-old-model"),
type: "assistant",
agent: "build",
model: { id: ModelV2.ID.make("old-model"), providerID: ProviderV2.ID.make("provider") },
content: [
new SessionMessage.AssistantReasoning({
type: "reasoning",
id: "reasoning-old-model",
text: "Visible thought",
providerMetadata: { anthropic: { signature: "sig_old" } },
}),
new SessionMessage.AssistantTool({
type: "tool",
id: "hosted-old-model",
name: "web_search",
provider: {
executed: true,
metadata: { openai: { itemId: "hosted-old-model" } },
resultMetadata: { openai: { itemId: "hosted-old-model" } },
},
state: new SessionMessage.ToolStateCompleted({
status: "completed",
input: { query: "Effect" },
content: [],
structured: {},
result: { type: "json", value: { status: "completed" } },
}),
time: { created, completed: created },
}),
new SessionMessage.AssistantTool({
type: "tool",
id: "local-old-model",
name: "read",
provider: {
executed: false,
metadata: { fake: { call: "old" } },
resultMetadata: { fake: { result: "old" } },
},
state: new SessionMessage.ToolStateCompleted({
status: "completed",
input: { path: "README.md" },
content: [],
structured: { text: "Hello" },
}),
time: { created, completed: created },
}),
],
time: { created, completed: created },
}),
],
model,
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Visible thought" },
{
type: "tool-call",
id: "hosted-old-model",
name: "web_search",
input: { query: "Effect" },
providerExecuted: true,
providerMetadata: undefined,
},
{
type: "tool-result",
id: "hosted-old-model",
name: "web_search",
result: { type: "json", value: { status: "completed" } },
providerExecuted: true,
cache: undefined,
metadata: undefined,
providerMetadata: undefined,
},
{
type: "tool-call",
id: "local-old-model",
name: "read",
input: { path: "README.md" },
providerExecuted: false,
providerMetadata: undefined,
},
])
expect(messages[1]?.content).toEqual([
{
type: "tool-result",
id: "local-old-model",
name: "read",
result: { type: "json", value: { text: "Hello" } },
providerExecuted: false,
cache: undefined,
metadata: undefined,
providerMetadata: undefined,
},
])
})
})

View file

@ -0,0 +1,213 @@
import { describe, expect } from "bun:test"
import { LLM } from "@opencode-ai/llm"
import { LLMClient } from "@opencode-ai/llm/route"
import { ConfigProvider, DateTime, Effect } from "effect"
import { Headers } from "effect/unstable/http"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ProjectV2 } from "@opencode-ai/core/project"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionV2 } from "@opencode-ai/core/session"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { it } from "./lib/effect"
type Api =
| {
readonly type: "aisdk"
readonly package: string
readonly url?: string
readonly settings?: Record<string, unknown>
}
| { readonly type: "native"; readonly url?: string; readonly settings: Record<string, unknown> }
const model = (api: Api, variants: ModelV2.Info["variants"] = []) =>
new ModelV2.Info({
id: ModelV2.ID.make("test-model"),
providerID: ProviderV2.ID.make("test-provider"),
name: "Test model",
api: { id: ModelV2.ID.make("api-test-model"), ...api },
capabilities: { tools: true, input: ["text"], output: ["text"] },
request: {
headers: { "x-test": "header" },
body: { store: false, apiKey: "secret" },
},
variants,
time: { released: DateTime.makeUnsafe(0) },
cost: [],
status: "active",
enabled: true,
limit: { context: 100, output: 20 },
})
const provider = (api: ProviderV2.Info["api"]) =>
new ProviderV2.Info({
id: ProviderV2.ID.make("test-provider"),
name: "Test provider",
enabled: { via: "env", name: "TEST_PROVIDER_API_KEY" },
env: ["TEST_PROVIDER_API_KEY"],
api,
request: { headers: {}, body: {} },
})
describe("SessionRunnerModel", () => {
it.effect("maps catalog OpenAI AI SDK models into native Responses routes", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
)
expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" })
expect(resolved.route).toMatchObject({
id: "openai-responses",
endpoint: { baseURL: "https://openai.example/v1" },
defaults: {
headers: { "x-test": "header" },
limits: { context: 100, output: 20 },
http: { body: { store: false } },
},
})
}),
)
it.effect("keeps catalog apiKey credentials out of provider JSON", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
)
const prepared = yield* LLMClient.prepare(LLM.request({ model: resolved, prompt: "Hello" }))
expect(JSON.stringify(prepared.body)).not.toContain("apiKey")
expect(JSON.stringify(prepared.body)).not.toContain("secret")
}),
)
it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
new ModelV2.Info({
...model({
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://compatible.example/v1",
settings: { apiKey: "settings-secret", compatibility: "strict" },
}),
request: { headers: {}, body: {} },
}),
)
const request = LLM.request({ model: resolved, prompt: "Hello" })
const headers = yield* resolved.route.auth.apply({
request,
method: "POST",
url: "https://compatible.example/v1/chat/completions",
body: "{}",
headers: Headers.empty,
})
expect(headers.authorization).toBe("Bearer settings-secret")
expect(resolved.route.defaults.http?.body).toEqual({})
}),
)
it.effect("applies the selected Session variant to request options", () =>
Effect.gen(function* () {
const catalog = model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }, [
{
id: ModelV2.VariantID.make("high"),
headers: { "x-variant": "high" },
body: { reasoningEffort: "high" },
},
])
const session = SessionV2.Info.make({
id: SessionV2.ID.make("ses_model_variant"),
projectID: ProjectV2.ID.global,
title: "test",
model: {
id: catalog.id,
providerID: catalog.providerID,
variant: ModelV2.VariantID.make("high"),
},
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: AbsolutePath.make("/project") },
})
const resolved = yield* SessionRunnerModel.resolve(session, catalog)
expect(resolved.route.defaults).toMatchObject({
headers: { "x-test": "header", "x-variant": "high" },
http: { body: { store: false, reasoningEffort: "high" } },
})
}),
)
it.effect("maps catalog Anthropic AI SDK models into native routes", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
model({ type: "aisdk", package: "@ai-sdk/anthropic", url: "https://anthropic.example/v1" }),
)
expect(resolved.route).toMatchObject({
id: "anthropic-messages",
endpoint: { baseURL: "https://anthropic.example/v1" },
})
}),
)
it.effect("preserves environment-backed bearer auth", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
new ModelV2.Info({
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
request: { headers: {}, body: {} },
}),
provider({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
)
const request = LLM.request({ model: resolved, prompt: "Hello" })
const headers = yield* resolved.route.auth
.apply({
request,
method: "POST",
url: "https://openai.example/v1/responses",
body: "{}",
headers: Headers.empty,
})
.pipe(
Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: { TEST_PROVIDER_API_KEY: "secret" } }))),
)
expect(headers.authorization).toBe("Bearer secret")
}),
)
it.effect("rejects catalog APIs without a native route", () =>
Effect.gen(function* () {
const failure = yield* SessionRunnerModel.fromCatalogModel(
model({ type: "aisdk", package: "@ai-sdk/google", url: "https://google.example/v1" }),
).pipe(Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.UnsupportedApiError",
providerID: "test-provider",
modelID: "test-model",
api: "aisdk:@ai-sdk/google",
})
}),
)
it.effect("reports whether a catalog model has a supported native route", () =>
Effect.sync(() => {
expect(
SessionRunnerModel.supported(
model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
),
).toBe(true)
expect(
SessionRunnerModel.supported(
model({ type: "aisdk", package: "@ai-sdk/google", url: "https://google.example/v1" }),
),
).toBe(false)
expect(SessionRunnerModel.supported(model({ type: "native", settings: {} }))).toBe(false)
}),
)
})

View file

@ -0,0 +1,155 @@
import { NodeFileSystem } from "@effect/platform-node"
import { HttpRecorder } from "@opencode-ai/http-recorder"
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
import { Auth, LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { Prompt } from "@opencode-ai/core/session/prompt"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
import { SessionRunner } from "@opencode-ai/core/session/runner"
import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
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 { describe, expect } from "bun:test"
import { eq } from "drizzle-orm"
import { Effect, Layer } from "effect"
import path from "node:path"
import { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:")
const events = EventV2.layer.pipe(Layer.provide(database))
const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))
const store = SessionStore.layer.pipe(Layer.provide(database))
const cassette = HttpRecorder.cassetteLayer("session-runner/openai-chat-streams-text", {
directory: path.resolve(import.meta.dir, "fixtures/recordings"),
mode: process.env.RECORD === "true" ? "record" : "replay",
}).pipe(Layer.provide(NodeFileSystem.layer))
const executor = RequestExecutor.layer.pipe(Layer.provide(cassette))
const client = LLMClient.layer.pipe(Layer.provide(executor))
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
assert: () => Effect.die("unused"),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
)
const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
const model = OpenAIChat.route
.with({
endpoint: { baseURL: "https://api.openai.com/v1" },
auth: Auth.bearer(process.env.OPENAI_API_KEY ?? "fixture"),
generation: { maxTokens: 20, temperature: 0 },
})
.model({ id: "gpt-4o-mini" })
const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
const runner = SessionRunnerLLM.defaultLayer.pipe(
Layer.provide(database),
Layer.provide(store),
Layer.provide(events),
Layer.provide(client),
Layer.provide(registry),
Layer.provide(models),
)
const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
const execution = Layer.effect(
SessionExecution.Service,
SessionRunCoordinator.Service.pipe(
Effect.map((coordinator) => SessionExecution.Service.of({ resume: coordinator.run, wake: coordinator.wake })),
),
).pipe(Layer.provide(coordinator))
const sessions = SessionV2.layer.pipe(
Layer.provide(events),
Layer.provide(database),
Layer.provide(store),
Layer.provide(Project.defaultLayer),
Layer.provide(execution),
)
const it = testEffect(
Layer.mergeAll(
database,
events,
projector,
store,
executor,
client,
permission,
registry,
models,
runner,
coordinator,
execution,
sessions,
),
)
const sessionID = SessionV2.ID.make("ses_runner_recorded")
describe("SessionRunnerLLM recorded", () => {
it.effect("executes one recorded V2 prompt through the recorded HTTP transport", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
const session = yield* SessionV2.Service
const prompt = yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Say hello in one short sentence." }),
resume: false,
})
yield* session.resume(sessionID)
const messages = yield* session.context(sessionID)
expect(messages).toHaveLength(2)
expect(messages[0]).toEqual(prompt)
expect(messages[1]).toMatchObject({ type: "assistant", agent: "build", finish: "stop" })
expect(messages[1]?.type === "assistant" ? messages[1].content : []).toMatchObject([
{ type: "text", text: "Hello!" },
])
expect(
(yield* db
.select({ type: EventTable.type })
.from(EventTable)
.where(eq(EventTable.aggregate_id, sessionID))
.orderBy(EventTable.seq)
.all()).map((event) => event.type),
).toEqual([
"session.next.prompted.1",
"session.next.step.started.1",
"session.next.text.started.1",
"session.next.text.ended.1",
"session.next.step.ended.2",
])
}),
)
})

View file

@ -0,0 +1,211 @@
import { describe, expect } from "bun:test"
import { Tool, ToolFailure } from "@opencode-ai/llm"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool-registry"
import { Effect, Exit, Layer, Schema, Scope } from "effect"
import { testEffect } from "./lib/effect"
const assertions: PermissionV2.AssertInput[] = []
let denyAction: string | undefined
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
)
const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
const it = testEffect(Layer.mergeAll(permission, registry))
const echo = Tool.make({
description: "Echo text",
parameters: Schema.Struct({ text: Schema.String }),
success: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.succeed({ text }),
})
describe("ToolRegistry", () => {
it.effect("rebuilds advertised definitions when a scoped transform closes", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const scope = yield* Scope.make()
const transform = yield* registry.transform().pipe(Scope.provide(scope))
yield* transform((editor) => editor.set("echo", { tool: echo, authorize: () => Effect.void }))
expect(yield* registry.definitions()).toMatchObject([{ name: "echo", description: "Echo text" }])
yield* Scope.close(scope, Exit.void)
expect(yield* registry.definitions()).toEqual([])
}),
)
it.effect("returns an error result for an unknown tool", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
sessionID: SessionV2.ID.make("ses_registry_test"),
call: { type: "tool-call", id: "call-missing", name: "missing", input: {} },
}),
).toEqual({ type: "error", value: "Unknown tool: missing" })
}),
)
it.effect("does not execute a tool when authorization fails", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
let executed = false
const transform = yield* registry.transform()
yield* transform((editor) =>
editor.set("denied", {
authorize: () => Effect.fail(new ToolFailure({ message: "Denied" })),
tool: Tool.make({
description: "Denied tool",
parameters: Schema.Struct({}),
success: Schema.Struct({ ok: Schema.Boolean }),
execute: () =>
Effect.sync(() => {
executed = true
return { ok: true }
}),
}),
}),
)
expect(
yield* registry.execute({
sessionID: SessionV2.ID.make("ses_registry_test"),
call: { type: "tool-call", id: "call-denied", name: "denied", input: {} },
}),
).toEqual({ type: "error", value: "Denied" })
expect(executed).toBe(false)
}),
)
it.effect("binds invocation identity while preserving leaf-owned permission inputs", () =>
Effect.gen(function* () {
assertions.length = 0
denyAction = undefined
const registry = yield* ToolRegistry.Service
const transform = yield* registry.transform()
const sessionID = SessionV2.ID.make("ses_registry_context")
yield* transform((editor) =>
editor.set("context", {
tool: Tool.make({
description: "Context tool",
parameters: Schema.Struct({}),
success: Schema.Struct({ ok: Schema.Boolean }),
}),
execute: ({ assertPermission, call, source }) =>
assertPermission({
action: "inspect",
resources: [call.id],
save: ["*"],
metadata: { tool: call.name },
}).pipe(
Effect.as({ ok: source === undefined }),
Effect.catch(() => Effect.fail(new ToolFailure({ message: "Denied" }))),
),
}),
)
expect(
yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-context", name: "context", input: {} },
}),
).toEqual({ type: "json", value: { ok: true } })
expect(assertions).toEqual([
{
sessionID,
action: "inspect",
resources: ["call-context"],
save: ["*"],
metadata: { tool: "context" },
},
])
expect(assertions[0]).not.toHaveProperty("source")
}),
)
it.effect("keeps ordered multi-assert policy flow in the leaf and stops on denial", () =>
Effect.gen(function* () {
assertions.length = 0
denyAction = "execute"
let executed = false
const registry = yield* ToolRegistry.Service
const transform = yield* registry.transform()
yield* transform((editor) =>
editor.set("ordered", {
tool: Tool.make({
description: "Ordered policy tool",
parameters: Schema.Struct({}),
success: Schema.Struct({ ok: Schema.Boolean }),
}),
execute: ({ assertPermission }) =>
Effect.gen(function* () {
yield* assertPermission({ action: "external_directory", resources: ["/outside/*"] })
yield* assertPermission({ action: "execute", resources: ["pwd"] })
executed = true
return { ok: true }
}).pipe(Effect.catch(() => Effect.fail(new ToolFailure({ message: "Denied" })))),
}),
)
expect(
yield* registry.execute({
sessionID: SessionV2.ID.make("ses_registry_context"),
call: { type: "tool-call", id: "call-ordered", name: "ordered", input: {} },
}),
).toEqual({ type: "error", value: "Denied" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "execute"])
expect(executed).toBe(false)
denyAction = undefined
}),
)
it.effect("settles encoded structured output with canonical projected content", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const transform = yield* registry.transform()
yield* transform((editor) =>
editor.set("projected", {
tool: Tool.make({
description: "Projected tool",
parameters: Schema.Struct({ prefix: Schema.String }),
success: Schema.Struct({ count: Schema.NumberFromString }),
execute: () => Effect.succeed({ count: 2 }),
toModelOutput: ({ callID, parameters, output }) => [
{ type: "text", text: `${callID}:${parameters.prefix}:${output.count}` },
],
}),
}),
)
expect(
yield* registry.settle({
sessionID: SessionV2.ID.make("ses_registry_test"),
call: { type: "tool-call", id: "call-projected", name: "projected", input: { prefix: "count" } },
}),
).toEqual({
result: { type: "text", value: "call-projected:count:2" },
output: { structured: { count: "2" }, content: [{ type: "text", text: "call-projected:count:2" }] },
})
}),
)
})

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,70 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionSystemContext } from "@opencode-ai/core/session-system-context"
import { SystemContext } from "@opencode-ai/core/system-context"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const directory = AbsolutePath.make("/repo/packages/core")
const projectDirectory = AbsolutePath.make("/repo")
const timestamp = Date.parse("2026-06-03T12:00:00.000Z")
const localDate = (time: number) => new Date(time).toDateString()
const it = testEffect(
SessionSystemContext.locationLayer.pipe(
Layer.provide(
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory }, { projectDirectory, vcs: { type: "git", store: AbsolutePath.make("/repo/.git") } }),
),
),
),
),
)
describe("SessionSystemContext", () => {
it.effect("loads location-scoped environment and host-local date context", () =>
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* SessionSystemContext.Service
const initialized = SystemContext.initialize(yield* context.load())
expect(initialized.baseline).toEqual([
{
key: SystemContext.Key.make("core/environment"),
text: [
"Here is some useful information about the environment you are running in:",
"<env>",
` Working directory: ${directory}`,
` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes",
` Platform: ${process.platform}`,
"</env>",
].join("\n"),
},
{ key: SystemContext.Key.make("core/date"), text: `Today's date: ${localDate(timestamp)}` },
])
}),
)
it.effect("refreshes the date without repeating unchanged environment context", () =>
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* SessionSystemContext.Service
const initialized = SystemContext.initialize(yield* context.load())
yield* TestClock.setTime(timestamp + 24 * 60 * 60 * 1000)
const refreshed = SystemContext.refresh(yield* context.load(), initialized.checkpoint)
expect(refreshed.changes).toEqual([
{
key: SystemContext.Key.make("core/date"),
text: `Today's date is now: ${localDate(timestamp + 24 * 60 * 60 * 1000)}`,
},
])
}),
)
})

View file

@ -0,0 +1,95 @@
import { describe, expect } from "bun:test"
import { asc } from "drizzle-orm"
import { Effect, Layer } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionTable, TodoTable } from "@opencode-ai/core/session/sql"
import { SessionTodo } from "@opencode-ai/core/session/todo"
import { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:")
const events = EventV2.layer.pipe(Layer.provide(database))
const todos = SessionTodo.layer.pipe(Layer.provide(database), Layer.provide(events))
const it = testEffect(Layer.mergeAll(database, events, todos))
const sessionID = SessionV2.ID.make("ses_todo_test")
const setup = Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "todo",
directory: "/project",
title: "todo",
version: "test",
})
.run()
.pipe(Effect.orDie)
})
describe("SessionTodo", () => {
it.effect("replaces persisted todos in order and publishes updates", () =>
Effect.gen(function* () {
yield* setup
const { db } = yield* Database.Service
const events = yield* EventV2.Service
const todos = yield* SessionTodo.Service
const published = new Array<EventV2.Payload>()
const unsubscribe = yield* events.listen((event) =>
Effect.sync(() => {
if (event.type === SessionTodo.Event.Updated.type) published.push(event)
}),
)
yield* Effect.addFinalizer(() => unsubscribe)
yield* todos.update({
sessionID,
todos: [
{ content: "second", status: "pending", priority: "low" },
{ content: "first", status: "in_progress", priority: "high" },
],
})
expect(yield* todos.get(sessionID)).toEqual([
{ content: "second", status: "pending", priority: "low" },
{ content: "first", status: "in_progress", priority: "high" },
])
expect(
(yield* db.select().from(TodoTable).orderBy(asc(TodoTable.position)).all().pipe(Effect.orDie)).map((row) => ({
content: row.content,
position: row.position,
})),
).toEqual([
{ content: "second", position: 0 },
{ content: "first", position: 1 },
])
yield* todos.update({ sessionID, todos: [{ content: "replacement", status: "completed", priority: "medium" }] })
expect(yield* todos.get(sessionID)).toEqual([{ content: "replacement", status: "completed", priority: "medium" }])
yield* todos.update({ sessionID, todos: [] })
expect(yield* todos.get(sessionID)).toEqual([])
expect(published.map((event) => event.data)).toEqual([
{
sessionID,
todos: [
{ content: "second", status: "pending", priority: "low" },
{ content: "first", status: "in_progress", priority: "high" },
],
},
{ sessionID, todos: [{ content: "replacement", status: "completed", priority: "medium" }] },
{ sessionID, todos: [] },
])
}),
)
})

View file

@ -0,0 +1,159 @@
import { describe, expect } from "bun:test"
import { asc, eq } from "drizzle-orm"
import { DateTime, Effect, Layer, Schema } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql"
import { ModelV2 } from "@opencode-ai/core/model"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionTable, SessionMessageTable } from "@opencode-ai/core/session/sql"
import { ToolOutput } from "@opencode-ai/core/tool-output"
import { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:")
const events = EventV2.layer.pipe(Layer.provide(database))
const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))
const it = testEffect(Layer.mergeAll(database, events, projector))
const timestamp = DateTime.makeUnsafe(1)
const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }
const content = (text: string) => [ToolOutput.text({ type: "text", text })]
describe("Tool.Progress", () => {
it.effect("projects durable progress and keeps final settlements durable", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
const service = yield* EventV2.Service
const sessionID = SessionV2.ID.make("ses_tool_progress_projector")
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "progress",
directory: "/project",
title: "progress",
version: "test",
})
.run()
.pipe(Effect.orDie)
const assistantMessageID = (yield* service.publish(SessionEvent.Step.Started, {
sessionID,
timestamp,
agent: "build",
model,
})).id
const readAssistant = Effect.gen(function* () {
const row = yield* db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, assistantMessageID))
.get()
.pipe(Effect.orDie)
if (!row) return yield* Effect.die("Missing projected assistant")
return Schema.decodeUnknownSync(SessionMessage.Assistant)({ ...row.data, id: row.id, type: row.type })
})
const start = (callID: string) =>
Effect.gen(function* () {
yield* service.publish(SessionEvent.Tool.Input.Started, {
sessionID,
timestamp,
assistantMessageID,
callID,
name: "bash",
})
yield* service.publish(SessionEvent.Tool.Called, {
sessionID,
timestamp,
assistantMessageID,
callID,
tool: "bash",
input: { command: "pwd" },
provider: { executed: false },
})
})
yield* start("call-success")
expect((yield* readAssistant).content[0]).toMatchObject({
state: { status: "running", structured: {}, content: [] },
})
yield* service.publish(SessionEvent.Tool.Progress, {
sessionID,
timestamp,
assistantMessageID,
callID: "call-success",
structured: { phase: "checkpoint" },
content: content("saved"),
})
expect((yield* readAssistant).content[0]).toMatchObject({
state: { status: "running", structured: { phase: "checkpoint" }, content: content("saved") },
})
const success = yield* service.publish(SessionEvent.Tool.Success, {
sessionID,
timestamp,
assistantMessageID,
callID: "call-success",
structured: { phase: "done" },
content: content("complete"),
provider: { executed: false },
})
expect((yield* readAssistant).content[0]).toMatchObject({
state: { status: "completed", structured: { phase: "done" }, content: content("complete") },
})
yield* start("call-failed")
yield* service.publish(SessionEvent.Tool.Progress, {
sessionID,
timestamp,
assistantMessageID,
callID: "call-failed",
structured: { phase: "checkpoint" },
content: content("before failure"),
})
const failed = yield* service.publish(SessionEvent.Tool.Failed, {
sessionID,
timestamp,
assistantMessageID,
callID: "call-failed",
error: { type: "unknown", message: "boom" },
provider: { executed: false },
})
expect((yield* readAssistant).content[1]).toMatchObject({
state: {
status: "error",
structured: { phase: "checkpoint" },
content: content("before failure"),
error: { type: "unknown", message: "boom" },
},
})
expect(Schema.is(SessionEvent.Durable)(success)).toBe(true)
expect(Schema.is(SessionEvent.Durable)(failed)).toBe(true)
const rows = yield* db
.select({ type: EventTable.type })
.from(EventTable)
.where(eq(EventTable.aggregate_id, sessionID))
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie)
expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Progress.type, 1))
expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Success.type, 1))
expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Failed.type, 1))
}),
)
})

View file

@ -0,0 +1,104 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect, test } from "bun:test"
import { Effect, Layer } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { tmpdir } from "./fixture/tmpdir"
const base = "https://skills.example.test/catalog/"
async function pull(skills: unknown[], files: Record<string, string> = {}) {
const tmp = await tmpdir()
const requests: string[] = []
const http = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.sync(() => requests.push(request.url)).pipe(
Effect.map(() => {
const body = request.url === `${base}index.json` ? JSON.stringify({ skills }) : files[request.url]
return HttpClientResponse.fromWeb(
request,
new Response(body ?? "Not Found", { status: body === undefined ? 404 : 200 }),
)
}),
),
),
)
const layer = SkillDiscovery.layer.pipe(
Layer.provide(http),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Global.layerWith({ cache: tmp.path })),
)
const directories = await Effect.runPromise(
Effect.gen(function* () {
return yield* (yield* SkillDiscovery.Service).pull(base)
}).pipe(Effect.provide(layer)),
)
return { tmp, requests, directories }
}
describe("SkillDiscovery.pull", () => {
test("rejects skill name traversal without fetching files", async () => {
const result = await pull([{ name: "../outside", files: ["SKILL.md"] }])
try {
expect(result.directories).toEqual([])
expect(result.requests).toEqual([`${base}index.json`])
expect(await fs.readdir(result.tmp.path)).toEqual([])
} finally {
await result.tmp[Symbol.asyncDispose]()
}
})
test("rejects file traversal without fetching files", async () => {
const result = await pull([{ name: "deploy", files: ["SKILL.md", "../outside.md"] }])
try {
expect(result.directories).toEqual([])
expect(result.requests).toEqual([`${base}index.json`])
expect(await fs.readdir(result.tmp.path)).toEqual([])
} finally {
await result.tmp[Symbol.asyncDispose]()
}
})
test("rejects absolute file paths without fetching files", async () => {
const result = await pull([{ name: "deploy", files: ["SKILL.md", "/tmp/outside.md"] }])
try {
expect(result.directories).toEqual([])
expect(result.requests).toEqual([`${base}index.json`])
expect(await fs.readdir(result.tmp.path)).toEqual([])
} finally {
await result.tmp[Symbol.asyncDispose]()
}
})
test("rejects cross-origin file URLs without fetching files", async () => {
const result = await pull([{ name: "deploy", files: ["SKILL.md", "https://evil.example.test/outside.md"] }])
try {
expect(result.directories).toEqual([])
expect(result.requests).toEqual([`${base}index.json`])
expect(await fs.readdir(result.tmp.path)).toEqual([])
} finally {
await result.tmp[Symbol.asyncDispose]()
}
})
test("downloads safe nested files under the skill root", async () => {
const result = await pull([{ name: "deploy", files: ["SKILL.md", "references/guide.md"] }], {
[`${base}deploy/SKILL.md`]: "# Deploy",
[`${base}deploy/references/guide.md`]: "# Guide",
})
try {
expect(result.directories).toHaveLength(1)
expect(result.requests.toSorted()).toEqual(
[`${base}index.json`, `${base}deploy/SKILL.md`, `${base}deploy/references/guide.md`].toSorted(),
)
expect(await fs.readFile(path.join(result.directories[0], "SKILL.md"), "utf8")).toBe("# Deploy")
expect(await fs.readFile(path.join(result.directories[0], "references", "guide.md"), "utf8")).toBe("# Guide")
} finally {
await result.tmp[Symbol.asyncDispose]()
}
})
})

View file

@ -0,0 +1,188 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { SystemContext } from "@opencode-ai/core/system-context"
import { Hash } from "@opencode-ai/core/util/hash"
const key = SystemContext.Key.make
describe("SystemContext", () => {
test("loads one coherent sample and initializes a deterministic baseline", async () => {
let loads = 0
const context = SystemContext.struct({
date: SystemContext.value({
key: key("core/date"),
load: Effect.sync(() => {
loads++
return { baseline: "Today's date is 2026-06-03.", update: "The current date is 2026-06-03." }
}),
}),
location: SystemContext.value({
key: key("core/location"),
load: Effect.succeed({ baseline: "Working directory: /repo", update: "The working directory is /repo." }),
}),
})
const initialized = SystemContext.initialize(await Effect.runPromise(SystemContext.load(context)))
expect(loads).toBe(1)
expect(initialized).toEqual({
baseline: [
{ key: key("core/date"), text: "Today's date is 2026-06-03." },
{ key: key("core/location"), text: "Working directory: /repo" },
],
checkpoint: {
"core/date": Hash.sha256("The current date is 2026-06-03."),
"core/location": Hash.sha256("The working directory is /repo."),
},
})
})
test("emits changed and newly registered components in declaration order", async () => {
const context = SystemContext.struct({
date: SystemContext.value({
key: key("core/date"),
load: Effect.succeed({ baseline: "Today's date is 2026-06-04.", update: "The current date is 2026-06-04." }),
}),
location: SystemContext.value({
key: key("core/location"),
load: Effect.succeed({ baseline: "Working directory: /repo", update: "The working directory is /repo." }),
}),
skills: SystemContext.value({
key: key("core/skills"),
load: Effect.succeed({ baseline: "Available skills: effect", update: "Available skills: effect" }),
}),
})
const refreshed = SystemContext.refresh(await Effect.runPromise(SystemContext.load(context)), {
"core/date": Hash.sha256("The current date is 2026-06-03."),
"core/location": Hash.sha256("The working directory is /repo."),
})
expect(refreshed).toEqual({
changes: [
{ key: key("core/date"), text: "The current date is 2026-06-04." },
{ key: key("core/skills"), text: "Available skills: effect" },
],
checkpoint: {
"core/date": Hash.sha256("The current date is 2026-06-04."),
"core/location": Hash.sha256("The working directory is /repo."),
"core/skills": Hash.sha256("Available skills: effect"),
},
})
expect(SystemContext.render(refreshed.changes)).toBe("The current date is 2026-06-04.\n\nAvailable skills: effect")
})
test("omits unavailable initial context and admits it after its first successful load", async () => {
let available = false
const context = SystemContext.struct({
remote: SystemContext.value({
key: key("core/remote-instructions"),
load: Effect.sync(() =>
available
? { baseline: "Remote instructions: available", update: "Remote instructions are now available." }
: SystemContext.unavailable,
),
}),
})
const initialized = SystemContext.initialize(await Effect.runPromise(SystemContext.load(context)))
available = true
const refreshed = SystemContext.refresh(
await Effect.runPromise(SystemContext.load(context)),
initialized.checkpoint,
)
expect(initialized).toEqual({ baseline: [], checkpoint: {} })
expect(refreshed.changes).toEqual([
{ key: key("core/remote-instructions"), text: "Remote instructions are now available." },
])
})
test("retains an existing checkpoint while context is unavailable", async () => {
const previous = { "core/remote-instructions": Hash.sha256("Remote instructions: old") }
const context = SystemContext.struct({
remote: SystemContext.value({
key: key("core/remote-instructions"),
load: Effect.succeed(SystemContext.unavailable),
}),
})
const refreshed = SystemContext.refresh(await Effect.runPromise(SystemContext.load(context)), previous)
expect(refreshed).toEqual({ changes: [], checkpoint: previous })
})
test("drops checkpoints for removed components", async () => {
const context = SystemContext.struct({
date: SystemContext.value({
key: key("core/date"),
load: Effect.succeed({ baseline: "Today's date is 2026-06-03.", update: "The current date is 2026-06-03." }),
}),
})
const refreshed = SystemContext.refresh(await Effect.runPromise(SystemContext.load(context)), {
"core/date": Hash.sha256("The current date is 2026-06-03."),
"plugin/removed": Hash.sha256("Removed plugin context"),
})
expect(refreshed).toEqual({
changes: [],
checkpoint: { "core/date": Hash.sha256("The current date is 2026-06-03.") },
})
})
test("ignores inherited checkpoint properties", async () => {
const context = SystemContext.struct({
date: SystemContext.value({
key: key("core/date"),
load: Effect.succeed({ baseline: "Today's date is 2026-06-03.", update: "The current date is 2026-06-03." }),
}),
})
const previous = Object.create({
"core/date": Hash.sha256("The current date is 2026-06-03."),
}) as SystemContext.Checkpoint
const refreshed = SystemContext.refresh(await Effect.runPromise(SystemContext.load(context)), previous)
expect(refreshed.changes).toEqual([{ key: key("core/date"), text: "The current date is 2026-06-03." }])
expect(Object.hasOwn(refreshed.checkpoint, "core/date")).toBe(true)
})
test("preserves unexpected loader failures", async () => {
const context = SystemContext.struct({
broken: SystemContext.value({
key: key("plugin/broken"),
load: Effect.fail("broken loader"),
}),
})
await expect(Effect.runPromise(SystemContext.load(context))).rejects.toBe("broken loader")
})
test("rejects duplicate component keys", () => {
expect(() =>
SystemContext.struct({
one: SystemContext.value({ key: key("core/date"), load: Effect.succeed({ baseline: "one", update: "one" }) }),
two: SystemContext.value({ key: key("core/date"), load: Effect.succeed({ baseline: "two", update: "two" }) }),
}),
).toThrow(new SystemContext.DuplicateKeyError({ key: key("core/date") }))
})
test("rejects duplicate component keys at the interpreter boundary", async () => {
const component = SystemContext.value({
key: key("core/date"),
load: Effect.succeed({ baseline: "date", update: "date" }),
})
const context: SystemContext.SystemContext = { components: [component, component] }
await expect(Effect.runPromise(SystemContext.load(context))).rejects.toBeInstanceOf(SystemContext.DuplicateKeyError)
})
test("requires namespaced component keys", () => {
const decode = Schema.decodeUnknownSync(SystemContext.Key)
expect(decode("core/date")).toBe(key("core/date"))
expect(() => decode("date")).toThrow()
expect(() => decode("core/")).toThrow()
})
})

View file

@ -0,0 +1,368 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer } from "effect"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool-registry"
import { ApplyPatchTool } from "@opencode-ai/core/tool/apply-patch"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const sessionID = SessionV2.ID.make("ses_apply_patch_tool_test")
const assertions: PermissionV2.AssertInput[] = []
let denyAction: string | undefined
let failRemoveTarget: string | undefined
let readsBeforeEditApproval = 0
let editApproved = false
let blockRemoveTarget: string | undefined
let removeStarted: Deferred.Deferred<void> | undefined
let releaseRemove: Deferred.Deferred<void> | undefined
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => {
assertions.push(input)
if (input.action === "edit") editApproved = true
}).pipe(
Effect.andThen(
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
)
const reset = () => {
assertions.length = 0
denyAction = undefined
failRemoveTarget = undefined
readsBeforeEditApproval = 0
editApproved = false
blockRemoveTarget = undefined
removeStarted = undefined
releaseRemove = undefined
}
const filesystem = Layer.effect(
FSUtil.Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
return FSUtil.Service.of({
...fs,
readFile: (target) =>
Effect.sync(() => {
if (!editApproved) readsBeforeEditApproval++
}).pipe(Effect.andThen(fs.readFile(target))),
remove: (target, options) => {
if (failRemoveTarget && path.basename(target) === failRemoveTarget) return Effect.die("forced remove failure")
if (blockRemoveTarget && path.basename(target) === blockRemoveTarget && removeStarted && releaseRemove)
return Deferred.succeed(removeStarted, undefined).pipe(
Effect.andThen(Deferred.await(releaseRemove)),
Effect.andThen(fs.remove(target, options)),
)
return fs.remove(target, options)
},
})
}),
).pipe(Layer.provide(FSUtil.defaultLayer))
const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) => {
const activeLocation = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
)
const planning = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
const commits = FileMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(planning))
const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
const patch = ApplyPatchTool.layer.pipe(
Layer.provide(registry),
Layer.provide(planning),
Layer.provide(commits),
Layer.provide(filesystem),
)
return Effect.gen(function* () {
return yield* body(yield* ToolRegistry.Service)
}).pipe(Effect.provide(Layer.mergeAll(registry, planning, commits, patch)))
}
const call = (patchText: string, id = "call-apply-patch") => ({
sessionID,
call: { type: "tool-call" as const, id, name: "apply_patch", input: { patchText } },
})
const exists = (target: string) =>
Effect.promise(() =>
fs.stat(target).then(
() => true,
() => false,
),
)
const it = testEffect(Layer.empty)
describe("ApplyPatchTool", () => {
it.live("registers and sequentially applies add, update, and delete hunks", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const update = path.join(tmp.path, "update.txt")
const remove = path.join(tmp.path, "remove.txt")
return Effect.promise(() =>
Promise.all([fs.writeFile(update, "before\n"), fs.writeFile(remove, "remove\n")]),
).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["apply_patch"])
const settled = yield* registry.settle(
call(
"*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Update File: update.txt\n@@\n-before\n+after\n*** Delete File: remove.txt\n*** End Patch",
),
)
expect(settled.result).toEqual({
type: "text",
value: "Applied patch sequentially:\nA nested/new.txt\nM update.txt\nD remove.txt",
})
expect(settled.output?.structured).toMatchObject({
applied: [
{ type: "add", resource: "nested/new.txt" },
{ type: "update", resource: "update.txt" },
{ type: "delete", resource: "remove.txt" },
],
})
expect(assertions).toEqual([
{ sessionID, action: "edit", resources: ["nested/new.txt", "update.txt", "remove.txt"], save: ["*"] },
])
expect(readsBeforeEditApproval).toBe(0)
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "nested/new.txt"), "utf8"))).toBe(
"created\n",
)
expect(yield* Effect.promise(() => fs.readFile(update, "utf8"))).toBe("after\n")
expect(yield* exists(remove)).toBe(false)
}),
),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("rejects moves before applying any hunk", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const source = path.join(tmp.path, "old.txt")
return Effect.promise(() => fs.writeFile(source, "before\n")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect(
yield* registry.execute(
call(
"*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch",
),
),
).toEqual({ type: "error", value: "apply_patch moves are not supported yet" })
expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false)
expect(assertions).toEqual([])
}),
),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("approves an external directory and the batch before reading external update content", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
reset()
const target = path.join(outside.path, "external.txt")
return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
Effect.andThen(
withTool(active.path, (registry) =>
Effect.gen(function* () {
expect(
yield* registry.execute(
call(`*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch`),
),
).toMatchObject({ type: "text" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
expect(readsBeforeEditApproval).toBe(0)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
}),
),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("approves one external directory scope for multiple files under the same parent", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
reset()
const first = path.join(outside.path, "first.txt")
const second = path.join(outside.path, "second.txt")
return Effect.promise(() =>
Promise.all([fs.writeFile(first, "before\n"), fs.writeFile(second, "before\n")]),
).pipe(
Effect.andThen(
withTool(active.path, (registry) =>
Effect.gen(function* () {
expect(
yield* registry.execute(
call(
`*** Begin Patch\n*** Update File: ${first}\n@@\n-before\n+after\n*** Update File: ${second}\n@@\n-before\n+after\n*** End Patch`,
),
),
).toMatchObject({ type: "text" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
expect(assertions[0]?.resources).toEqual([
path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "*").replaceAll("\\", "/"),
])
}),
),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("rejects invalid later update before applying an earlier add", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect(
yield* registry.execute(
call(
"*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: missing.txt\n@@\n-before\n+after\n*** End Patch",
),
),
).toEqual({ type: "error", value: "Unable to apply patch at missing.txt" })
expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false)
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("rejects add hunks targeting an existing file without replacing it", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const target = path.join(tmp.path, "existing.txt")
return Effect.promise(() => fs.writeFile(target, "sentinel\n")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect(
yield* registry.execute(
call("*** Begin Patch\n*** Add File: existing.txt\n+replacement\n*** End Patch"),
),
).toEqual({ type: "error", value: "Unable to apply patch at existing.txt" })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("sentinel\n")
}),
),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("reports earlier sequential applications when a later commit fails", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const first = path.join(tmp.path, "first.txt")
const second = path.join(tmp.path, "second.txt")
failRemoveTarget = path.basename(second)
return Effect.promise(() => Promise.all([fs.writeFile(first, "first"), fs.writeFile(second, "second")])).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect(
yield* registry.execute(
call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"),
),
).toEqual({
type: "error",
value: "Patch partially applied before failing at second.txt. Applied: first.txt",
})
expect(yield* exists(first)).toBe(false)
expect(yield* exists(second)).toBe(true)
}),
),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("finishes the sequential commit phase when interrupted after the first mutation", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const first = path.join(tmp.path, "first.txt")
const second = path.join(tmp.path, "second.txt")
blockRemoveTarget = path.basename(second)
return Effect.gen(function* () {
removeStarted = yield* Deferred.make<void>()
releaseRemove = yield* Deferred.make<void>()
yield* Effect.promise(() => Promise.all([fs.writeFile(first, "first"), fs.writeFile(second, "second")]))
yield* withTool(tmp.path, (registry) =>
Effect.gen(function* () {
const run = yield* registry
.execute(
call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"),
)
.pipe(Effect.forkChild)
yield* Deferred.await(removeStarted!)
const interrupt = yield* Fiber.interrupt(run).pipe(Effect.forkChild)
yield* Deferred.succeed(releaseRemove!, undefined)
yield* Fiber.join(interrupt)
expect(yield* exists(first)).toBe(false)
expect(yield* exists(second)).toBe(false)
}),
)
})
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
})

View file

@ -0,0 +1,406 @@
import fs from "fs/promises"
import { realpathSync } from "node:fs"
import path from "path"
import { describe, expect, test } from "bun:test"
import { Effect, Layer } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Config } from "@opencode-ai/core/config"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AppProcess } from "@opencode-ai/core/process"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { BashTool } from "@opencode-ai/core/tool/bash"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ToolRegistry } from "@opencode-ai/core/tool-registry"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const sessionID = SessionV2.ID.make("ses_bash_tool_test")
const assertions: PermissionV2.AssertInput[] = []
const runs: Array<{
readonly command: string
readonly cwd?: string
readonly shell?: string | boolean
readonly options?: AppProcess.RunOptions
}> = []
const truncations: ToolOutputStore.TruncateInput[] = []
let denyAction: string | undefined
let result: AppProcess.RunResult = {
command: "mock",
exitCode: 0,
stdout: Buffer.from("hello\n"),
stderr: Buffer.alloc(0),
stdoutTruncated: false,
stderrTruncated: false,
}
let runFailure: AppProcess.AppProcessError | undefined
let truncate = (input: ToolOutputStore.TruncateInput): Effect.Effect<ToolOutputStore.TruncateResult> =>
Effect.succeed({ content: input.content, truncated: false })
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
)
const appProcess = Layer.succeed(
AppProcess.Service,
AppProcess.Service.of({
run: (command: ChildProcess.Command, options?: AppProcess.RunOptions) =>
Effect.suspend(() => {
if (command._tag !== "StandardCommand") throw new Error("expected standard command")
runs.push({ command: command.command, cwd: command.options.cwd, shell: command.options.shell, options })
return runFailure ? Effect.fail(runFailure) : Effect.succeed(result)
}),
} as unknown as AppProcess.Interface),
)
const resources = Layer.succeed(
ToolOutputStore.Service,
ToolOutputStore.Service.of({
limits: () => Effect.die("unused"),
write: () => Effect.die("unused"),
truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))),
read: () => Effect.die("unused"),
cleanup: () => Effect.die("unused"),
}),
)
const config = Layer.succeed(
Config.Service,
Config.Service.of({
entries: () => Effect.succeed([]),
}),
)
const reset = () => {
assertions.length = 0
runs.length = 0
truncations.length = 0
denyAction = undefined
runFailure = undefined
result = {
command: "mock",
exitCode: 0,
stdout: Buffer.from("hello\n"),
stderr: Buffer.alloc(0),
stdoutTruncated: false,
stderrTruncated: false,
}
truncate = (input) => Effect.succeed({ content: input.content, truncated: false })
}
const withTool = <A, E, R>(
directory: string,
body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>,
processLayer: Layer.Layer<AppProcess.Service> = appProcess,
) => {
const filesystem = FSUtil.defaultLayer
const activeLocation = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
)
const mutation = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
const bash = BashTool.layer.pipe(
Layer.provide(registry),
Layer.provide(permission),
Layer.provide(mutation),
Layer.provide(processLayer),
Layer.provide(resources),
Layer.provide(config),
)
return Effect.gen(function* () {
return yield* body(yield* ToolRegistry.Service)
}).pipe(Effect.provide(Layer.mergeAll(registry, bash)))
}
const call = (input: typeof BashTool.Parameters.Type, id = "call-bash") => ({
sessionID,
call: { type: "tool-call" as const, id, name: "bash", input },
})
const it = testEffect(Layer.empty)
describe("BashTool", () => {
it.live("registers and returns structured successful output from the active Location", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withTool(tmp.path, (registry) =>
Effect.gen(function* () {
const definitions = yield* registry.definitions()
expect(definitions.map((tool) => tool.name)).toEqual(["bash"])
expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background")
expect(yield* registry.settle(call({ command: "pwd", description: "Print working directory" }))).toEqual({
result: { type: "text", value: "hello\n\n\nCommand exited with code 0." },
output: {
structured: {
command: "pwd",
cwd: realpathSync(tmp.path),
exitCode: 0,
output: "hello\n",
truncated: false,
},
content: [{ type: "text", text: "hello\n\n\nCommand exited with code 0." }],
},
})
expect(runs).toMatchObject([{ command: "pwd", cwd: realpathSync(tmp.path) }])
expect(runs[0]?.options).toMatchObject({
maxOutputBytes: BashTool.MAX_CAPTURE_BYTES,
maxErrorBytes: BashTool.MAX_CAPTURE_BYTES,
})
expect(assertions).toEqual([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }])
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("resolves a relative workdir from the active Location", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return Effect.promise(() => fs.mkdir(path.join(tmp.path, "src"))).pipe(
Effect.andThen(withTool(tmp.path, (registry) => registry.execute(call({ command: "pwd", workdir: "src" })))),
Effect.andThen(
Effect.sync(() => expect(runs).toMatchObject([{ cwd: realpathSync(path.join(tmp.path, "src")) }])),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
if (process.platform !== "win32") {
it.live("executes a real shell command through AppProcess", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withTool(
tmp.path,
(registry) => registry.settle(call({ command: "printf core-bash" })),
AppProcess.defaultLayer,
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.result).toEqual({ type: "text", value: "core-bash\n\nCommand exited with code 0." })
expect(settled.output?.structured).toMatchObject({
command: "printf core-bash",
cwd: realpathSync(tmp.path),
exitCode: 0,
output: "core-bash",
})
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
}
it.live("approves an explicit external workdir before bash execution", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
reset()
return withTool(active.path, (registry) =>
registry.execute(call({ command: "pwd", workdir: outside.path })),
).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "bash"])
expect(assertions[0]).toMatchObject({
resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")],
})
expect(runs).toHaveLength(1)
}),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("does not execute after external-directory or bash denial", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) =>
Effect.gen(function* () {
reset()
denyAction = "external_directory"
yield* withTool(active.path, (registry) => registry.execute(call({ command: "pwd", workdir: outside.path })))
expect(assertions.map((item) => item.action)).toEqual(["external_directory"])
expect(runs).toEqual([])
reset()
denyAction = "bash"
yield* withTool(active.path, (registry) => registry.execute(call({ command: "pwd" })))
expect(assertions.map((item) => item.action)).toEqual(["bash"])
expect(runs).toEqual([])
}),
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("reports external command arguments as advisory warnings without enforcing approval", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
reset()
denyAction = "external_directory"
const target = path.join(outside.path, "secret.txt")
return withTool(active.path, (registry) => registry.settle(call({ command: `cat ${target}` }))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["bash"])
expect(runs).toHaveLength(1)
expect(settled.output?.structured).toMatchObject({
warnings: [
`Command argument references external directory ${path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")}. Bash runs with host-user filesystem, process, and network authority; this scan is advisory only.`,
],
})
expect(settled.result).toMatchObject({ type: "text", value: expect.stringContaining("Warnings:") })
}),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("keeps non-zero exits useful and exposes managed overflow by opaque URI", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
result = { ...result, exitCode: 7, stdout: Buffer.from("HEAD full output TAIL") }
truncate = (input) =>
Effect.succeed({
content: "HEAD\n\n... output truncated; full content available as tool-output://opaque ...\n\nTAIL",
truncated: true,
resource: new ToolOutputStore.Resource({
uri: "tool-output://opaque",
mime: "text/plain",
size: input.content.length,
}),
})
return withTool(tmp.path, (registry) => registry.settle(call({ command: "false" }, "call-overflow"))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.result).toMatchObject({
type: "text",
value: expect.stringContaining("Command exited with code 7"),
})
expect(settled.output?.structured).toMatchObject({
command: "false",
cwd: realpathSync(tmp.path),
exitCode: 7,
truncated: true,
resource: { uri: "tool-output://opaque" },
})
expect(truncations).toMatchObject([
{ sessionID, toolCallID: "call-overflow", content: "HEAD full output TAIL" },
])
expect(JSON.stringify(settled)).not.toContain(tmp.path + path.sep + "tool-output")
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("surfaces bounded process-capture truncation", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
result = { ...result, stdoutTruncated: true }
return withTool(tmp.path, (registry) => registry.settle(call({ command: "verbose" }))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.output?.structured).toMatchObject({ truncated: true, stdoutTruncated: true })
expect(settled.result).toMatchObject({
type: "text",
value: expect.stringContaining("stdout capture truncated"),
})
expect(settled.output?.structured).not.toHaveProperty("resource")
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("returns a useful timeout settlement", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
runFailure = new AppProcess.AppProcessError({ command: "sleep", cause: new Error("Timed out") })
return withTool(tmp.path, (registry) => registry.settle(call({ command: "sleep 60", timeout: 10 }))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.result).toMatchObject({
type: "text",
value: expect.stringContaining("Command timed out"),
})
expect(settled.output?.structured).toMatchObject({
command: "sleep 60",
timedOut: true,
truncated: false,
})
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
})
test("keeps locked deferred parity TODOs visible", async () => {
const source = await fs.readFile(new URL("../src/tool/bash.ts", import.meta.url), "utf8")
for (const todo of [
"Port tree-sitter bash / PowerShell parser-based approval reduction.",
"Port BashArity reusable command-prefix approvals.",
"Replace token-based command-argument external-directory advisories with parser-based detection.",
"Restore PowerShell and cmd-specific invocation/path handling on Windows.",
"Add plugin shell.env environment augmentation once V2 plugin hooks exist.",
"Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired.",
"Persist background job status and define restart recovery before exposing remote observation.",
"Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.",
"Revisit binary output handling if stdout/stderr decoding is text-only.",
"Stream full shell output into managed storage while retaining only a bounded in-memory preview.",
]) {
expect(source).toContain(`TODO: ${todo}`)
}
})

View file

@ -0,0 +1,458 @@
import fs from "fs/promises"
import path from "path"
import { fileURLToPath } from "url"
import { describe, expect, test } from "bun:test"
import { Effect, Layer } from "effect"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool-registry"
import { EditTool } from "@opencode-ai/core/tool/edit"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const sessionID = SessionV2.ID.make("ses_edit_tool_test")
const assertions: PermissionV2.AssertInput[] = []
const writes: string[] = []
let reads = 0
let denyAction: string | undefined
let afterAssertion = (_input: PermissionV2.AssertInput): Effect.Effect<void> => Effect.void
let afterRead = (_target: string, _content: Uint8Array): Effect.Effect<void> => Effect.void
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(
input.action === denyAction
? Effect.fail(new PermissionV2.DeniedError({ rules: [] }))
: afterAssertion(input),
),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
)
const reset = () => {
assertions.length = 0
writes.length = 0
reads = 0
denyAction = undefined
afterAssertion = () => Effect.void
afterRead = () => Effect.void
}
const filesystem = Layer.effect(
FSUtil.Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
return FSUtil.Service.of({
...fs,
readFile: (target) =>
fs
.readFile(target)
.pipe(
Effect.tap((content) =>
Effect.sync(() => reads++).pipe(Effect.andThen(Effect.suspend(() => afterRead(target, content)))),
),
),
writeWithDirs: (target, content, mode) =>
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeWithDirs(target, content, mode))),
})
}),
).pipe(Layer.provide(FSUtil.defaultLayer))
const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) => {
const activeLocation = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
)
const planning = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
const commits = FileMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(planning))
const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
const edit = EditTool.layer.pipe(
Layer.provide(registry),
Layer.provide(planning),
Layer.provide(commits),
Layer.provide(filesystem),
)
return Effect.gen(function* () {
return yield* body(yield* ToolRegistry.Service)
}).pipe(Effect.provide(Layer.mergeAll(registry, planning, commits, edit)))
}
const call = (input: typeof EditTool.Parameters.Type, id = "call-edit") => ({
sessionID,
call: { type: "tool-call" as const, id, name: "edit", input },
})
const it = testEffect(Layer.empty)
describe("EditTool", () => {
it.live("registers and replaces relative exact text through FileMutation once", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const target = path.join(tmp.path, "hello.txt")
return Effect.promise(() => fs.writeFile(target, "before\nrest\n")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["edit"])
const settled = yield* registry.settle(
call({ path: "hello.txt", oldString: "before", newString: "after" }),
)
expect(settled.result).toEqual({
type: "text",
value: "Edited file successfully: hello.txt\nReplacements: 1\n```diff\n-before\n+after\n```",
})
expect(settled.output?.structured).toEqual({
operation: "write",
target: yield* Effect.promise(() => fs.realpath(target)),
resource: "hello.txt",
existed: true,
replacements: 1,
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n")
expect(assertions).toEqual([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }])
expect(writes).toEqual([yield* Effect.promise(() => fs.realpath(target))])
}),
),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("accepts an absolute file path inside the active Location", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const target = path.join(tmp.path, "absolute.txt")
return Effect.promise(() => fs.writeFile(target, "before")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
registry.execute(call({ path: target, oldString: "before", newString: "after" })),
),
),
Effect.andThen((result) =>
Effect.gen(function* () {
expect(result.type).toBe("text")
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after")
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("approves an explicit external absolute path before edit", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
reset()
const target = path.join(outside.path, "external.txt")
return Effect.promise(() => fs.writeFile(target, "before")).pipe(
Effect.andThen(
withTool(active.path, (registry) =>
registry.execute(call({ path: target, oldString: "before", newString: "after" })),
),
),
Effect.andThen((result) =>
Effect.gen(function* () {
expect(result.type).toBe("text")
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after")
expect(writes).toHaveLength(1)
}),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("does not write when external_directory or edit approval is denied", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) =>
Effect.gen(function* () {
const external = path.join(outside.path, "denied.txt")
yield* Effect.promise(() => fs.writeFile(external, "before"))
reset()
denyAction = "external_directory"
expect(
yield* withTool(active.path, (registry) =>
registry.execute(call({ path: external, oldString: "before", newString: "after" })),
),
).toEqual({
type: "error",
value: `Unable to edit ${external}`,
})
expect(assertions.map((input) => input.action)).toEqual(["external_directory"])
expect(reads).toBe(0)
expect(writes).toEqual([])
reset()
denyAction = "edit"
expect(
yield* withTool(active.path, (registry) =>
registry.execute(call({ path: external, oldString: "before", newString: "after" })),
),
).toEqual({
type: "error",
value: `Unable to edit ${external}`,
})
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
expect(reads).toBe(0)
expect(writes).toEqual([])
expect(yield* Effect.promise(() => fs.readFile(external, "utf8"))).toBe("before")
}),
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("denied edit reads no target content and does not disclose whether oldString matches", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
denyAction = "edit"
const target = path.join(tmp.path, "secret.txt")
return Effect.promise(() => fs.writeFile(target, "secret content")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
const matching = yield* registry.execute(
call({ path: "secret.txt", oldString: "secret content", newString: "replacement" }),
)
const missing = yield* registry.execute(
call({ path: "secret.txt", oldString: "not present", newString: "replacement" }),
)
expect(matching).toEqual({ type: "error", value: "Unable to edit secret.txt" })
expect(missing).toEqual(matching)
expect(assertions.map((input) => input.action)).toEqual(["edit", "edit"])
expect(reads).toBe(0)
expect(writes).toEqual([])
}),
),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("rejects no-op, empty, missing, and ambiguous exact replacements", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const target = path.join(tmp.path, "matches.txt")
return Effect.promise(() => fs.writeFile(target, "same same")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect(
yield* registry.execute(call({ path: "matches.txt", oldString: "same", newString: "same" })),
).toEqual({
type: "error",
value: "No changes to apply: oldString and newString are identical.",
})
expect(
yield* registry.execute(call({ path: "matches.txt", oldString: "", newString: "after" })),
).toEqual({
type: "error",
value: "oldString must not be empty. Use write to create or overwrite a file.",
})
expect(
yield* registry.execute(call({ path: "matches.txt", oldString: "missing", newString: "after" })),
).toEqual({
type: "error",
value:
"Could not find oldString in the file. It must match exactly, including whitespace and indentation.",
})
expect(
yield* registry.execute(call({ path: "matches.txt", oldString: "same", newString: "after" })),
).toEqual({
type: "error",
value:
"Found multiple exact matches for oldString. Provide more surrounding context or set replaceAll to true.",
})
expect(writes).toEqual([])
}),
),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("replaces every exact occurrence when replaceAll is true", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const target = path.join(tmp.path, "all.txt")
return Effect.promise(() => fs.writeFile(target, "same same same")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
registry.settle(call({ path: "all.txt", oldString: "same", newString: "after", replaceAll: true })),
),
),
Effect.andThen((settled) =>
Effect.gen(function* () {
expect(settled.output?.structured).toMatchObject({ replacements: 3 })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after after after")
expect(writes).toHaveLength(1)
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("preserves BOM and CRLF line endings", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const target = path.join(tmp.path, "windows.txt")
return Effect.promise(() => fs.writeFile(target, "\uFEFFbefore\r\nrest\r\n")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
registry.execute(call({ path: "windows.txt", oldString: "before\nrest", newString: "after\nrest" })),
),
),
Effect.andThen(() => Effect.promise(() => fs.readFile(target, "utf8"))),
Effect.tap((content) => Effect.sync(() => expect(content).toBe("\uFEFFafter\r\nrest\r\n"))),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("rejects an in-place content change after matching but before conditional commit", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const target = path.join(tmp.path, "concurrent.txt")
afterRead = () => (reads === 1 ? Effect.promise(() => fs.writeFile(target, "newer\n")) : Effect.void)
return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
registry.execute(call({ path: "concurrent.txt", oldString: "before", newString: "after" })),
),
),
Effect.andThen((result) =>
Effect.gen(function* () {
expect(result).toEqual({
type: "error",
value: "File changed after permission approval. Read it again before editing.",
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("newer\n")
expect(writes).toEqual([])
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
if (process.platform !== "win32") {
it.live("delegates post-approval revalidation to FileMutation before writing", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
reset()
const parent = path.join(active.path, "parent")
const detached = path.join(active.path, "detached")
afterAssertion = (input) =>
input.action === "edit"
? Effect.promise(async () => {
await fs.rename(parent, detached)
await fs.symlink(outside.path, parent)
})
: Effect.void
return Effect.promise(async () => {
await fs.mkdir(parent)
await fs.writeFile(path.join(parent, "escape.txt"), "before")
}).pipe(
Effect.andThen(
withTool(active.path, (registry) =>
registry.execute(call({ path: "parent/escape.txt", oldString: "before", newString: "after" })),
),
),
Effect.andThen((result) =>
Effect.gen(function* () {
expect(result).toEqual({ type: "error", value: "Unable to edit parent/escape.txt" })
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(writes).toEqual([])
expect(
yield* Effect.promise(() =>
fs.stat(path.join(outside.path, "escape.txt")).then(
() => true,
() => false,
),
),
).toBe(false)
}),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
}
})
test("keeps the locked edit schema, semantics docstring, and deferred TODOs visible", async () => {
const source = (await fs.readFile(new URL("../src/tool/edit.ts", import.meta.url), "utf8")).replaceAll("\r\n", "\n")
const definition = await Effect.runPromise(
withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => registry.definitions()),
)
const schema = definition[0]?.inputSchema as { readonly properties?: Record<string, unknown> }
expect(Object.keys(schema.properties ?? {}).sort()).toEqual(["newString", "oldString", "path", "replaceAll"])
expect(source).toContain(
"Named project references\n * are read-oriented and deliberately are not accepted by mutation tools.",
)
for (const todo of [
"Port V1 fuzzy correction strategies only after exact-edit behavior is established: line-trimmed matching, block-anchor fallback, indentation correction, and similarity-threshold review.",
"Add formatter integration after V2 formatter runtime exists.",
"Publish watcher/file-edit events after V2 watcher integration exists.",
"Add snapshots / undo after design exists.",
"Add LSP notification and diagnostics after V2 LSP runtime exists.",
]) {
expect(source).toContain(`TODO: ${todo}`)
}
})

View file

@ -0,0 +1,231 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { LocationSearch } from "@opencode-ai/core/location-search"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { RelativePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { GlobTool } from "@opencode-ai/core/tool/glob"
import { ToolRegistry } from "@opencode-ai/core/tool-registry"
import { testEffect } from "./lib/effect"
const sessionID = SessionV2.ID.make("ses_glob_tool_test")
const assertions: PermissionV2.AssertInput[] = []
const resolutions: FileSystem.ListInput[] = []
const searches: LocationSearch.FilesInput[] = []
const roots: FileSystem.RootTarget[] = []
let allow = true
let result = new LocationSearch.FilesResult({ items: [], truncated: false, partial: false })
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.DeniedError({ rules: [] }))),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
)
const filesystem = Layer.succeed(
FileSystem.Service,
FileSystem.Service.of({
read: () => Effect.die("unused"),
resolveReadPath: () => Effect.die("unused"),
resolveRead: () => Effect.die("unused"),
readResolved: () => Effect.die("unused"),
readTextPageResolved: () => Effect.die("unused"),
list: () => Effect.die("unused"),
resolveRoot: (input = {}) =>
Effect.sync(() => {
resolutions.push(input)
const relative = input.path ?? RelativePath.make(".")
const resource = input.reference === undefined ? relative : `${input.reference}:${relative}`
return new FileSystem.RootTarget({
absolute: `/project/${relative}`,
real: `/project/${relative}`,
directory: "/project",
root: "/project",
resource,
reference: input.reference,
type: "directory",
dev: 1,
})
}),
revalidateRoot: Effect.succeed,
resolveList: () => Effect.die("unused"),
listResolved: () => Effect.die("unused"),
listPage: () => Effect.die("unused"),
listPageResolved: () => Effect.die("unused"),
find: () => Effect.die("unused"),
grep: () => Effect.die("unused"),
isIgnored: () => false,
}),
)
const search = Layer.succeed(
LocationSearch.Service,
LocationSearch.Service.of({
files: (input, root) =>
Effect.sync(() => {
searches.push(input)
if (root) roots.push(root)
return result
}),
grep: () => Effect.die("unused"),
}),
)
const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
const glob = GlobTool.layer.pipe(
Layer.provide(registry),
Layer.provide(permission),
Layer.provide(filesystem),
Layer.provide(search),
)
const it = testEffect(Layer.mergeAll(registry, permission, filesystem, search, glob))
const reset = () => {
assertions.length = 0
resolutions.length = 0
searches.length = 0
roots.length = 0
allow = true
result = new LocationSearch.FilesResult({ items: [], truncated: false, partial: false })
}
const call = (input: typeof GlobTool.Parameters.Type, id = "call-glob") => ({
sessionID,
call: { type: "tool-call" as const, id, name: "glob", input },
})
describe("GlobTool", () => {
it.effect("registers the glob definition", () =>
Effect.gen(function* () {
reset()
expect((yield* (yield* ToolRegistry.Service).definitions()).map((tool) => tool.name)).toEqual(["glob"])
}),
)
it.effect("authorizes the active Location pattern and delegates traversal only to LocationSearch.files", () =>
Effect.gen(function* () {
reset()
const registry = yield* ToolRegistry.Service
expect(yield* registry.execute(call({ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 }))).toEqual({
type: "text",
value: "No files found",
})
expect(assertions).toEqual([
{
sessionID,
action: "glob",
resources: ["**/*.ts"],
save: ["*"],
metadata: { root: "src", reference: undefined, path: "src", limit: 12 },
},
])
expect(resolutions).toEqual([{ path: RelativePath.make("src"), reference: undefined }])
expect(searches).toEqual([{ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 }])
expect(roots).toMatchObject([{ resource: "src" }])
}),
)
it.effect("prevents Location search when permission is denied", () =>
Effect.gen(function* () {
reset()
allow = false
expect(yield* (yield* ToolRegistry.Service).execute(call({ pattern: "*.secret" }))).toEqual({
type: "error",
value: "Unable to find files matching *.secret",
})
expect(searches).toEqual([])
}),
)
it.effect("returns active Location glob resources", () =>
Effect.gen(function* () {
reset()
result = new LocationSearch.FilesResult({
items: [
new LocationSearch.File({
path: RelativePath.make("src/index.ts"),
canonical: "/project/src/index.ts",
resource: "src/index.ts",
mtime: 1,
}),
],
truncated: false,
partial: false,
})
expect(yield* (yield* ToolRegistry.Service).settle(call({ pattern: "*.ts" }))).toEqual({
result: { type: "text", value: "src/index.ts" },
output: {
structured: result,
content: [{ type: "text", text: "src/index.ts" }],
},
})
}),
)
it.effect("searches named references with root and reference metadata", () =>
Effect.gen(function* () {
reset()
result = new LocationSearch.FilesResult({
items: [
new LocationSearch.File({
path: RelativePath.make("guide.md"),
canonical: "/project/docs/guide.md",
resource: "docs:guide.md",
mtime: 1,
}),
],
truncated: false,
partial: false,
})
expect(yield* (yield* ToolRegistry.Service).execute(call({ pattern: "*.md", reference: "docs" }))).toEqual({
type: "text",
value: "docs:guide.md",
})
expect(assertions).toEqual([
{
sessionID,
action: "glob",
resources: ["*.md"],
save: ["*"],
metadata: { root: "docs:.", reference: "docs", path: undefined, limit: undefined },
},
])
expect(searches).toEqual([{ pattern: "*.md", reference: "docs" }])
}),
)
it.effect("formats bounded and partial results without discarding structured output", () =>
Effect.sync(() => {
const output = new LocationSearch.FilesResult({
items: [
new LocationSearch.File({
path: RelativePath.make("one.ts"),
canonical: "/project/one.ts",
resource: "one.ts",
mtime: 1,
}),
],
truncated: true,
partial: true,
})
expect(GlobTool.toModelOutput(output)).toBe(
"one.ts\n\n(Results are truncated: showing first 1 results. Consider using a more specific path or pattern.)\n\n(Results may be incomplete because some discovered files could not be read.)",
)
}),
)
})

View file

@ -0,0 +1,286 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Ripgrep as FileSystemRipgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { LocationSearch } from "@opencode-ai/core/location-search"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AppProcess } from "@opencode-ai/core/process"
import { ProjectReference } from "@opencode-ai/core/project-reference"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { GrepTool } from "@opencode-ai/core/tool/grep"
import { ToolRegistry } from "@opencode-ai/core/tool-registry"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { it as runtimeIt } from "./lib/effect"
import { testEffect } from "./lib/effect"
const assertions: PermissionV2.AssertInput[] = []
const searches: LocationSearch.GrepInput[] = []
const roots: FileSystem.RootTarget[] = []
let allow = true
let result = new LocationSearch.GrepResult({ items: [], truncated: false, partial: false })
let searchFailure: Ripgrep.InvalidPatternError | undefined
const filesystem = Layer.succeed(
FileSystem.Service,
FileSystem.Service.of({
read: () => Effect.die("unused"),
resolveReadPath: () => Effect.die("unused"),
resolveRead: () => Effect.die("unused"),
readResolved: () => Effect.die("unused"),
readTextPageResolved: () => Effect.die("unused"),
list: () => Effect.die("unused"),
resolveRoot: (input = {}) =>
Effect.succeed(
new FileSystem.RootTarget({
absolute: `/project/${input.path ?? "."}`,
real: `/project/${input.path ?? "."}`,
directory: "/project",
root: "/project",
resource: input.reference === undefined ? (input.path ?? ".") : `${input.reference}:${input.path ?? "."}`,
reference: input.reference,
type: "directory",
dev: 1,
}),
),
revalidateRoot: Effect.succeed,
resolveList: () => Effect.die("unused"),
listResolved: () => Effect.die("unused"),
listPage: () => Effect.die("unused"),
listPageResolved: () => Effect.die("unused"),
find: () => Effect.die("unused"),
grep: () => Effect.die("unused"),
isIgnored: () => false,
}),
)
const search = Layer.succeed(
LocationSearch.Service,
LocationSearch.Service.of({
files: () => Effect.die("unused"),
grep: (input, root) =>
Effect.sync(() => {
searches.push(input)
if (root) roots.push(root)
if (searchFailure) throw searchFailure
return result
}),
}),
)
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => {
assertions.push(input)
}).pipe(Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.DeniedError({ rules: [] })))),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
)
const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
const grep = GrepTool.layer.pipe(
Layer.provide(registry),
Layer.provide(filesystem),
Layer.provide(search),
Layer.provide(permission),
)
const it = testEffect(Layer.mergeAll(registry, filesystem, search, permission, grep))
const sessionID = SessionV2.ID.make("ses_grep_tool_test")
const execute = (input: Record<string, unknown>) =>
ToolRegistry.Service.use((registry) =>
registry.execute({ sessionID, call: { type: "tool-call", id: "call-grep", name: "grep", input } }),
)
const settle = (input: Record<string, unknown>) =>
ToolRegistry.Service.use((registry) =>
registry.settle({ sessionID, call: { type: "tool-call", id: "call-grep", name: "grep", input } }),
)
const reset = () => {
assertions.length = 0
searches.length = 0
roots.length = 0
allow = true
searchFailure = undefined
result = new LocationSearch.GrepResult({ items: [], truncated: false, partial: false })
}
function references(entries: Record<string, ProjectReference.Resolved>) {
return ProjectReference.Service.of({
list: () => Effect.succeed(Object.values(entries)),
get: (name) => Effect.succeed(entries[name]),
resolveMention: () => Effect.succeed(undefined),
ensurePath: () => Effect.void,
containsManagedPath: () => Effect.succeed(false),
})
}
function provideLive(directory: string, projectReferences = references({})) {
const dependencies = Layer.mergeAll(
FSUtil.defaultLayer,
FileSystemRipgrep.defaultLayer,
AppProcess.defaultLayer,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
Layer.succeed(ProjectReference.Service, projectReferences),
)
const filesystem = FileSystem.layer.pipe(Layer.provide(dependencies))
const search = LocationSearch.layer.pipe(
Layer.provide(filesystem),
Layer.provide(Ripgrep.layer.pipe(Layer.provide(dependencies))),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(dependencies),
)
const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
const grep = GrepTool.layer.pipe(
Layer.provide(registry),
Layer.provide(filesystem),
Layer.provide(search),
Layer.provide(permission),
)
return Layer.mergeAll(registry, filesystem, search, permission, grep)
}
describe("GrepTool", () => {
it.effect("registers the grep contribution", () =>
Effect.gen(function* () {
reset()
expect(yield* (yield* ToolRegistry.Service).definitions()).toMatchObject([{ name: "grep" }])
}),
)
it.effect("authorizes the regex resource and delegates an active Location grep", () =>
Effect.gen(function* () {
reset()
const input = { pattern: "needle", path: "src", include: "*.ts", limit: 2 }
expect(yield* execute(input)).toEqual({ type: "text", value: "No files found" })
expect(assertions).toEqual([
{
sessionID,
action: "grep",
resources: ["needle"],
save: ["*"],
metadata: { root: "src", reference: undefined, path: RelativePath.make("src"), include: "*.ts", limit: 2 },
},
])
expect(searches).toEqual([{ pattern: "needle", path: RelativePath.make("src"), include: "*.ts", limit: 2 }])
expect(roots).toMatchObject([{ resource: "src" }])
}),
)
it.effect("delegates named reference grep and exposes the canonical selected root in metadata", () =>
Effect.gen(function* () {
reset()
yield* execute({ pattern: "guide", path: "docs", reference: "manual", include: "*.md" })
expect(assertions[0]).toMatchObject({
resources: ["guide"],
metadata: { root: "manual:docs", reference: "manual", path: RelativePath.make("docs"), include: "*.md" },
})
expect(searches).toEqual([
{ pattern: "guide", path: RelativePath.make("docs"), reference: "manual", include: "*.md" },
])
}),
)
it.effect("does not search when permission is denied", () =>
Effect.gen(function* () {
reset()
allow = false
expect(yield* execute({ pattern: "secret" })).toEqual({ type: "error", value: "Unable to grep for secret" })
expect(assertions).toHaveLength(1)
expect(searches).toEqual([])
}),
)
it.effect("keeps structured results raw while formatting bounded partial previews for models", () =>
Effect.gen(function* () {
reset()
result = new LocationSearch.GrepResult({
items: [
new LocationSearch.Match({
path: RelativePath.make("src/index.ts"),
canonical: "/project/src/index.ts",
resource: "src/index.ts",
lines: "needle preview",
linePreviewTruncated: true,
line: 3,
offset: 8,
submatches: [new LocationSearch.Submatch({ text: "needle", start: 0, end: 6 })],
mtime: 1,
}),
],
truncated: true,
partial: true,
})
const settlement = yield* settle({ pattern: "needle" })
expect(settlement.output?.structured).toEqual(result)
expect(settlement.result).toEqual({
type: "text",
value:
"Found 1 matches\nsrc/index.ts:\n Line 3: needle preview...\n\n(Results are truncated: showing first 1 matches. Consider using a more specific path or pattern.)\n\n(Some paths were inaccessible and skipped)",
})
}),
)
it.effect("returns a useful tool error for an invalid regex", () =>
Effect.gen(function* () {
reset()
searchFailure = new Ripgrep.InvalidPatternError({
pattern: "[",
message: "regex parse error: unclosed character class",
})
expect(yield* execute({ pattern: "[" })).toEqual({
type: "error",
value: 'Invalid grep pattern "[": regex parse error: unclosed character class',
})
expect(searches).toEqual([{ pattern: "[" }])
}),
)
runtimeIt.live("greps active Location and named-reference files with include globs", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const docs = path.join(tmp.path, "docs")
return Effect.gen(function* () {
reset()
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "src"))
await fs.mkdir(docs)
await fs.writeFile(path.join(tmp.path, "src", "index.ts"), "needle ts\n")
await fs.writeFile(path.join(tmp.path, "src", "notes.txt"), "needle txt\n")
await fs.writeFile(path.join(docs, "guide.md"), "needle docs\n")
})
expect(yield* execute({ pattern: "needle", path: "src", include: "*.ts" })).toEqual({
type: "text",
value: "Found 1 matches\nsrc/index.ts:\n Line 1: needle ts\n",
})
expect(yield* execute({ pattern: "needle", reference: "docs", include: "*.md" })).toEqual({
type: "text",
value: "Found 1 matches\ndocs:guide.md:\n Line 1: needle docs\n",
})
}).pipe(
Effect.provide(provideLive(tmp.path, references({ docs: { name: "docs", kind: "local", path: docs } }))),
)
}),
),
)
})

View file

@ -0,0 +1,265 @@
import { describe, expect } from "bun:test"
import path from "path"
import { Effect, Layer } from "effect"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { Config } from "@opencode-ai/core/config"
import { ConfigToolOutput } from "@opencode-ai/core/config/tool-output"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { testEffect } from "./lib/effect"
import { tmpdir } from "./fixture/tmpdir"
const sessionID = SessionV2.ID.make("ses_tool_output_store")
const otherSessionID = SessionV2.ID.make("ses_tool_output_store_other")
const withStore = <A, E, R>(
body: (input: { root: string; store: ToolOutputStore.Interface; fs: FSUtil.Interface }) => Effect.Effect<A, E, R>,
config?: Config.Info,
) =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
const global = Global.layerWith({ data: tmp.path })
const configured = config
? Layer.succeed(
Config.Service,
Config.Service.of({
entries: () => Effect.succeed([new Config.Document({ type: "document", info: config })]),
}),
)
: Layer.empty
const store = ToolOutputStore.layer.pipe(
Layer.provide(FSUtil.defaultLayer),
Layer.provide(global),
Layer.provide(configured),
)
return Effect.gen(function* () {
return yield* body({ root: tmp.path, store: yield* ToolOutputStore.Service, fs: yield* FSUtil.Service })
}).pipe(Effect.provide(Layer.mergeAll(store, FSUtil.defaultLayer)))
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const it = testEffect(Layer.empty)
describe("ToolOutputStore", () => {
it.live("returns under-limit text unchanged without writing a resource", () =>
withStore(({ store }) =>
Effect.gen(function* () {
expect(yield* store.truncate({ sessionID, toolCallID: "call-short", content: "line one\nline two" })).toEqual({
content: "line one\nline two",
truncated: false,
})
}),
),
)
it.live("stores byte-truncated output and returns an opaque head-tail preview", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const content = "HEAD-" + "x".repeat(100) + "-TAIL"
const result = yield* store.truncate({ sessionID, toolCallID: "call-bytes", content, maxBytes: 20 })
expect(result.truncated).toBe(true)
if (!result.truncated) throw new Error("expected truncation")
expect(result.content).toContain("HEAD-")
expect(result.content).toContain("-TAIL")
expect(result.content).toContain("output truncated")
expect(result.resource.uri).toMatch(/^tool-output:\/\/[0-9A-Za-z]+$/)
expect(result.resource.uri.slice("tool-output://".length)).not.toContain("/")
expect(result.resource.uri).not.toContain("\\")
expect(result.resource).toMatchObject({ mime: "text/plain", size: Buffer.byteLength(content) })
expect((yield* store.read({ sessionID, uri: result.resource.uri })).content).toBe(content)
}),
),
)
it.live("stores line-truncated output and keeps both ends in the preview", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const content = Array.from({ length: 10 }, (_, index) => `line-${index}`).join("\n")
const result = yield* store.truncate({ sessionID, toolCallID: "call-lines", content, maxLines: 4 })
expect(result.truncated).toBe(true)
if (!result.truncated) throw new Error("expected truncation")
expect(result.content).toContain("line-0\nline-1")
expect(result.content).toContain("line-8\nline-9")
expect(result.content).not.toContain("line-4")
}),
),
)
it.live("keeps one-line previews bounded", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const result = yield* store.truncate({
sessionID,
toolCallID: "call-one-line",
content: "one\ntwo\nthree",
maxLines: 1,
})
expect(result.truncated).toBe(true)
if (!result.truncated) throw new Error("expected truncation")
const preview = result.content.split("\n\n... output truncated")[0]
expect(preview).toBe("one")
}),
),
)
it.live("pages reads within the bounded managed-resource limit", () =>
withStore(({ root, store, fs }) =>
Effect.gen(function* () {
const resource = yield* store.write({
sessionID,
toolCallID: "call-page",
content: "0123456789",
name: "out.txt",
})
const first = yield* store.read({ sessionID, uri: resource.uri, limit: 4 })
const second = yield* store.read({ sessionID, uri: resource.uri, offset: first.next, limit: 4 })
const last = yield* store.read({ sessionID, uri: resource.uri, offset: second.next, limit: 4 })
expect(first).toMatchObject({ content: "0123", offset: 0, truncated: true, next: 4 })
expect(second).toMatchObject({ content: "4567", offset: 4, truncated: true, next: 8 })
expect(last).toMatchObject({ content: "89", offset: 8, truncated: false })
expect(last.resource).toEqual({ uri: resource.uri, mime: "text/plain", name: "out.txt", size: 10 })
expect(
JSON.parse(
yield* fs.readFileString(
path.join(root, "tool-output", "managed", `${resource.uri.slice("tool-output://".length)}.json`),
),
),
).toMatchObject({
sessionID,
toolCallID: "call-page",
})
const bounded = yield* store.read({
sessionID,
uri: (yield* store.write({
sessionID,
toolCallID: "call-bounded",
content: "x".repeat(ToolOutputStore.MAX_READ_BYTES + 10),
})).uri,
limit: ToolOutputStore.MAX_READ_BYTES + 10,
})
expect(Buffer.byteLength(bounded.content)).toBe(ToolOutputStore.MAX_READ_BYTES)
expect(bounded).toMatchObject({ truncated: true, next: ToolOutputStore.MAX_READ_BYTES })
}),
),
)
it.live("allows the owning session and denies cross-session reads", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const resource = yield* store.write({ sessionID, toolCallID: "call-owned", content: "owned" })
expect((yield* store.read({ sessionID, uri: resource.uri })).content).toBe("owned")
expect(yield* Effect.flip(store.read({ sessionID: otherSessionID, uri: resource.uri }))).toBeInstanceOf(
ToolOutputStore.AccessDeniedError,
)
}),
),
)
it.live("rejects resources whose payload size no longer matches metadata", () =>
withStore(({ root, store, fs }) =>
Effect.gen(function* () {
const resource = yield* store.write({ sessionID, toolCallID: "call-modified", content: "original" })
const id = resource.uri.slice("tool-output://".length)
yield* fs.writeFileString(path.join(root, "tool-output", "managed", `${id}.txt`), "changed payload")
expect(yield* Effect.flip(store.read({ sessionID, uri: resource.uri }))).toBeInstanceOf(
ToolOutputStore.ResourceNotFoundError,
)
}),
),
)
it.live("honors configured truncation limits", () =>
withStore(
({ store }) =>
Effect.gen(function* () {
expect(yield* store.limits()).toEqual({ maxLines: 2, maxBytes: 1_000 })
expect(
(yield* store.truncate({ sessionID, toolCallID: "call-config", content: "one\ntwo\nthree" })).truncated,
).toBe(true)
}),
new Config.Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
),
)
it.live("cleans old managed resources while preserving recent and unrelated files", () =>
withStore(({ root, store, fs }) =>
Effect.gen(function* () {
const old = yield* store.write({ sessionID, toolCallID: "call-old", content: "old" })
const recent = yield* store.write({ sessionID, toolCallID: "call-recent", content: "recent" })
const directory = path.join(root, "tool-output", "managed")
const oldID = old.uri.slice("tool-output://".length)
const recentID = recent.uri.slice("tool-output://".length)
const oldMetadata = path.join(directory, `${oldID}.json`)
const unrelated = path.join(root, "tool-output", "unrelated.txt")
const unrelatedManaged = path.join(directory, "unrelated.txt")
const record = JSON.parse(yield* fs.readFileString(oldMetadata))
yield* fs.writeFileString(
oldMetadata,
JSON.stringify({ ...record, created: Date.now() - 8 * 24 * 60 * 60 * 1_000 }),
)
yield* fs.writeFileString(unrelated, "keep")
yield* fs.writeFileString(unrelatedManaged, "keep")
yield* store.cleanup()
expect(yield* fs.exists(path.join(directory, `${oldID}.txt`))).toBe(false)
expect(yield* fs.exists(oldMetadata)).toBe(false)
expect(yield* fs.exists(path.join(directory, `${recentID}.txt`))).toBe(true)
expect(yield* fs.exists(unrelated)).toBe(true)
expect(yield* fs.exists(unrelatedManaged)).toBe(true)
}),
),
)
it.live("cleans stale generated orphan payloads and malformed pairs", () =>
withStore(({ root, store, fs }) =>
Effect.gen(function* () {
const directory = path.join(root, "tool-output", "managed")
yield* fs.ensureDir(directory)
const orphanID = "00000000000000000000000000"
const malformedID = "00000000000000000000000001"
const orphan = path.join(directory, `${orphanID}.txt`)
const malformedPayload = path.join(directory, `${malformedID}.txt`)
const malformedMetadata = path.join(directory, `${malformedID}.json`)
yield* fs.writeFileString(orphan, "orphan")
yield* fs.writeFileString(malformedPayload, "malformed")
yield* fs.writeFileString(malformedMetadata, "not json")
const old = new Date(Date.now() - 8 * 24 * 60 * 60 * 1_000)
yield* Effect.all([fs.utimes(orphan, old, old), fs.utimes(malformedPayload, old, old)])
yield* store.cleanup()
expect(yield* fs.exists(orphan)).toBe(false)
expect(yield* fs.exists(malformedPayload)).toBe(false)
expect(yield* fs.exists(malformedMetadata)).toBe(false)
}),
),
)
it.live("cleans managed resources whose payload size no longer matches metadata", () =>
withStore(({ root, store, fs }) =>
Effect.gen(function* () {
const resource = yield* store.write({ sessionID, toolCallID: "call-modified", content: "original" })
const directory = path.join(root, "tool-output", "managed")
const id = resource.uri.slice("tool-output://".length)
const payload = path.join(directory, `${id}.txt`)
const metadata = path.join(directory, `${id}.json`)
yield* fs.writeFileString(payload, "changed payload")
yield* store.cleanup()
expect(yield* fs.exists(payload)).toBe(false)
expect(yield* fs.exists(metadata)).toBe(false)
}),
),
)
})

View file

@ -0,0 +1,119 @@
import { describe, expect } from "bun:test"
import { Effect, Exit, Fiber, Layer } from "effect"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { QuestionV2 } from "@opencode-ai/core/question"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool-registry"
import { QuestionTool } from "@opencode-ai/core/tool/question"
import { testEffect } from "./lib/effect"
const sessionID = SessionV2.ID.make("ses_question_tool_test")
const assertions: PermissionV2.AssertInput[] = []
let captured: QuestionV2.AskInput | undefined
let reject = false
const capturedInput = () => captured
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
assert: (input) => Effect.sync(() => assertions.push(input)),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
)
const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
const question = Layer.succeed(
QuestionV2.Service,
QuestionV2.Service.of({
ask: (input: QuestionV2.AskInput) =>
Effect.sync(() => {
captured = input
}).pipe(Effect.andThen(reject ? Effect.fail(new QuestionV2.RejectedError()) : Effect.succeed([["Build"], []]))),
reply: () => Effect.die("unused"),
reject: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
)
const tool = QuestionTool.layer.pipe(Layer.provide(registry), Layer.provide(question))
const it = testEffect(Layer.mergeAll(permission, registry, question, tool))
describe("QuestionTool", () => {
it.effect("registers question and projects user answers without a permission assertion", () =>
Effect.gen(function* () {
assertions.length = 0
captured = undefined
reject = false
const registry = yield* ToolRegistry.Service
const questions = [
{
question: "What should happen?",
header: "Action",
options: [{ label: "Build", description: "Build it" }],
},
{
question: "Which environment?",
header: "Environment",
options: [{ label: "Dev", description: "Development" }],
},
]
expect((yield* registry.definitions()).map((definition) => definition.name)).toEqual(["question"])
expect(
yield* registry.settle({
sessionID,
call: { type: "tool-call", id: "call-question", name: "question", input: { questions } },
}),
).toEqual({
result: {
type: "text",
value:
'User has answered your questions: "What should happen?"="Build", "Which environment?"="Unanswered". You can now continue with the user\'s answers in mind.',
},
output: {
structured: { answers: [["Build"], []] },
content: [
{
type: "text",
text: 'User has answered your questions: "What should happen?"="Build", "Which environment?"="Unanswered". You can now continue with the user\'s answers in mind.',
},
],
},
})
expect(assertions).toEqual([])
expect(capturedInput()).toEqual({ sessionID, questions, tool: undefined })
}),
)
it.effect("does not invent tool ownership metadata without a durable registry source", () =>
Effect.gen(function* () {
captured = undefined
reject = false
const registryService = yield* ToolRegistry.Service
yield* registryService.execute({
sessionID,
call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } },
})
expect(capturedInput()).toEqual({ sessionID, questions: [], tool: undefined })
}),
)
it.effect("keeps dismissed questions out of model-facing output", () =>
Effect.gen(function* () {
captured = undefined
reject = true
const registryService = yield* ToolRegistry.Service
const fiber = yield* registryService
.execute({
sessionID,
call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } },
})
.pipe(Effect.forkScoped)
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
}),
)
})

View file

@ -0,0 +1,402 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool-registry"
import { ReadTool } from "@opencode-ai/core/tool/read"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { RelativePath } from "@opencode-ai/core/schema"
import { testEffect } from "./lib/effect"
const assertions: PermissionV2.AssertInput[] = []
const reads: FileSystem.ReadInput[] = []
const textPageInputs: FileSystem.TextPageInput[] = []
const pages: FileSystem.ListTarget[] = []
const pageInputs: Pick<FileSystem.ListPageInput, "offset" | "limit">[] = []
let resolvedInput: FileSystem.ReadInput | undefined
let resolveFailure: unknown
let listResolveFailure: unknown = new Error("not a directory")
let listReal = "/project/src"
let size = 5
let real = "/project/README.md"
let afterApproval = () => {}
const resourceReads: ToolOutputStore.ReadInput[] = []
const filesystem = Layer.succeed(
FileSystem.Service,
FileSystem.Service.of({
read: () => Effect.die("unused"),
resolveReadPath: (input) =>
resolveFailure === undefined
? Effect.succeed({
type: "file" as const,
target: new FileSystem.ReadTarget({
real,
resource: input.reference === undefined ? "README.md" : `${input.reference}:README.md`,
size,
dev: 1,
}),
})
: listResolveFailure === undefined
? Effect.succeed({
type: "directory" as const,
target: new FileSystem.ListTarget({
absolute: `/project/${input.path ?? "."}`,
real: listReal,
directory: "/project",
root: "/project",
resource: input.path ?? ".",
}),
})
: Effect.die(resolveFailure),
resolveRead: (input) =>
Effect.sync(() => {
resolvedInput = input
}).pipe(
Effect.andThen(
resolveFailure === undefined
? Effect.succeed(
new FileSystem.ReadTarget({
real,
resource: input.reference === undefined ? "README.md" : `${input.reference}:README.md`,
size,
dev: 1,
}),
)
: Effect.die(resolveFailure),
),
),
readResolved: () =>
Effect.sync(() => {
reads.push({ path: RelativePath.make("README.md") })
return new FileSystem.TextContent({ type: "text", content: "hello", mime: "text/plain" })
}),
readTextPageResolved: (_target, page = {}) =>
Effect.sync(() => {
textPageInputs.push(page)
return new FileSystem.TextPage({
type: "text-page",
content: "hello",
mime: "text/plain",
offset: page.offset ?? 1,
truncated: true,
next: (page.offset ?? 1) + 1,
})
}),
resolveRoot: () => Effect.die("unused"),
revalidateRoot: Effect.succeed,
list: () => Effect.die("unused"),
resolveList: (input = {}) =>
listResolveFailure === undefined
? Effect.succeed(
new FileSystem.ListTarget({
absolute: `/project/${input.path ?? "."}`,
real: listReal,
directory: "/project",
root: "/project",
resource: input.path ?? ".",
}),
)
: Effect.die(listResolveFailure),
listResolved: () => Effect.die("unused"),
listPage: () => Effect.die("unused"),
listPageResolved: (target, page = {}) =>
Effect.sync(() => {
pages.push(target)
pageInputs.push(page)
return new FileSystem.ListPage({ entries: [], truncated: false })
}),
find: () => Effect.die("unused"),
grep: () => Effect.die("unused"),
isIgnored: () => false,
}),
)
let allow = true
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => {
assertions.push(input)
if (allow) afterApproval()
}).pipe(Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.DeniedError({ rules: [] })))),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
)
const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
const resources = Layer.succeed(
ToolOutputStore.Service,
ToolOutputStore.Service.of({
limits: () => Effect.die("unused"),
write: () => Effect.die("unused"),
truncate: () => Effect.die("unused"),
cleanup: () => Effect.die("unused"),
read: (input) =>
Effect.sync(() => {
resourceReads.push(input)
return new ToolOutputStore.Page({
resource: new ToolOutputStore.Resource({ uri: input.uri, mime: "text/plain", size: 5 }),
content: "hello",
offset: input.offset ?? 0,
truncated: false,
})
}),
}),
)
const read = ReadTool.layer.pipe(
Layer.provide(registry),
Layer.provide(filesystem),
Layer.provide(permission),
Layer.provide(resources),
)
const it = testEffect(Layer.mergeAll(registry, filesystem, permission, resources, read))
const sessionID = SessionV2.ID.make("ses_read_tool_test")
describe("ReadTool", () => {
it.effect("registers, authorizes, and reads through the location filesystem", () =>
Effect.gen(function* () {
assertions.length = 0
reads.length = 0
allow = true
resolveFailure = undefined
listResolveFailure = new Error("not a directory")
size = 5
real = "/project/README.md"
afterApproval = () => {}
resolvedInput = undefined
const registry = yield* ToolRegistry.Service
expect(yield* registry.definitions()).toMatchObject([{ name: "read" }])
expect(
yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
}),
).toEqual({ type: "json", value: { type: "text", content: "hello", mime: "text/plain" } })
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["README.md"], save: ["*"] }])
expect(reads).toEqual([{ path: RelativePath.make("README.md") }])
}),
)
it.effect("does not read when permission is denied", () =>
Effect.gen(function* () {
assertions.length = 0
reads.length = 0
allow = false
resolveFailure = undefined
listResolveFailure = new Error("not a directory")
size = 5
real = "/project/README.md"
afterApproval = () => {}
resolvedInput = undefined
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
}),
).toEqual({ type: "error", value: "Unable to read README.md" })
expect(reads).toEqual([])
}),
)
it.effect("reads an opaque managed resource without treating it as a path", () =>
Effect.gen(function* () {
resourceReads.length = 0
assertions.length = 0
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
sessionID,
call: {
type: "tool-call",
id: "call-read-resource",
name: "read",
input: { resource: "tool-output://opaque", offset: 2, limit: 10 },
},
}),
).toEqual({
type: "json",
value: {
resource: { uri: "tool-output://opaque", mime: "text/plain", size: 5 },
content: "hello",
offset: 2,
truncated: false,
},
})
expect(resourceReads).toEqual([{ sessionID, uri: "tool-output://opaque", offset: 2, limit: 10 }])
expect(assertions).toEqual([])
}),
)
it.effect("lists a bounded directory page through read", () =>
Effect.gen(function* () {
assertions.length = 0
pages.length = 0
pageInputs.length = 0
allow = true
resolveFailure = new Error("Path is not a file")
listResolveFailure = undefined
listReal = "/project/src"
afterApproval = () => {}
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
sessionID,
call: {
type: "tool-call",
id: "call-read-directory",
name: "read",
input: { path: "src", offset: 2, limit: 10 },
},
}),
).toEqual({ type: "json", value: { entries: [], truncated: false } })
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }])
expect(pageInputs).toEqual([{ offset: 2, limit: 10 }])
}),
)
it.effect("does not list a directory when permission is denied", () =>
Effect.gen(function* () {
pages.length = 0
allow = false
resolveFailure = new Error("Path is not a file")
listResolveFailure = undefined
listReal = "/project/src"
afterApproval = () => {}
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-read-directory-denied", name: "read", input: { path: "src" } },
}),
).toEqual({ type: "error", value: "Unable to read src" })
expect(pages).toEqual([])
}),
)
it.effect("does not list when the directory changes after permission approval", () =>
Effect.gen(function* () {
pages.length = 0
allow = true
resolveFailure = new Error("Path is not a file")
listResolveFailure = undefined
listReal = "/project/src"
afterApproval = () => {
listReal = "/outside/src"
}
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-read-directory-swapped", name: "read", input: { path: "src" } },
}),
).toEqual({ type: "error", value: "Unable to read src" })
expect(pages).toEqual([])
}),
)
it.effect("authorizes project references with their canonical identity", () =>
Effect.gen(function* () {
assertions.length = 0
reads.length = 0
allow = true
resolveFailure = undefined
listResolveFailure = new Error("not a directory")
size = 5
real = "/project/README.md"
afterApproval = () => {}
resolvedInput = undefined
const registry = yield* ToolRegistry.Service
yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md", reference: "docs" } },
})
expect(assertions).toMatchObject([{ resources: ["docs:README.md"] }])
}),
)
it.effect("settles missing files as typed tool errors", () =>
Effect.gen(function* () {
allow = true
reads.length = 0
real = "/project/README.md"
afterApproval = () => {}
const registry = yield* ToolRegistry.Service
resolveFailure = new Error("missing")
listResolveFailure = new Error("missing")
expect(
yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-missing", name: "read", input: { path: "missing.txt" } },
}),
).toEqual({ type: "error", value: "Unable to read missing.txt" })
expect(reads).toEqual([])
}),
)
it.effect("reads large UTF-8 text files as bounded pages with continuation", () =>
Effect.gen(function* () {
textPageInputs.length = 0
allow = true
resolveFailure = undefined
listResolveFailure = new Error("not a directory")
size = FileSystem.MAX_READ_BYTES + 1
real = "/project/large.txt"
afterApproval = () => {}
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
sessionID,
call: {
type: "tool-call",
id: "call-large",
name: "read",
input: { path: "large.txt", offset: 2, limit: 1 },
},
}),
).toEqual({
type: "json",
value: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 },
})
expect(textPageInputs).toEqual([{ offset: 2, limit: 1 }])
}),
)
it.effect("does not read when the file changes after permission approval", () =>
Effect.gen(function* () {
assertions.length = 0
reads.length = 0
allow = true
resolveFailure = undefined
listResolveFailure = new Error("not a directory")
size = 5
real = "/project/README.md"
afterApproval = () => {
real = "/outside/README.md"
}
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-swapped", name: "read", input: { path: "README.md" } },
}),
).toEqual({ type: "error", value: "Unable to read README.md" })
expect(reads).toEqual([])
}),
)
})

View file

@ -0,0 +1,150 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SkillV2 } from "@opencode-ai/core/skill"
import { SkillTool } from "@opencode-ai/core/tool/skill"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ToolRegistry } from "@opencode-ai/core/tool-registry"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
const sessionID = SessionV2.ID.make("ses_skill_tool_test")
describe("SkillTool", () => {
it.live("lists available skills, authorizes the selected name, and loads model-facing content", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const directory = path.join(tmp.path, "effect")
const location = path.join(directory, "SKILL.md")
const reference = path.join(directory, "reference.md")
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
yield* Effect.promise(() =>
Promise.all([fs.writeFile(location, "unused"), fs.writeFile(reference, "reference")]),
)
const info: SkillV2.Info = {
name: "effect",
description: "Use Effect",
location: AbsolutePath.make(location),
content: "# Effect\n\nGuidance",
}
const assertions: PermissionV2.AssertInput[] = []
const truncations: ToolOutputStore.TruncateInput[] = []
let truncate = (input: ToolOutputStore.TruncateInput): Effect.Effect<ToolOutputStore.TruncateResult> =>
Effect.succeed({ content: input.content, truncated: false })
let bootWaited = false
const boot = Layer.succeed(
PluginBoot.Service,
PluginBoot.Service.of({
wait: () =>
Effect.sync(() => {
bootWaited = true
}),
}),
)
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
assert: (input) => Effect.sync(() => assertions.push(input)),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
)
const skills = Layer.succeed(
SkillV2.Service,
SkillV2.Service.of({
transform: () => Effect.die("unused"),
sources: () => Effect.die("unused"),
list: () => Effect.succeed([info]),
forAgent: () => Effect.die("unused"),
}),
)
const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
const resources = Layer.succeed(
ToolOutputStore.Service,
ToolOutputStore.Service.of({
limits: () => Effect.die("unused"),
write: () => Effect.die("unused"),
truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))),
read: () => Effect.die("unused"),
cleanup: () => Effect.die("unused"),
}),
)
const tool = SkillTool.layer.pipe(
Layer.provide(registry),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(boot),
Layer.provide(skills),
Layer.provide(resources),
)
const layer = Layer.mergeAll(permission, skills, registry, boot, resources, tool)
return yield* Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
expect(bootWaited).toBe(true)
expect((yield* registry.definitions())[0]).toMatchObject({
name: "skill",
description: expect.stringContaining("**effect**: Use Effect"),
})
expect(
yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-skill", name: "skill", input: { name: "effect" } },
}),
).toEqual({
type: "text",
value: SkillTool.toModelOutput(info, [reference]),
})
expect(truncations).toEqual([
{ sessionID, toolCallID: "call-skill", content: SkillTool.toModelOutput(info, [reference]) },
])
truncate = (input) =>
Effect.succeed({
content: "HEAD\n\n... output truncated; full content available as tool-output://opaque ...\n\nTAIL",
truncated: true,
resource: new ToolOutputStore.Resource({
uri: "tool-output://opaque",
mime: "text/plain",
size: input.content.length,
}),
})
expect(
yield* registry.settle({
sessionID,
call: { type: "tool-call", id: "call-skill-overflow", name: "skill", input: { name: "effect" } },
}),
).toMatchObject({
result: { type: "text", value: expect.stringContaining("tool-output://opaque") },
output: {
structured: { truncated: true, resource: { uri: "tool-output://opaque" } },
},
})
expect(assertions).toEqual([
{ sessionID, action: "skill", resources: ["effect"], save: ["effect"] },
{ sessionID, action: "skill", resources: ["effect"], save: ["effect"] },
])
expect(
yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-missing-skill", name: "skill", input: { name: "missing" } },
}),
).toEqual({ type: "error", value: 'Skill "missing" not found. Available skills: effect' })
}).pipe(Effect.provide(layer))
}),
),
),
)
})

View file

@ -0,0 +1,106 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionTodo } from "@opencode-ai/core/session/todo"
import { TodoWriteTool } from "@opencode-ai/core/tool/todowrite"
import { ToolRegistry } from "@opencode-ai/core/tool-registry"
import { testEffect } from "./lib/effect"
const sessionID = SessionV2.ID.make("ses_todowrite_tool_test")
const assertions: PermissionV2.AssertInput[] = []
let deny = false
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(deny ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
)
const database = Database.layerFromPath(":memory:")
const events = EventV2.layer.pipe(Layer.provide(database))
const todos = SessionTodo.layer.pipe(Layer.provide(database), Layer.provide(events))
const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
const tool = TodoWriteTool.layer.pipe(Layer.provide(registry), Layer.provide(todos))
const it = testEffect(Layer.mergeAll(database, events, todos, permission, registry, tool))
const setup = Effect.gen(function* () {
assertions.length = 0
deny = false
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "todowrite",
directory: "/project",
title: "todowrite",
version: "test",
})
.run()
.pipe(Effect.orDie)
})
const call = (todos: ReadonlyArray<SessionTodo.Info>, id = "call-todowrite") => ({
sessionID,
call: { type: "tool-call" as const, id, name: TodoWriteTool.name, input: { todos } },
})
describe("TodoWriteTool", () => {
it.effect("registers, approves the wildcard resource, persists todos, and returns typed output", () =>
Effect.gen(function* () {
yield* setup
const registry = yield* ToolRegistry.Service
const service = yield* SessionTodo.Service
const todoList = [{ content: "Implement slice", status: "in_progress", priority: "high" }]
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual([TodoWriteTool.name])
expect(yield* registry.settle(call(todoList))).toEqual({
result: { type: "text", value: JSON.stringify(todoList, null, 2) },
output: {
structured: { todos: todoList },
content: [{ type: "text", text: JSON.stringify(todoList, null, 2) }],
},
})
expect(assertions).toEqual([{ sessionID, action: "todowrite", resources: ["*"], save: ["*"] }])
expect(yield* service.get(sessionID)).toEqual(todoList)
}),
)
it.effect("does not update persisted todos when permission is denied", () =>
Effect.gen(function* () {
yield* setup
const registry = yield* ToolRegistry.Service
const service = yield* SessionTodo.Service
yield* service.update({ sessionID, todos: [{ content: "keep", status: "pending", priority: "low" }] })
deny = true
expect(yield* registry.execute(call([{ content: "blocked", status: "completed", priority: "high" }]))).toEqual({
type: "error",
value: "Unable to update todos",
})
expect(yield* service.get(sessionID)).toEqual([{ content: "keep", status: "pending", priority: "low" }])
expect(assertions).toEqual([{ sessionID, action: "todowrite", resources: ["*"], save: ["*"] }])
}),
)
})

View file

@ -0,0 +1,295 @@
import { describe, expect, test } from "bun:test"
import { Duration, Effect, Fiber, Layer, Schema } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ToolRegistry } from "@opencode-ai/core/tool-registry"
import { WebFetchTool } from "@opencode-ai/core/tool/webfetch"
import { testEffect } from "./lib/effect"
const sessionID = SessionV2.ID.make("ses_webfetch_test")
const requests: Array<{ readonly url: string; readonly headers: Record<string, string> }> = []
const assertions: PermissionV2.AssertInput[] = []
const truncations: ToolOutputStore.TruncateInput[] = []
let respond = (_request: HttpClientRequest.HttpClientRequest) =>
Effect.succeed(new Response("hello", { headers: { "content-type": "text/plain" } }))
let truncate = (input: ToolOutputStore.TruncateInput): Effect.Effect<ToolOutputStore.TruncateResult> =>
Effect.succeed({ content: input.content, truncated: false })
const http = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.sync(() => requests.push({ url: request.url, headers: request.headers })).pipe(
Effect.andThen(respond(request)),
Effect.map((response) => HttpClientResponse.fromWeb(request, response)),
),
),
)
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
assert: (input) => Effect.sync(() => assertions.push(input)),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
)
const resources = Layer.succeed(
ToolOutputStore.Service,
ToolOutputStore.Service.of({
limits: () => Effect.die("unused"),
write: () => Effect.die("unused"),
truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))),
read: () => Effect.die("unused"),
cleanup: () => Effect.die("unused"),
}),
)
const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
const webfetch = WebFetchTool.layer.pipe(Layer.provide(registry), Layer.provide(http), Layer.provide(resources))
const it = testEffect(Layer.mergeAll(registry, permission, http, resources, webfetch))
const fetchWebfetch = WebFetchTool.layer.pipe(
Layer.provide(registry),
Layer.provide(FetchHttpClient.layer),
Layer.provide(resources),
)
const live = testEffect(Layer.mergeAll(registry, permission, FetchHttpClient.layer, resources, fetchWebfetch))
const reset = () => {
requests.length = 0
assertions.length = 0
truncations.length = 0
respond = () => Effect.succeed(new Response("hello", { headers: { "content-type": "text/plain" } }))
truncate = (input) => Effect.succeed({ content: input.content, truncated: false })
}
const call = (input: typeof WebFetchTool.Parameters.Type, id = "call-webfetch") => ({
sessionID,
call: { type: "tool-call" as const, id, name: "webfetch", input },
})
describe("WebFetchTool helpers", () => {
test("defaults format and rejects invalid timeout controls", () => {
const decode = Schema.decodeUnknownSync(WebFetchTool.Parameters)
expect(decode({ url: "https://example.com" })).toEqual({ url: "https://example.com", format: "markdown" })
expect(() => decode({ url: "https://example.com", timeout: 0 })).toThrow()
expect(() => decode({ url: "https://example.com", timeout: WebFetchTool.MAX_TIMEOUT_SECONDS + 1 })).toThrow()
})
test("ports HTML text and markdown conversions without active content", () => {
const html = "<h1>Hello</h1><script>bad()</script><p>world <strong>wide</strong></p><style>.bad {}</style>"
expect(WebFetchTool.extractTextFromHTML(html)).toBe("Helloworld wide")
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("# Hello\n\nworld **wide**")
})
})
describe("WebFetchTool contribution", () => {
it.effect("registers and fetches an ordinary hostname HTTP URL without rewriting it", () =>
Effect.gen(function* () {
reset()
const registry = yield* ToolRegistry.Service
const url = "http://example.com/public"
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["webfetch"])
expect(yield* registry.settle(call({ url, format: "text", timeout: 4 }))).toEqual({
result: { type: "text", value: "hello" },
output: {
structured: { url, contentType: "text/plain", format: "text", output: "hello", truncated: false },
content: [{ type: "text", text: "hello" }],
},
})
expect(assertions).toEqual([
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text", timeout: 4 } },
])
expect(requests).toMatchObject([{ url, headers: { accept: expect.stringContaining("text/plain;q=1.0") } }])
}),
)
it.effect("accepts localhost URLs with the same requested-URL permission check", () =>
Effect.gen(function* () {
reset()
const registry = yield* ToolRegistry.Service
const url = "http://localhost/private"
expect(yield* registry.execute(call({ url, format: "text" }))).toEqual({
type: "text",
value: "hello",
})
expect(assertions).toEqual([
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } },
])
expect(requests.map((request) => request.url)).toEqual([url])
}),
)
live.effect("follows redirects while approving only the requested URL", () =>
Effect.acquireUseRelease(
Effect.sync(() =>
Bun.serve({
port: 0,
fetch: (request) =>
new URL(request.url).pathname === "/redirect"
? new Response("", { status: 302, headers: { location: "/target" } })
: new Response("redirected", { headers: { "content-type": "text/plain" } }),
}),
),
(server) =>
Effect.gen(function* () {
reset()
const registry = yield* ToolRegistry.Service
const url = new URL("/redirect", server.url).toString()
expect(yield* registry.execute(call({ url, format: "text" }))).toEqual({ type: "text", value: "redirected" })
expect(assertions).toEqual([
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } },
])
}),
(server) => Effect.promise(() => server.stop(true)),
),
)
it.effect("rejects non-HTTP schemes before permission or transport", () =>
Effect.gen(function* () {
reset()
const registry = yield* ToolRegistry.Service
expect(yield* registry.execute(call({ url: "file:///etc/passwd", format: "text" }))).toEqual({
type: "error",
value: "Unable to fetch file:///etc/passwd",
})
expect(assertions).toEqual([])
expect(requests).toEqual([])
}),
)
it.effect("converts HTML to requested markdown and text", () =>
Effect.gen(function* () {
reset()
respond = () =>
Effect.succeed(
new Response("<h1>Hello</h1><p>world</p><script>bad()</script>", {
headers: { "content-type": "text/html; charset=utf-8" },
}),
)
const registry = yield* ToolRegistry.Service
expect(yield* registry.execute(call({ url: "https://1.1.1.1", format: "markdown" }))).toEqual({
type: "text",
value: "# Hello\n\nworld",
})
expect(yield* registry.execute(call({ url: "https://1.1.1.1", format: "text" }))).toEqual({
type: "text",
value: "Helloworld",
})
}),
)
it.effect("exposes managed overflow through an opaque resource URI", () =>
Effect.gen(function* () {
reset()
truncate = (input) =>
Effect.succeed({
content: "HEAD\n\n... output truncated; full content available as tool-output://opaque ...\n\nTAIL",
truncated: true,
resource: new ToolOutputStore.Resource({
uri: "tool-output://opaque",
mime: input.mime ?? "text/plain",
size: input.content.length,
}),
})
const registry = yield* ToolRegistry.Service
const settled = yield* registry.settle(call({ url: "https://1.1.1.1", format: "html" }, "call-overflow"))
expect(settled.result).toMatchObject({ type: "text", value: expect.stringContaining("tool-output://opaque") })
expect(settled.output?.structured).toMatchObject({
truncated: true,
resource: { uri: "tool-output://opaque", mime: "text/html" },
})
expect(truncations).toEqual([{ sessionID, toolCallID: "call-overflow", content: "hello", mime: "text/html" }])
}),
)
it.effect("rejects declared and streamed oversized bodies", () =>
Effect.gen(function* () {
reset()
const registry = yield* ToolRegistry.Service
respond = () =>
Effect.succeed(
new Response("small", {
headers: { "content-type": "text/plain", "content-length": String(WebFetchTool.MAX_RESPONSE_BYTES + 1) },
}),
)
expect(yield* registry.execute(call({ url: "https://1.1.1.1/declared", format: "text" }))).toEqual({
type: "error",
value: "Unable to fetch https://1.1.1.1/declared",
})
respond = () =>
Effect.succeed(
new Response("x".repeat(WebFetchTool.MAX_RESPONSE_BYTES + 1), { headers: { "content-type": "text/plain" } }),
)
expect(yield* registry.execute(call({ url: "https://1.1.1.1/streamed", format: "text" }))).toEqual({
type: "error",
value: "Unable to fetch https://1.1.1.1/streamed",
})
}),
)
it.effect("keeps images and files unsupported until typed settlement can carry attachments", () =>
Effect.gen(function* () {
reset()
const registry = yield* ToolRegistry.Service
respond = () => Effect.succeed(new Response("png", { headers: { "content-type": "image/png" } }))
expect(yield* registry.execute(call({ url: "https://1.1.1.1/image", format: "html" }))).toEqual({
type: "error",
value: "Unable to fetch https://1.1.1.1/image",
})
respond = () => Effect.succeed(new Response("pdf", { headers: { "content-type": "application/pdf" } }))
expect(yield* registry.execute(call({ url: "https://1.1.1.1/file", format: "html" }))).toEqual({
type: "error",
value: "Unable to fetch https://1.1.1.1/file",
})
expect(truncations).toEqual([])
}),
)
it.effect("retries Cloudflare challenges with an honest user agent", () =>
Effect.gen(function* () {
reset()
let count = 0
respond = () =>
Effect.succeed(
++count === 1
? new Response("challenge", { status: 403, headers: { "cf-mitigated": "challenge" } })
: new Response("ok", { headers: { "content-type": "text/plain" } }),
)
const registry = yield* ToolRegistry.Service
expect(yield* registry.execute(call({ url: "https://1.1.1.1", format: "text" }))).toEqual({
type: "text",
value: "ok",
})
expect(requests).toHaveLength(2)
expect(requests[0]?.headers["user-agent"]).toContain("Mozilla/5.0")
expect(requests[1]?.headers["user-agent"]).toBe("opencode")
}),
)
it.effect("times out stalled requests", () =>
Effect.gen(function* () {
reset()
respond = () => Effect.never
const registry = yield* ToolRegistry.Service
const fiber = yield* registry
.execute(call({ url: "https://1.1.1.1/slow", format: "text", timeout: 1 }))
.pipe(Effect.forkChild)
yield* TestClock.adjust(Duration.seconds(1))
expect(yield* Fiber.join(fiber)).toEqual({ type: "error", value: "Unable to fetch https://1.1.1.1/slow" })
}),
)
})

View file

@ -0,0 +1,331 @@
import { describe, expect, test } from "bun:test"
import { Effect, Layer, Schema } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool-registry"
import { WebSearchTool } from "@opencode-ai/core/tool/websearch"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { testEffect } from "./lib/effect"
const sessionID = SessionV2.ID.make("ses_websearch_test")
const payload = (text: string) =>
JSON.stringify({
jsonrpc: "2.0",
id: 1,
result: { content: [{ type: "text", text }] },
})
describe("WebSearchTool provider selection", () => {
test("rejects out-of-range numeric controls", () => {
const decode = Schema.decodeUnknownSync(WebSearchTool.Parameters)
expect(() => decode({ query: "x", numResults: 0 })).toThrow()
expect(() => decode({ query: "x", numResults: WebSearchTool.MAX_NUM_RESULTS + 1 })).toThrow()
expect(() => decode({ query: "x", contextMaxCharacters: WebSearchTool.MAX_CONTEXT_CHARACTERS + 1 })).toThrow()
})
test("selects a stable provider per session", () => {
expect(WebSearchTool.selectProvider(sessionID)).toBe(WebSearchTool.selectProvider(sessionID))
})
test("supports an explicit operational override", () => {
expect(WebSearchTool.selectProvider(sessionID, { enableExa: false, enableParallel: false }, "parallel")).toBe(
"parallel",
)
expect(WebSearchTool.selectProvider(sessionID, { enableExa: false, enableParallel: false }, "exa")).toBe("exa")
})
test("prefers Parallel when both explicit flags are enabled", () => {
expect(WebSearchTool.selectProvider(sessionID, { enableExa: true, enableParallel: true })).toBe("parallel")
})
test("prefers Exa when only its explicit flag is enabled", () => {
expect(WebSearchTool.selectProvider(sessionID, { enableExa: true, enableParallel: false })).toBe("exa")
})
})
describe("WebSearchTool MCP response parser", () => {
test("parses plain JSON-RPC responses", async () => {
expect(await Effect.runPromise(WebSearchTool.parseResponse(payload("search results")))).toBe("search results")
})
test("parses SSE JSON-RPC responses and ignores non-JSON frames", async () => {
expect(
await Effect.runPromise(
WebSearchTool.parseResponse(`data: [DONE]\nevent: message\ndata: ${payload("search results")}\n\n`),
),
).toBe("search results")
})
})
interface Request {
readonly url: string
readonly headers: Record<string, string>
readonly body: unknown
}
const requests: Request[] = []
const assertions: PermissionV2.AssertInput[] = []
const truncations: ToolOutputStore.TruncateInput[] = []
let responseBody = payload("search results")
let config: WebSearchTool.Config = { enableExa: false, enableParallel: false }
let truncate = (input: ToolOutputStore.TruncateInput): Effect.Effect<ToolOutputStore.TruncateResult> =>
Effect.succeed({ content: input.content, truncated: false })
const http = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.sync(() => {
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
requests.push({
url: request.url,
headers: request.headers,
body: JSON.parse(new TextDecoder().decode(request.body.body)),
})
return HttpClientResponse.fromWeb(request, new Response(responseBody, { status: 200 }))
}),
),
)
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
assert: (input) => Effect.sync(() => assertions.push(input)),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
)
const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
const websearchConfig = Layer.succeed(
WebSearchTool.ConfigService,
WebSearchTool.ConfigService.of({
get provider() {
return config.provider
},
get enableExa() {
return config.enableExa
},
get enableParallel() {
return config.enableParallel
},
get exaApiKey() {
return config.exaApiKey
},
get parallelApiKey() {
return config.parallelApiKey
},
}),
)
const resources = Layer.succeed(
ToolOutputStore.Service,
ToolOutputStore.Service.of({
limits: () => Effect.die("unused"),
write: () => Effect.die("unused"),
truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))),
read: () => Effect.die("unused"),
cleanup: () => Effect.die("unused"),
}),
)
const websearch = WebSearchTool.layer.pipe(
Layer.provide(registry),
Layer.provide(permission),
Layer.provide(http),
Layer.provide(websearchConfig),
Layer.provide(resources),
)
const it = testEffect(Layer.mergeAll(registry, permission, http, websearchConfig, resources, websearch))
describe("WebSearchTool contribution", () => {
it.effect("registers websearch, asserts query permission, and calls Exa", () =>
Effect.gen(function* () {
requests.length = 0
assertions.length = 0
truncations.length = 0
truncate = (input) => Effect.succeed({ content: input.content, truncated: false })
responseBody = payload("exa results")
config = { provider: "exa", enableExa: false, enableParallel: false }
const registry = yield* ToolRegistry.Service
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["websearch"])
expect(
yield* registry.execute({
sessionID,
call: {
type: "tool-call",
id: "call-exa",
name: "websearch",
input: {
query: "effect typescript",
numResults: 3,
livecrawl: "preferred",
type: "fast",
contextMaxCharacters: 2500,
},
},
}),
).toEqual({ type: "text", value: "exa results" })
expect(assertions).toEqual([
{
sessionID,
action: "websearch",
resources: ["effect typescript"],
save: ["*"],
metadata: {
query: "effect typescript",
numResults: 3,
livecrawl: "preferred",
type: "fast",
contextMaxCharacters: 2500,
provider: "exa",
},
},
])
expect(requests).toEqual([
{
url: WebSearchTool.EXA_URL,
headers: expect.any(Object),
body: {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "web_search_exa",
arguments: {
query: "effect typescript",
type: "fast",
numResults: 3,
livecrawl: "preferred",
contextMaxCharacters: 2500,
},
},
},
},
])
}),
)
it.effect("calls Parallel with session ID and keeps bearer credentials out of output", () =>
Effect.gen(function* () {
requests.length = 0
assertions.length = 0
responseBody = payload("parallel results")
config = { provider: "parallel", enableExa: false, enableParallel: false, parallelApiKey: "parallel-secret" }
const registry = yield* ToolRegistry.Service
const settled = yield* registry.settle({
sessionID,
call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } },
})
expect(requests[0]).toMatchObject({
url: WebSearchTool.PARALLEL_URL,
headers: { authorization: "Bearer parallel-secret" },
body: {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "web_search",
arguments: { objective: "effect layers", search_queries: ["effect layers"], session_id: sessionID },
},
},
})
expect(requests[0]?.body).not.toHaveProperty("params.arguments.model_name")
expect(settled).toEqual({
result: { type: "text", value: "parallel results" },
output: {
structured: { provider: "parallel", text: "parallel results", truncated: false },
content: [{ type: "text", text: "parallel results" }],
},
})
expect(JSON.stringify(settled)).not.toContain("parallel-secret")
}),
)
it.effect("keeps an Exa credential in the transport URL and out of model output", () =>
Effect.gen(function* () {
requests.length = 0
assertions.length = 0
responseBody = payload("credentialed exa results")
config = { provider: "exa", enableExa: false, enableParallel: false, exaApiKey: "exa secret" }
const registry = yield* ToolRegistry.Service
const settled = yield* registry.settle({
sessionID,
call: { type: "tool-call", id: "call-exa-key", name: "websearch", input: { query: "effect schema" } },
})
expect(requests[0]?.url).toBe(`${WebSearchTool.EXA_URL}?exaApiKey=exa+secret`)
expect(JSON.stringify(settled)).not.toContain("exa secret")
}),
)
it.effect("returns the legacy no-results fallback as concise model text", () =>
Effect.gen(function* () {
requests.length = 0
assertions.length = 0
responseBody = ""
config = { provider: "exa", enableExa: false, enableParallel: false }
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-empty", name: "websearch", input: { query: "nothing" } },
}),
).toEqual({ type: "text", value: WebSearchTool.NO_RESULTS })
}),
)
it.effect("exposes managed overflow through typed structured output", () =>
Effect.gen(function* () {
requests.length = 0
assertions.length = 0
truncations.length = 0
responseBody = payload("full search results")
config = { provider: "exa", enableExa: false, enableParallel: false }
truncate = (input) =>
Effect.succeed({
content: "HEAD\n\n... output truncated; full content available as tool-output://opaque ...\n\nTAIL",
truncated: true,
resource: new ToolOutputStore.Resource({
uri: "tool-output://opaque",
mime: "text/plain",
size: input.content.length,
}),
})
const registry = yield* ToolRegistry.Service
const settled = yield* registry.settle({
sessionID,
call: { type: "tool-call", id: "call-overflow", name: "websearch", input: { query: "verbose" } },
})
expect(settled.result).toMatchObject({ type: "text", value: expect.stringContaining("tool-output://opaque") })
expect(settled.output?.structured).toMatchObject({
provider: "exa",
truncated: true,
resource: { uri: "tool-output://opaque", mime: "text/plain" },
})
expect(truncations).toEqual([{ sessionID, toolCallID: "call-overflow", content: "full search results" }])
}),
)
it.effect("rejects oversized MCP response bodies", () =>
Effect.gen(function* () {
requests.length = 0
assertions.length = 0
responseBody = "x".repeat(WebSearchTool.MAX_RESPONSE_BYTES + 1)
config = { provider: "exa", enableExa: false, enableParallel: false }
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-large-response", name: "websearch", input: { query: "too much" } },
}),
).toEqual({ type: "error", value: "Unable to search the web for too much" })
}),
)
})

View file

@ -0,0 +1,328 @@
import fs from "fs/promises"
import path from "path"
import { fileURLToPath } from "url"
import { describe, expect, test } from "bun:test"
import { Effect, Layer } from "effect"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool-registry"
import { WriteTool } from "@opencode-ai/core/tool/write"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const sessionID = SessionV2.ID.make("ses_write_tool_test")
const assertions: PermissionV2.AssertInput[] = []
const writes: string[] = []
let denyAction: string | undefined
let afterAssertion = (_input: PermissionV2.AssertInput): Effect.Effect<void> => Effect.void
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(
input.action === denyAction
? Effect.fail(new PermissionV2.DeniedError({ rules: [] }))
: afterAssertion(input),
),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
)
const reset = () => {
assertions.length = 0
writes.length = 0
denyAction = undefined
afterAssertion = () => Effect.void
}
const filesystem = Layer.effect(
FSUtil.Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
return FSUtil.Service.of({
...fs,
writeWithDirs: (target, content, mode) =>
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeWithDirs(target, content, mode))),
})
}),
).pipe(Layer.provide(FSUtil.defaultLayer))
const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) => {
const activeLocation = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
)
const planning = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
const commits = FileMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(planning))
const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
const write = WriteTool.layer.pipe(Layer.provide(registry), Layer.provide(planning), Layer.provide(commits))
return Effect.gen(function* () {
return yield* body(yield* ToolRegistry.Service)
}).pipe(Effect.provide(Layer.mergeAll(registry, planning, commits, write)))
}
const call = (input: typeof WriteTool.Parameters.Type, id = "call-write") => ({
sessionID,
call: { type: "tool-call" as const, id, name: "write", input },
})
const it = testEffect(Layer.empty)
describe("WriteTool", () => {
it.live("registers and creates a relative file through FileMutation once", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["write"])
const settled = yield* registry.settle(call({ path: "src/new.txt", content: "created" }))
expect(settled).toEqual({
result: { type: "text", value: "Created file successfully: src/new.txt" },
output: {
structured: {
operation: "write",
target: path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt"),
resource: "src/new.txt",
existed: false,
},
content: [{ type: "text", text: "Created file successfully: src/new.txt" }],
},
})
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "src", "new.txt"), "utf8"))).toBe(
"created",
)
expect(assertions).toEqual([{ sessionID, action: "edit", resources: ["src/new.txt"], save: ["*"] }])
expect(writes).toEqual([path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt")])
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("overwrites a relative existing file and reports that it wrote the file", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return Effect.promise(() => fs.writeFile(path.join(tmp.path, "existing.txt"), "before")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) => registry.settle(call({ path: "existing.txt", content: "after" }))),
),
Effect.andThen((settled) =>
Effect.gen(function* () {
expect(settled.result).toEqual({ type: "text", value: "Wrote file successfully: existing.txt" })
expect(settled.output?.structured).toMatchObject({ resource: "existing.txt", existed: true })
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "existing.txt"), "utf8"))).toBe(
"after",
)
expect(writes).toHaveLength(1)
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("preserves exactly one BOM when overwriting existing files", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const preserved = path.join(tmp.path, "preserved.txt")
const deduplicated = path.join(tmp.path, "deduplicated.txt")
return Effect.promise(() =>
Promise.all([fs.writeFile(preserved, "\uFEFFbefore"), fs.writeFile(deduplicated, "\uFEFFbefore")]),
).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
yield* registry.settle(call({ path: "preserved.txt", content: "after" }, "call-preserved"))
yield* registry.settle(call({ path: "deduplicated.txt", content: "\uFEFFafter" }, "call-deduplicated"))
expect(yield* Effect.promise(() => fs.readFile(preserved, "utf8"))).toBe("\uFEFFafter")
expect(yield* Effect.promise(() => fs.readFile(deduplicated, "utf8"))).toBe("\uFEFFafter")
}),
),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("accepts an absolute file path inside the active Location", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const target = path.join(tmp.path, "absolute.txt")
return withTool(tmp.path, (registry) => registry.execute(call({ path: target, content: "inside" }))).pipe(
Effect.andThen((result) =>
Effect.gen(function* () {
expect(result).toEqual({ type: "text", value: "Created file successfully: absolute.txt" })
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("inside")
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("approves an explicit external absolute path before edit", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
reset()
const target = path.join(outside.path, "external.txt")
return withTool(active.path, (registry) => registry.settle(call({ path: target, content: "external" }))).pipe(
Effect.andThen((settled) =>
Effect.gen(function* () {
const canonicalTarget = path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "external.txt")
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
expect(assertions[0]).toMatchObject({
resources: [
path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "*").replaceAll("\\", "/"),
],
})
expect(assertions[1]).toMatchObject({ resources: [canonicalTarget.replaceAll("\\", "/")], save: ["*"] })
expect(settled.output?.structured).toMatchObject({
target: canonicalTarget,
resource: canonicalTarget.replaceAll("\\", "/"),
existed: false,
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("external")
expect(writes).toEqual([canonicalTarget])
}),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("does not write when external_directory or edit approval is denied", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) =>
Effect.gen(function* () {
const external = path.join(outside.path, "denied.txt")
reset()
denyAction = "external_directory"
expect(
yield* withTool(active.path, (registry) => registry.execute(call({ path: external, content: "blocked" }))),
).toEqual({
type: "error",
value: `Unable to write ${external}`,
})
expect(assertions.map((input) => input.action)).toEqual(["external_directory"])
expect(writes).toEqual([])
reset()
denyAction = "edit"
expect(
yield* withTool(active.path, (registry) =>
registry.execute(call({ path: "denied.txt", content: "blocked" })),
),
).toEqual({
type: "error",
value: "Unable to write denied.txt",
})
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(writes).toEqual([])
}),
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
if (process.platform !== "win32") {
it.live("delegates post-approval revalidation to FileMutation before writing", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
reset()
const parent = path.join(active.path, "parent")
afterAssertion = (input) =>
input.action === "edit"
? Effect.promise(async () => {
await fs.rmdir(parent)
await fs.symlink(outside.path, parent)
})
: Effect.void
return Effect.promise(() => fs.mkdir(parent)).pipe(
Effect.andThen(
withTool(active.path, (registry) =>
registry.execute(call({ path: "parent/escape.txt", content: "blocked" })),
),
),
Effect.andThen((result) =>
Effect.gen(function* () {
expect(result).toEqual({ type: "error", value: "Unable to write parent/escape.txt" })
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(writes).toEqual([])
expect(
yield* Effect.promise(() =>
fs.stat(path.join(outside.path, "escape.txt")).then(
() => true,
() => false,
),
),
).toBe(false)
}),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
}
})
test("keeps the locked write schema, semantics docstring, and deferred UX TODOs visible", async () => {
const source = (await fs.readFile(new URL("../src/tool/write.ts", import.meta.url), "utf8")).replaceAll("\r\n", "\n")
const definition = await Effect.runPromise(
withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => registry.definitions()),
)
const schema = definition[0]?.inputSchema as { readonly properties?: Record<string, unknown> }
expect(Object.keys(schema.properties ?? {}).sort()).toEqual(["content", "path"])
expect(source).toContain(
"Named project references\n * are read-oriented and deliberately are not accepted by mutation tools.",
)
for (const todo of [
"Revisit whether model-facing mutation schemas should prefer absolute `filePath` naming for trained-in compatibility after evaluating model behavior.",
"Add formatter integration after V2 formatter runtime exists.",
"Publish watcher/file-edit events after V2 watcher integration exists.",
"Add snapshots / undo after design exists.",
"Add LSP notification and diagnostics after V2 LSP runtime exists.",
]) {
expect(source).toContain(`TODO: ${todo}`)
}
})