chore: merge dev
This commit is contained in:
commit
d3fe65e2f7
312 changed files with 32857 additions and 4562 deletions
|
|
@ -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)
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
103
packages/core/test/background-job.test.ts
Normal file
103
packages/core/test/background-job.test.ts
Normal 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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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* () {
|
||||
|
|
|
|||
73
packages/core/test/effect/keyed-mutex.test.ts
Normal file
73
packages/core/test/effect/keyed-mutex.test.ts
Normal 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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -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)
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
357
packages/core/test/file-mutation.test.ts
Normal file
357
packages/core/test/file-mutation.test.ts
Normal 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))
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
27
packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json
vendored
Normal file
27
packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
])
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
234
packages/core/test/location-mutation.test.ts
Normal file
234
packages/core/test/location-mutation.test.ts
Normal 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",
|
||||
})
|
||||
})
|
||||
})
|
||||
285
packages/core/test/location-search.test.ts
Normal file
285
packages/core/test/location-search.test.ts
Normal 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),
|
||||
})
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
16
packages/core/test/opencode.test.ts
Normal file
16
packages/core/test/opencode.test.ts
Normal 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()
|
||||
}),
|
||||
)
|
||||
})
|
||||
68
packages/core/test/patch.test.ts
Normal file
68
packages/core/test/patch.test.ts
Normal 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",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
90
packages/core/test/plugin.test.ts
Normal file
90
packages/core/test/plugin.test.ts
Normal 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([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
115
packages/core/test/question.test.ts
Normal file
115
packages/core/test/question.test.ts
Normal 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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
259
packages/core/test/session-create.test.ts
Normal file
259
packages/core/test/session-create.test.ts
Normal 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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
458
packages/core/test/session-projector.test.ts
Normal file
458
packages/core/test/session-projector.test.ts
Normal 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 },
|
||||
}),
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
463
packages/core/test/session-prompt.test.ts
Normal file
463
packages/core/test/session-prompt.test.ts
Normal 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([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
384
packages/core/test/session-run-coordinator.test.ts
Normal file
384
packages/core/test/session-run-coordinator.test.ts
Normal 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)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
377
packages/core/test/session-runner-message.test.ts
Normal file
377
packages/core/test/session-runner-message.test.ts
Normal 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,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
213
packages/core/test/session-runner-model.test.ts
Normal file
213
packages/core/test/session-runner-model.test.ts
Normal 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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
155
packages/core/test/session-runner-recorded.test.ts
Normal file
155
packages/core/test/session-runner-recorded.test.ts
Normal 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",
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
211
packages/core/test/session-runner-tool-registry.test.ts
Normal file
211
packages/core/test/session-runner-tool-registry.test.ts
Normal 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" }] },
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
2121
packages/core/test/session-runner.test.ts
Normal file
2121
packages/core/test/session-runner.test.ts
Normal file
File diff suppressed because it is too large
Load diff
70
packages/core/test/session-system-context.test.ts
Normal file
70
packages/core/test/session-system-context.test.ts
Normal 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)}`,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
95
packages/core/test/session-todo.test.ts
Normal file
95
packages/core/test/session-todo.test.ts
Normal 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: [] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
159
packages/core/test/session-tool-progress.test.ts
Normal file
159
packages/core/test/session-tool-progress.test.ts
Normal 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))
|
||||
}),
|
||||
)
|
||||
})
|
||||
104
packages/core/test/skill-discovery.test.ts
Normal file
104
packages/core/test/skill-discovery.test.ts
Normal 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]()
|
||||
}
|
||||
})
|
||||
})
|
||||
188
packages/core/test/system-context.test.ts
Normal file
188
packages/core/test/system-context.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
368
packages/core/test/tool-apply-patch.test.ts
Normal file
368
packages/core/test/tool-apply-patch.test.ts
Normal 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]()),
|
||||
),
|
||||
)
|
||||
})
|
||||
406
packages/core/test/tool-bash.test.ts
Normal file
406
packages/core/test/tool-bash.test.ts
Normal 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}`)
|
||||
}
|
||||
})
|
||||
458
packages/core/test/tool-edit.test.ts
Normal file
458
packages/core/test/tool-edit.test.ts
Normal 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}`)
|
||||
}
|
||||
})
|
||||
231
packages/core/test/tool-glob.test.ts
Normal file
231
packages/core/test/tool-glob.test.ts
Normal 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.)",
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
286
packages/core/test/tool-grep.test.ts
Normal file
286
packages/core/test/tool-grep.test.ts
Normal 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 } }))),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
265
packages/core/test/tool-output-store.test.ts
Normal file
265
packages/core/test/tool-output-store.test.ts
Normal 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)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
119
packages/core/test/tool-question.test.ts
Normal file
119
packages/core/test/tool-question.test.ts
Normal 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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
402
packages/core/test/tool-read.test.ts
Normal file
402
packages/core/test/tool-read.test.ts
Normal 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([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
150
packages/core/test/tool-skill.test.ts
Normal file
150
packages/core/test/tool-skill.test.ts
Normal 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))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
106
packages/core/test/tool-todowrite.test.ts
Normal file
106
packages/core/test/tool-todowrite.test.ts
Normal 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: ["*"] }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
295
packages/core/test/tool-webfetch.test.ts
Normal file
295
packages/core/test/tool-webfetch.test.ts
Normal 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" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
331
packages/core/test/tool-websearch.test.ts
Normal file
331
packages/core/test/tool-websearch.test.ts
Normal 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" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
328
packages/core/test/tool-write.test.ts
Normal file
328
packages/core/test/tool-write.test.ts
Normal 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}`)
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue