chore: generate
This commit is contained in:
parent
76ee87ead8
commit
b0a929440b
87 changed files with 2301 additions and 1599 deletions
|
|
@ -114,7 +114,15 @@ describe("AgentV2", () => {
|
|||
)
|
||||
|
||||
const agents = yield* agent.all()
|
||||
expect(agents.map((item) => String(item.id)).sort()).toEqual(["build", "compaction", "explore", "general", "plan", "summary", "title"])
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,10 @@ describe("DatabaseMigration", () => {
|
|||
const layers = [Database.layerFromPath(filename), Database.layerFromPath(filename)]
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.all(layers.map((layer) => Effect.scoped(Layer.build(layer))), { concurrency: "unbounded" }),
|
||||
Effect.all(
|
||||
layers.map((layer) => Effect.scoped(Layer.build(layer))),
|
||||
{ concurrency: "unbounded" },
|
||||
),
|
||||
)
|
||||
})
|
||||
if (process.platform === "linux") {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ describe("KeyedMutex", () => {
|
|||
const secondStarted = yield* Deferred.make<void>()
|
||||
|
||||
const first = yield* mutex
|
||||
.withLock("shared")(Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))))
|
||||
.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)
|
||||
|
|
@ -53,7 +55,9 @@ describe("KeyedMutex", () => {
|
|||
const releaseFirst = yield* Deferred.make<void>()
|
||||
|
||||
const first = yield* mutex
|
||||
.withLock("shared")(Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))))
|
||||
.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)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ import { testEffect } from "./lib/effect"
|
|||
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("project"), workspaceID: WorkspaceV2.ID.make("wrk_test") })),
|
||||
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)))
|
||||
|
|
@ -89,7 +91,9 @@ describe("EventV2", () => {
|
|||
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" }))
|
||||
expect(EventV2.ID.fromExternal({ namespace: "a:b", key: "c" })).not.toBe(
|
||||
EventV2.ID.fromExternal({ namespace: "a", key: "b:c" }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -105,7 +109,10 @@ describe("EventV2", () => {
|
|||
expect(event.type).toBe("test.message")
|
||||
expect(event).not.toHaveProperty("version")
|
||||
expect(event.data).toEqual({ text: "hello" })
|
||||
expect(event.location).toEqual({ directory: AbsolutePath.make("project"), workspaceID: WorkspaceV2.ID.make("wrk_test") })
|
||||
expect(event.location).toEqual({
|
||||
directory: AbsolutePath.make("project"),
|
||||
workspaceID: WorkspaceV2.ID.make("wrk_test"),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -292,7 +299,9 @@ describe("EventV2", () => {
|
|||
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)
|
||||
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" })
|
||||
|
|
@ -309,11 +318,18 @@ describe("EventV2", () => {
|
|||
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)
|
||||
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([
|
||||
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"],
|
||||
])
|
||||
|
|
@ -336,7 +352,9 @@ describe("EventV2", () => {
|
|||
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)
|
||||
const fiber = yield* events
|
||||
.aggregateEvents({ aggregateID })
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Deferred.await(readStarted)
|
||||
|
||||
pause = false
|
||||
|
|
@ -355,7 +373,9 @@ describe("EventV2", () => {
|
|||
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)
|
||||
const fiber = yield* events
|
||||
.aggregateEvents({ aggregateID })
|
||||
.pipe(Stream.take(count), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
for (let index = 0; index < count; index++) {
|
||||
|
|
@ -363,7 +383,10 @@ describe("EventV2", () => {
|
|||
}
|
||||
|
||||
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) }]),
|
||||
Array.from({ length: count }, (_, index) => [
|
||||
EventV2.Cursor.make(index),
|
||||
{ id: aggregateID, text: String(index) },
|
||||
]),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
|
@ -372,7 +395,9 @@ describe("EventV2", () => {
|
|||
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)
|
||||
const fiber = yield* events
|
||||
.aggregateEvents({ aggregateID })
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* events.publish(Message, { text: "live only" })
|
||||
|
|
@ -450,47 +475,49 @@ 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)
|
||||
}),
|
||||
)
|
||||
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)
|
||||
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 })
|
||||
}),
|
||||
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", () =>
|
||||
|
|
@ -750,16 +777,18 @@ describe("EventV2", () => {
|
|||
{ 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)
|
||||
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")
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -89,7 +89,9 @@ describe("FileMutation", () => {
|
|||
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({
|
||||
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")
|
||||
|
|
@ -105,8 +107,20 @@ describe("FileMutation", () => {
|
|||
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)
|
||||
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)),
|
||||
),
|
||||
)
|
||||
|
|
@ -119,7 +133,12 @@ describe("FileMutation", () => {
|
|||
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(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)),
|
||||
),
|
||||
|
|
@ -135,8 +154,20 @@ describe("FileMutation", () => {
|
|||
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)
|
||||
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)),
|
||||
),
|
||||
),
|
||||
|
|
@ -155,10 +186,19 @@ describe("FileMutation", () => {
|
|||
await fs.symlink(outside, parent)
|
||||
})
|
||||
|
||||
expect(yield* (yield* FileMutation.Service).write({ plan, content: "escape" }).pipe(Effect.flip)).toMatchObject({
|
||||
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)
|
||||
expect(
|
||||
yield* Effect.promise(() =>
|
||||
fs.stat(path.join(outside, "new.txt")).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
),
|
||||
).toBe(false)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
),
|
||||
|
|
@ -233,7 +273,9 @@ describe("FileMutation", () => {
|
|||
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)
|
||||
const second = yield* files
|
||||
.writeIfUnchanged({ plan, expected, content: "second" })
|
||||
.pipe(Effect.flip, Effect.forkChild)
|
||||
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
yield* Fiber.join(first)
|
||||
|
|
|
|||
|
|
@ -27,11 +27,11 @@ function provide(directory: string, projectReferences = inertReferences) {
|
|||
)
|
||||
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),
|
||||
)
|
||||
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))
|
||||
}
|
||||
|
||||
|
|
@ -138,10 +138,9 @@ describe("LocationSearch", () => {
|
|||
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"),
|
||||
])
|
||||
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)),
|
||||
),
|
||||
)
|
||||
|
|
@ -191,7 +190,9 @@ describe("LocationSearch", () => {
|
|||
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`))
|
||||
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)
|
||||
|
|
@ -243,7 +244,9 @@ describe("LocationSearch", () => {
|
|||
await fs.symlink(outside, source)
|
||||
})
|
||||
|
||||
expect(Exit.isFailure(yield* (yield* LocationSearch.Service).files({ pattern: "*" }, approved).pipe(Effect.exit))).toBe(true)
|
||||
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)),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -4,10 +4,17 @@ 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"),
|
||||
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: "update",
|
||||
path: "update.txt",
|
||||
chunks: [{ oldLines: ["old"], newLines: ["new"], changeContext: "section", endOfFile: undefined }],
|
||||
movePath: undefined,
|
||||
},
|
||||
{ type: "delete", path: "delete.txt" },
|
||||
])
|
||||
})
|
||||
|
|
@ -19,11 +26,7 @@ describe("Patch", () => {
|
|||
})
|
||||
|
||||
test("derives fuzzy line updates while preserving BOM", () => {
|
||||
const update = Patch.derive(
|
||||
"update.txt",
|
||||
[{ oldLines: [" old "], newLines: ["new"] }],
|
||||
"\uFEFFold\n",
|
||||
)
|
||||
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")
|
||||
})
|
||||
|
|
@ -39,7 +42,9 @@ describe("Patch", () => {
|
|||
})
|
||||
|
||||
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([
|
||||
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",
|
||||
|
|
@ -50,8 +55,14 @@ describe("Patch", () => {
|
|||
})
|
||||
|
||||
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")
|
||||
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",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -25,7 +25,13 @@ const current = Layer.succeed(
|
|||
)
|
||||
const events = EventV2.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 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),
|
||||
|
|
|
|||
|
|
@ -234,7 +234,9 @@ describe("SessionV2.create", () => {
|
|||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const created = yield* session.create({ location })
|
||||
const unavailable = (effect: Effect.Effect<void, SessionV2.NotFoundError | SessionV2.OperationUnavailableError>) =>
|
||||
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")),
|
||||
|
|
|
|||
|
|
@ -87,19 +87,15 @@ describe("SessionProjector", () => {
|
|||
})
|
||||
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)),
|
||||
(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,
|
||||
),
|
||||
(yield* sessions.context(sessionID)).map((message) => (message.type === "user" ? message.text : message.type)),
|
||||
).toEqual(["first", "second"])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
|
|
@ -222,7 +218,9 @@ describe("SessionProjector", () => {
|
|||
summary: "summary",
|
||||
include: "msg-1",
|
||||
})
|
||||
expect(yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)).toMatchObject({
|
||||
expect(
|
||||
yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie),
|
||||
).toMatchObject({
|
||||
agent: "build",
|
||||
model,
|
||||
time_updated: DateTime.toEpochMillis(created),
|
||||
|
|
@ -272,17 +270,36 @@ describe("SessionProjector", () => {
|
|||
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)
|
||||
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)
|
||||
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 })
|
||||
expect(
|
||||
yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie),
|
||||
).toMatchObject({ delivery: "queue", promoted_seq: null })
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -383,7 +383,9 @@ describe("SessionV2.prompt", () => {
|
|||
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" }])
|
||||
expect(yield* session.messages({ sessionID })).toMatchObject([
|
||||
{ id: messageID, type: "user", text: "Reserved prompt" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -19,46 +19,54 @@ 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)
|
||||
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(
|
||||
|
|
@ -80,94 +88,97 @@ describe("toLLMMessages", () => {
|
|||
})
|
||||
|
||||
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: {},
|
||||
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" } },
|
||||
}),
|
||||
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: {},
|
||||
new SessionMessage.AssistantTool({
|
||||
type: "tool",
|
||||
id: "pending",
|
||||
name: "read",
|
||||
state: new SessionMessage.ToolStatePending({ status: "pending", input: '{"path":"README.md"}' }),
|
||||
time: { created },
|
||||
}),
|
||||
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: {},
|
||||
new SessionMessage.AssistantTool({
|
||||
type: "tool",
|
||||
id: "running",
|
||||
name: "read",
|
||||
state: new SessionMessage.ToolStateRunning({
|
||||
status: "running",
|
||||
input: { path: "README.md" },
|
||||
content: [],
|
||||
structured: {},
|
||||
}),
|
||||
time: { created },
|
||||
}),
|
||||
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" },
|
||||
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 },
|
||||
}),
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
],
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
], model)
|
||||
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([
|
||||
|
|
@ -211,7 +222,10 @@ describe("toLLMMessages", () => {
|
|||
name: "write",
|
||||
providerExecuted: true,
|
||||
providerMetadata: { fake: { continuation: "failed" } },
|
||||
result: { type: "error", value: { error: { type: "unknown", message: "Denied" }, content: [], structured: {} } },
|
||||
result: {
|
||||
type: "error",
|
||||
value: { error: { type: "unknown", message: "Denied" }, content: [], structured: {} },
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(messages[1]?.content).toEqual([
|
||||
|
|
@ -231,23 +245,26 @@ describe("toLLMMessages", () => {
|
|||
})
|
||||
|
||||
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)
|
||||
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([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ const permission = Layer.succeed(
|
|||
PermissionV2.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void),
|
||||
Effect.andThen(
|
||||
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
|
|
|
|||
|
|
@ -1979,7 +1979,11 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail hosted tool on raw failure" }), resume: false })
|
||||
yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: new Prompt({ text: "Fail hosted tool on raw failure" }),
|
||||
resume: false,
|
||||
})
|
||||
const failure = providerUnavailable()
|
||||
responseStream = Stream.concat(
|
||||
Stream.fromIterable([
|
||||
|
|
|
|||
|
|
@ -32,36 +32,125 @@ describe("Tool.Progress", () => {
|
|||
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
|
||||
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)
|
||||
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 } })
|
||||
})
|
||||
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: [] } })
|
||||
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") } })
|
||||
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") } })
|
||||
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" } } })
|
||||
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)
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -86,13 +86,10 @@ describe("SkillDiscovery.pull", () => {
|
|||
})
|
||||
|
||||
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",
|
||||
},
|
||||
)
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -33,7 +33,9 @@ const permission = Layer.succeed(
|
|||
assertions.push(input)
|
||||
if (input.action === "edit") editApproved = true
|
||||
}).pipe(
|
||||
Effect.andThen(input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void),
|
||||
Effect.andThen(
|
||||
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
|
|
@ -67,7 +69,10 @@ const filesystem = Layer.effect(
|
|||
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 Deferred.succeed(removeStarted, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(releaseRemove)),
|
||||
Effect.andThen(fs.remove(target, options)),
|
||||
)
|
||||
return fs.remove(target, options)
|
||||
},
|
||||
})
|
||||
|
|
@ -98,7 +103,13 @@ const call = (patchText: string, id = "call-apply-patch") => ({
|
|||
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 exists = (target: string) =>
|
||||
Effect.promise(() =>
|
||||
fs.stat(target).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
)
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
describe("ApplyPatchTool", () => {
|
||||
|
|
@ -109,13 +120,17 @@ describe("ApplyPatchTool", () => {
|
|||
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(
|
||||
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"),
|
||||
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",
|
||||
|
|
@ -132,7 +147,9 @@ describe("ApplyPatchTool", () => {
|
|||
{ 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(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)
|
||||
}),
|
||||
|
|
@ -155,7 +172,11 @@ describe("ApplyPatchTool", () => {
|
|||
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")),
|
||||
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([])
|
||||
|
|
@ -179,7 +200,9 @@ describe("ApplyPatchTool", () => {
|
|||
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`)),
|
||||
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)
|
||||
|
|
@ -190,7 +213,9 @@ describe("ApplyPatchTool", () => {
|
|||
)
|
||||
},
|
||||
([active, outside]) =>
|
||||
Effect.promise(() => Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined)),
|
||||
Effect.promise(() =>
|
||||
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -201,12 +226,18 @@ describe("ApplyPatchTool", () => {
|
|||
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(
|
||||
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`)),
|
||||
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([
|
||||
|
|
@ -218,7 +249,9 @@ describe("ApplyPatchTool", () => {
|
|||
)
|
||||
},
|
||||
([active, outside]) =>
|
||||
Effect.promise(() => Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined)),
|
||||
Effect.promise(() =>
|
||||
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -230,7 +263,11 @@ describe("ApplyPatchTool", () => {
|
|||
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")),
|
||||
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)
|
||||
}),
|
||||
|
|
@ -251,7 +288,9 @@ describe("ApplyPatchTool", () => {
|
|||
withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* registry.execute(call("*** Begin Patch\n*** Add File: existing.txt\n+replacement\n*** End Patch")),
|
||||
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")
|
||||
}),
|
||||
|
|
@ -276,8 +315,13 @@ describe("ApplyPatchTool", () => {
|
|||
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" })
|
||||
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)
|
||||
}),
|
||||
|
|
@ -303,7 +347,11 @@ describe("ApplyPatchTool", () => {
|
|||
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)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -59,11 +59,13 @@ const filesystem = Layer.effect(
|
|||
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)))),
|
||||
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))),
|
||||
})
|
||||
|
|
@ -369,7 +371,10 @@ describe("EditTool", () => {
|
|||
),
|
||||
Effect.andThen((result) =>
|
||||
Effect.gen(function* () {
|
||||
expect(result).toEqual({ type: "error", value: "File changed after permission approval. Read it again before editing." })
|
||||
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([])
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -153,7 +153,14 @@ describe("GlobTool", () => {
|
|||
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 })],
|
||||
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,
|
||||
})
|
||||
|
|
@ -172,7 +179,14 @@ describe("GlobTool", () => {
|
|||
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 })],
|
||||
items: [
|
||||
new LocationSearch.File({
|
||||
path: RelativePath.make("guide.md"),
|
||||
canonical: "/project/docs/guide.md",
|
||||
resource: "docs:guide.md",
|
||||
mtime: 1,
|
||||
}),
|
||||
],
|
||||
truncated: false,
|
||||
partial: false,
|
||||
})
|
||||
|
|
@ -197,7 +211,14 @@ describe("GlobTool", () => {
|
|||
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 })],
|
||||
items: [
|
||||
new LocationSearch.File({
|
||||
path: RelativePath.make("one.ts"),
|
||||
canonical: "/project/one.ts",
|
||||
resource: "one.ts",
|
||||
mtime: 1,
|
||||
}),
|
||||
],
|
||||
truncated: true,
|
||||
partial: true,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ const filesystem = Layer.succeed(
|
|||
real: `/project/${input.path ?? "."}`,
|
||||
directory: "/project",
|
||||
root: "/project",
|
||||
resource: input.reference === undefined ? input.path ?? "." : `${input.reference}:${input.path ?? "."}`,
|
||||
resource: input.reference === undefined ? (input.path ?? ".") : `${input.reference}:${input.path ?? "."}`,
|
||||
reference: input.reference,
|
||||
type: "directory",
|
||||
dev: 1,
|
||||
|
|
@ -87,7 +87,12 @@ const permission = Layer.succeed(
|
|||
}),
|
||||
)
|
||||
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 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")
|
||||
|
||||
|
|
@ -136,7 +141,12 @@ function provideLive(directory: string, projectReferences = references({})) {
|
|||
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))
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -178,7 +188,9 @@ describe("GrepTool", () => {
|
|||
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" }])
|
||||
expect(searches).toEqual([
|
||||
{ pattern: "guide", path: RelativePath.make("docs"), reference: "manual", include: "*.md" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -218,7 +230,8 @@ describe("GrepTool", () => {
|
|||
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)",
|
||||
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)",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
@ -226,7 +239,10 @@ describe("GrepTool", () => {
|
|||
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" })
|
||||
searchFailure = new Ripgrep.InvalidPatternError({
|
||||
pattern: "[",
|
||||
message: "regex parse error: unclosed character class",
|
||||
})
|
||||
|
||||
expect(yield* execute({ pattern: "[" })).toEqual({
|
||||
type: "error",
|
||||
|
|
@ -261,7 +277,9 @@ describe("GrepTool", () => {
|
|||
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 } }))))
|
||||
}).pipe(
|
||||
Effect.provide(provideLive(tmp.path, references({ docs: { name: "docs", kind: "local", path: docs } }))),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -47,9 +47,10 @@ 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 })
|
||||
expect(yield* store.truncate({ sessionID, toolCallID: "call-short", content: "line one\nline two" })).toEqual({
|
||||
content: "line one\nline two",
|
||||
truncated: false,
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -92,7 +93,12 @@ describe("ToolOutputStore", () => {
|
|||
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 })
|
||||
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")
|
||||
|
|
@ -105,7 +111,12 @@ describe("ToolOutputStore", () => {
|
|||
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 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 })
|
||||
|
|
@ -114,7 +125,13 @@ describe("ToolOutputStore", () => {
|
|||
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({
|
||||
expect(
|
||||
JSON.parse(
|
||||
yield* fs.readFileString(
|
||||
path.join(root, "tool-output", "managed", `${resource.uri.slice("tool-output://".length)}.json`),
|
||||
),
|
||||
),
|
||||
).toMatchObject({
|
||||
sessionID,
|
||||
toolCallID: "call-page",
|
||||
})
|
||||
|
|
@ -165,9 +182,9 @@ describe("ToolOutputStore", () => {
|
|||
({ 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,
|
||||
)
|
||||
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 }) }),
|
||||
),
|
||||
|
|
@ -186,7 +203,10 @@ describe("ToolOutputStore", () => {
|
|||
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(
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -362,7 +362,12 @@ describe("ReadTool", () => {
|
|||
expect(
|
||||
yield* registry.execute({
|
||||
sessionID,
|
||||
call: { type: "tool-call", id: "call-large", name: "read", input: { path: "large.txt", offset: 2, limit: 1 } },
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-large",
|
||||
name: "read",
|
||||
input: { path: "large.txt", offset: 2, limit: 1 },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
type: "json",
|
||||
|
|
|
|||
|
|
@ -45,7 +45,12 @@ describe("SkillTool", () => {
|
|||
let bootWaited = false
|
||||
const boot = Layer.succeed(
|
||||
PluginBoot.Service,
|
||||
PluginBoot.Service.of({ wait: () => Effect.sync(() => { bootWaited = true }) }),
|
||||
PluginBoot.Service.of({
|
||||
wait: () =>
|
||||
Effect.sync(() => {
|
||||
bootWaited = true
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const permission = Layer.succeed(
|
||||
PermissionV2.Service,
|
||||
|
|
|
|||
|
|
@ -28,7 +28,9 @@ describe("WebSearchTool provider selection", () => {
|
|||
})
|
||||
|
||||
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 }, "parallel")).toBe(
|
||||
"parallel",
|
||||
)
|
||||
expect(WebSearchTool.selectProvider(sessionID, { enableExa: false, enableParallel: false }, "exa")).toBe("exa")
|
||||
})
|
||||
|
||||
|
|
@ -47,9 +49,11 @@ describe("WebSearchTool MCP response parser", () => {
|
|||
})
|
||||
|
||||
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",
|
||||
)
|
||||
expect(
|
||||
await Effect.runPromise(
|
||||
WebSearchTool.parseResponse(`data: [DONE]\nevent: message\ndata: ${payload("search results")}\n\n`),
|
||||
),
|
||||
).toBe("search results")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -151,7 +155,13 @@ describe("WebSearchTool contribution", () => {
|
|||
type: "tool-call",
|
||||
id: "call-exa",
|
||||
name: "websearch",
|
||||
input: { query: "effect typescript", numResults: 3, livecrawl: "preferred", type: "fast", contextMaxCharacters: 2500 },
|
||||
input: {
|
||||
query: "effect typescript",
|
||||
numResults: 3,
|
||||
livecrawl: "preferred",
|
||||
type: "fast",
|
||||
contextMaxCharacters: 2500,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({ type: "text", value: "exa results" })
|
||||
|
|
@ -161,7 +171,14 @@ describe("WebSearchTool contribution", () => {
|
|||
action: "websearch",
|
||||
resources: ["effect typescript"],
|
||||
save: ["*"],
|
||||
metadata: { query: "effect typescript", numResults: 3, livecrawl: "preferred", type: "fast", contextMaxCharacters: 2500, provider: "exa" },
|
||||
metadata: {
|
||||
query: "effect typescript",
|
||||
numResults: 3,
|
||||
livecrawl: "preferred",
|
||||
type: "fast",
|
||||
contextMaxCharacters: 2500,
|
||||
provider: "exa",
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(requests).toEqual([
|
||||
|
|
@ -174,7 +191,13 @@ describe("WebSearchTool contribution", () => {
|
|||
method: "tools/call",
|
||||
params: {
|
||||
name: "web_search_exa",
|
||||
arguments: { query: "effect typescript", type: "fast", numResults: 3, livecrawl: "preferred", contextMaxCharacters: 2500 },
|
||||
arguments: {
|
||||
query: "effect typescript",
|
||||
type: "fast",
|
||||
numResults: 3,
|
||||
livecrawl: "preferred",
|
||||
contextMaxCharacters: 2500,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -211,7 +234,10 @@ describe("WebSearchTool contribution", () => {
|
|||
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" }] },
|
||||
output: {
|
||||
structured: { provider: "parallel", text: "parallel results", truncated: false },
|
||||
content: [{ type: "text", text: "parallel results" }],
|
||||
},
|
||||
})
|
||||
expect(JSON.stringify(settled)).not.toContain("parallel-secret")
|
||||
}),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue