chore: generate

This commit is contained in:
opencode-agent[bot] 2026-06-04 03:03:39 +00:00
parent 76ee87ead8
commit b0a929440b
87 changed files with 2360 additions and 1658 deletions

View file

@ -1205,13 +1205,9 @@
"table": "session_share"
},
{
"columns": [
"project_id"
],
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1220,13 +1216,9 @@
"table": "workspace"
},
{
"columns": [
"active_account_id"
],
"columns": ["active_account_id"],
"tableTo": "account",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "SET NULL",
"nameExplicit": false,
@ -1235,13 +1227,9 @@
"table": "account_state"
},
{
"columns": [
"aggregate_id"
],
"columns": ["aggregate_id"],
"tableTo": "event_sequence",
"columnsTo": [
"aggregate_id"
],
"columnsTo": ["aggregate_id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1250,13 +1238,9 @@
"table": "event"
},
{
"columns": [
"project_id"
],
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1265,13 +1249,9 @@
"table": "permission"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1280,13 +1260,9 @@
"table": "message"
},
{
"columns": [
"message_id"
],
"columns": ["message_id"],
"tableTo": "message",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1295,13 +1271,9 @@
"table": "part"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1310,13 +1282,9 @@
"table": "session_message"
},
{
"columns": [
"project_id"
],
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1325,13 +1293,9 @@
"table": "session"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1340,13 +1304,9 @@
"table": "todo"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1355,137 +1315,105 @@
"table": "session_share"
},
{
"columns": [
"email",
"url"
],
"columns": ["email", "url"],
"nameExplicit": false,
"name": "control_account_pk",
"entityType": "pks",
"table": "control_account"
},
{
"columns": [
"session_id",
"position"
],
"columns": ["session_id", "position"],
"nameExplicit": false,
"name": "todo_pk",
"entityType": "pks",
"table": "todo"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "workspace_pk",
"table": "workspace",
"entityType": "pks"
},
{
"columns": [
"name"
],
"columns": ["name"],
"nameExplicit": false,
"name": "data_migration_pk",
"table": "data_migration",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "account_state_pk",
"table": "account_state",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "account_pk",
"table": "account",
"entityType": "pks"
},
{
"columns": [
"aggregate_id"
],
"columns": ["aggregate_id"],
"nameExplicit": false,
"name": "event_sequence_pk",
"table": "event_sequence",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "event_pk",
"table": "event",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "permission_pk",
"table": "permission",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "project_pk",
"table": "project",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "message_pk",
"table": "message",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "part_pk",
"table": "part",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "session_message_pk",
"table": "session_message",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "session_pk",
"table": "session",
"entityType": "pks"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"nameExplicit": false,
"name": "session_share_pk",
"table": "session_share",

View file

@ -1333,13 +1333,9 @@
"table": "session_share"
},
{
"columns": [
"project_id"
],
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1348,13 +1344,9 @@
"table": "workspace"
},
{
"columns": [
"active_account_id"
],
"columns": ["active_account_id"],
"tableTo": "account",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "SET NULL",
"nameExplicit": false,
@ -1363,13 +1355,9 @@
"table": "account_state"
},
{
"columns": [
"aggregate_id"
],
"columns": ["aggregate_id"],
"tableTo": "event_sequence",
"columnsTo": [
"aggregate_id"
],
"columnsTo": ["aggregate_id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1378,13 +1366,9 @@
"table": "event"
},
{
"columns": [
"project_id"
],
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1393,13 +1377,9 @@
"table": "permission"
},
{
"columns": [
"project_id"
],
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1408,13 +1388,9 @@
"table": "project_directory"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1423,13 +1399,9 @@
"table": "message"
},
{
"columns": [
"message_id"
],
"columns": ["message_id"],
"tableTo": "message",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1438,13 +1410,9 @@
"table": "part"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1453,13 +1421,9 @@
"table": "session_input"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1468,13 +1432,9 @@
"table": "session_message"
},
{
"columns": [
"project_id"
],
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1483,13 +1443,9 @@
"table": "session"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1498,13 +1454,9 @@
"table": "todo"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1513,156 +1465,119 @@
"table": "session_share"
},
{
"columns": [
"email",
"url"
],
"columns": ["email", "url"],
"nameExplicit": false,
"name": "control_account_pk",
"entityType": "pks",
"table": "control_account"
},
{
"columns": [
"project_id",
"directory"
],
"columns": ["project_id", "directory"],
"nameExplicit": false,
"name": "project_directory_pk",
"entityType": "pks",
"table": "project_directory"
},
{
"columns": [
"session_id",
"position"
],
"columns": ["session_id", "position"],
"nameExplicit": false,
"name": "todo_pk",
"entityType": "pks",
"table": "todo"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "workspace_pk",
"table": "workspace",
"entityType": "pks"
},
{
"columns": [
"name"
],
"columns": ["name"],
"nameExplicit": false,
"name": "data_migration_pk",
"table": "data_migration",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "account_state_pk",
"table": "account_state",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "account_pk",
"table": "account",
"entityType": "pks"
},
{
"columns": [
"aggregate_id"
],
"columns": ["aggregate_id"],
"nameExplicit": false,
"name": "event_sequence_pk",
"table": "event_sequence",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "event_pk",
"table": "event",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "permission_pk",
"table": "permission",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "project_pk",
"table": "project",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "message_pk",
"table": "message",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "part_pk",
"table": "part",
"entityType": "pks"
},
{
"columns": [
"seq"
],
"columns": ["seq"],
"nameExplicit": false,
"name": "session_input_pk",
"table": "session_input",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "session_message_pk",
"table": "session_message",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "session_pk",
"table": "session",
"entityType": "pks"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"nameExplicit": false,
"name": "session_share_pk",
"table": "session_share",
@ -1943,9 +1858,7 @@
"table": "todo"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "session_input_id_unique",
"entityType": "uniques",
@ -1953,4 +1866,4 @@
}
],
"renames": []
}
}

View file

@ -19,7 +19,9 @@ export const Plugin = PluginV2.define({
const transform = yield* skill.transform()
const entries = yield* config.entries()
const items = entries.flatMap((entry) =>
entry.type === "document" ? (entry.info.skills ?? []) : [path.join(entry.path, "skill"), path.join(entry.path, "skills")],
entry.type === "document"
? (entry.info.skills ?? [])
: [path.join(entry.path, "skill"), path.join(entry.path, "skills")],
)
yield* transform((editor) => {

View file

@ -8,8 +8,12 @@ export default {
yield* tx.run(`DROP INDEX IF EXISTS \`session_message_session_idx\`;`)
yield* tx.run(`DROP INDEX IF EXISTS \`session_message_session_type_idx\`;`)
yield* tx.run(`CREATE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`)
yield* tx.run(`CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`)
yield* tx.run(`CREATE INDEX \`session_message_session_type_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`time_created\`,\`id\`);`)
yield* tx.run(
`CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`,
)
yield* tx.run(
`CREATE INDEX \`session_message_session_type_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`time_created\`,\`id\`);`,
)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -6,13 +6,20 @@ export default {
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session_message\` ADD COLUMN \`seq\` integer NOT NULL DEFAULT 0;`)
yield* tx.run(`UPDATE \`session_message\` SET \`seq\` = COALESCE((SELECT \`seq\` + 1 FROM \`event\` WHERE \`event\`.\`id\` = \`session_message\`.\`id\`), 0);`)
const unmatched = yield* tx.get<{ count: number }>(`SELECT COUNT(*) AS \`count\` FROM \`session_message\` WHERE \`seq\` = 0;`)
if ((unmatched?.count ?? 0) > 0) return yield* Effect.die("Cannot migrate session_message projections without matching durable events")
yield* tx.run(
`UPDATE \`session_message\` SET \`seq\` = COALESCE((SELECT \`seq\` + 1 FROM \`event\` WHERE \`event\`.\`id\` = \`session_message\`.\`id\`), 0);`,
)
const unmatched = yield* tx.get<{ count: number }>(
`SELECT COUNT(*) AS \`count\` FROM \`session_message\` WHERE \`seq\` = 0;`,
)
if ((unmatched?.count ?? 0) > 0)
return yield* Effect.die("Cannot migrate session_message projections without matching durable events")
yield* tx.run(`UPDATE \`session_message\` SET \`seq\` = \`seq\` - 1;`)
yield* tx.run(`DROP INDEX IF EXISTS \`session_message_session_type_time_created_id_idx\`;`)
yield* tx.run(`CREATE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`)
yield* tx.run(`CREATE INDEX \`session_message_session_type_seq_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`seq\`);`)
yield* tx.run(
`CREATE INDEX \`session_message_session_type_seq_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`seq\`);`,
)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -6,7 +6,9 @@ export default {
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DROP INDEX IF EXISTS \`session_input_session_pending_seq_idx\`;`)
yield* tx.run(`CREATE INDEX IF NOT EXISTS \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`)
yield* tx.run(
`CREATE INDEX IF NOT EXISTS \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`,
)
yield* tx.run(
`CREATE INDEX IF NOT EXISTS \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`seq\`);`,
)

View file

@ -20,21 +20,23 @@ export interface KeyedMutex<in Key> {
export const makeUnsafe = <Key>(): KeyedMutex<Key> => {
const locks = new Map<Key, { readonly semaphore: Semaphore.Semaphore; users: number }>()
const withLock = (key: Key) => <A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.suspend(() => {
const current = locks.get(key)
const entry = current ?? { semaphore: Semaphore.makeUnsafe(1), users: 0 }
if (!current) locks.set(key, entry)
entry.users++
return entry.semaphore.withPermit(effect).pipe(
Effect.ensuring(
Effect.sync(() => {
entry.users--
if (entry.users === 0) locks.delete(key)
}),
),
)
})
const withLock =
(key: Key) =>
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.suspend(() => {
const current = locks.get(key)
const entry = current ?? { semaphore: Semaphore.makeUnsafe(1), users: 0 }
if (!current) locks.set(key, entry)
entry.users++
return entry.semaphore.withPermit(effect).pipe(
Effect.ensuring(
Effect.sync(() => {
entry.users--
if (entry.users === 0) locks.delete(key)
}),
),
)
})
return { size: Effect.sync(() => locks.size), withLock }
}

View file

@ -146,7 +146,10 @@ export interface Interface {
) => Effect.Effect<Payload<D>>
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
readonly all: () => Stream.Stream<Payload>
readonly aggregateEvents: (input: { readonly aggregateID: string; readonly after?: Cursor }) => Stream.Stream<CursorEvent>
readonly aggregateEvents: (input: {
readonly aggregateID: string
readonly after?: Cursor
}) => Stream.Stream<CursorEvent>
readonly sync: (handler: Sync) => Effect.Effect<Unsubscribe>
readonly listen: (listener: Listener) => Effect.Effect<Unsubscribe>
readonly beforeCommit: (guard: CommitGuard) => Effect.Effect<void>
@ -169,394 +172,434 @@ export interface LayerOptions {
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
}
export const layerWith = (options?: LayerOptions) => Layer.effect(
Service,
Effect.gen(function* () {
const all = yield* PubSub.unbounded<Payload>()
const synchronized = new Map<string, Set<PubSub.PubSub<void>>>()
const typed = new Map<string, PubSub.PubSub<Payload>>()
const projectors = new Map<string, AnyProjector[]>()
const commitGuards = new Array<CommitGuard>()
const listeners = new Array<Listener>()
const syncHandlers = new Array<Sync>()
const { db } = yield* Database.Service
export const layerWith = (options?: LayerOptions) =>
Layer.effect(
Service,
Effect.gen(function* () {
const all = yield* PubSub.unbounded<Payload>()
const synchronized = new Map<string, Set<PubSub.PubSub<void>>>()
const typed = new Map<string, PubSub.PubSub<Payload>>()
const projectors = new Map<string, AnyProjector[]>()
const commitGuards = new Array<CommitGuard>()
const listeners = new Array<Listener>()
const syncHandlers = new Array<Sync>()
const { db } = yield* Database.Service
const getOrCreate = (definition: Definition) =>
Effect.gen(function* () {
const existing = typed.get(definition.type)
if (existing) return existing
const pubsub = yield* PubSub.unbounded<Payload>()
typed.set(definition.type, pubsub)
return pubsub
})
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
yield* PubSub.shutdown(all)
yield* Effect.forEach(synchronized.values(), (pubsubs) =>
Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }),
{ discard: true })
yield* Effect.forEach(typed.values(), PubSub.shutdown, { discard: true })
}),
)
function commitSyncEvent(
event: Payload,
input?: { readonly seq: number; readonly aggregateID: string; readonly ownerID?: string; readonly strictOwner?: boolean },
) {
return Effect.gen(function* () {
const definition = registry.get(event.type)
const sync = definition?.sync
if (sync) {
if (event.version !== sync.version) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Expected event version ${sync.version}, got ${event.version}`,
}),
)
}
const aggregateID = (event.data as Record<string, unknown>)[sync.aggregate]
if (typeof aggregateID !== "string") {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Expected string aggregate field ${sync.aggregate}`,
}),
)
} else {
if (input && input.aggregateID !== aggregateID) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
}),
)
}
const list = projectors.get(event.type) ?? []
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const committed = yield* db
.transaction(
() =>
Effect.gen(function* () {
const row = yield* db
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.get()
.pipe(Effect.orDie)
const latest = row?.seq ?? -1
if (input && input.seq <= latest) return
if (input && row?.ownerID && row.ownerID !== input.ownerID) {
if (input.strictOwner) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
}),
)
}
return
}
const seq = input?.seq ?? latest + 1
if (input && seq !== latest + 1) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
}),
)
}
for (const guard of commitGuards) {
yield* guard(event)
}
for (const projector of list) {
yield* projector({ ...event, seq } as Payload)
}
const encoded = syncRegistry.get(versionedType(definition.type, sync.version))!.encode(event.data)
yield* db
.insert(EventSequenceTable)
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq, ...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}) },
})
.run()
.pipe(Effect.orDie)
yield* db
.insert(EventTable)
.values([
{
id: event.id,
aggregate_id: aggregateID,
seq,
type: versionedType(definition.type, sync.version),
data: encoded as Record<string, unknown>,
},
])
.run()
.pipe(Effect.orDie)
return { aggregateID, seq }
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
if (committed) {
yield* Effect.forEach(
synchronized.get(committed.aggregateID) ?? [],
(pubsub) => PubSub.publish(pubsub, undefined),
{ discard: true },
)
}
return committed
}),
)
}
}
})
}
function publishEvent<D extends Definition>(event: Payload<D>) {
return Effect.gen(function* () {
const durable = registry.get(event.type)?.sync !== undefined
if (durable) {
for (const sync of syncHandlers) {
yield* sync(event as Payload)
}
const committed = yield* commitSyncEvent(event as Payload)
if (committed) event = { ...event, seq: committed.seq }
}
for (const listener of listeners) {
yield* listener(event as Payload)
}
const pubsub = typed.get(event.type)
if (pubsub) yield* PubSub.publish(pubsub, event as Payload)
yield* PubSub.publish(all, event as Payload)
return event
})
}
function publish<D extends Definition>(definition: D, data: Data<D>, options?: PublishOptions) {
return Effect.gen(function* () {
const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
const location =
options?.location ??
(serviceLocation
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined)
return yield* publishEvent({
id: options?.id ?? ID.create(),
...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type,
...(definition.sync === undefined ? {} : { version: definition.sync.version }),
...(location ? { location } : {}),
data,
} as Payload<D>)
})
}
function replay(event: SerializedEvent, options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean }) {
return Effect.gen(function* () {
const definition = syncRegistry.get(event.type)
if (!definition) {
yield* Effect.die(
new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` }),
)
} else {
const payload = {
id: event.id,
type: definition.type,
version: definition.sync.version,
data: definition.decode(event.data),
} as Payload
const committed = yield* commitSyncEvent(payload, { seq: event.seq, aggregateID: event.aggregateID, ownerID: options?.ownerID, strictOwner: options?.strictOwner })
if (committed && options?.publish) {
const published = { ...payload, seq: committed.seq }
for (const listener of listeners) {
yield* listener(published)
}
const pubsub = typed.get(payload.type)
if (pubsub) yield* PubSub.publish(pubsub, published)
yield* PubSub.publish(all, published)
}
}
})
}
function replayAll(events: SerializedEvent[], options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean }) {
return Effect.gen(function* () {
const source = events[0]?.aggregateID
if (!source) return undefined
if (events.some((event) => event.aggregateID !== source)) {
yield* Effect.die(
new InvalidSyncEventError({
type: events[0]?.type ?? "unknown",
message: "Replay events must belong to the same aggregate",
}),
)
}
const start = events[0]?.seq ?? 0
for (const [index, event] of events.entries()) {
const seq = start + index
if (event.seq !== seq) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Replay sequence mismatch at index ${index}: expected ${seq}, got ${event.seq}`,
}),
)
}
}
for (const event of events) {
yield* replay(event, options)
}
return source
})
}
function remove(aggregateID: string) {
return db
.transaction(() =>
Effect.gen(function* () {
yield* db.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run()
yield* db.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run()
}),
)
.pipe(Effect.orDie)
}
function claim(aggregateID: string, ownerID: string) {
return db
.update(EventSequenceTable)
.set({ owner_id: ownerID })
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.run()
.pipe(Effect.orDie)
}
const subscribe = <D extends Definition>(definition: D): Stream.Stream<Payload<D>> =>
Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe(
Stream.map((event) => event as Payload<D>),
)
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(all)
const decodeSerializedEvent = (event: SerializedEvent): CursorEvent => {
const definition = syncRegistry.get(event.type)
if (!definition) {
throw new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` })
}
return {
cursor: Cursor.make(event.seq),
event: {
id: event.id,
type: definition.type,
version: definition.sync.version,
seq: event.seq,
data: definition.decode(event.data),
},
}
}
const readAfter = (aggregateID: string, after: number) =>
(options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe(
Effect.andThen(
db
.select()
.from(EventTable)
.where(and(eq(EventTable.aggregate_id, aggregateID), gt(EventTable.seq, after)))
.orderBy(asc(EventTable.seq))
.all(),
),
Effect.orDie,
Effect.map((rows) =>
rows.map((event) =>
decodeSerializedEvent({
id: event.id,
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
data: event.data,
}),
),
),
)
const subscribeSynchronized = (aggregateID: string) =>
Effect.gen(function* () {
const pubsub = yield* PubSub.sliding<void>(1)
const subscription = yield* PubSub.subscribe(pubsub)
yield* Effect.acquireRelease(
Effect.sync(() => {
const pubsubs = synchronized.get(aggregateID) ?? new Set()
pubsubs.add(pubsub)
synchronized.set(aggregateID, pubsubs)
}),
() =>
Effect.sync(() => {
const pubsubs = synchronized.get(aggregateID)
pubsubs?.delete(pubsub)
if (pubsubs?.size === 0) synchronized.delete(aggregateID)
}).pipe(Effect.andThen(PubSub.shutdown(pubsub))),
)
return subscription
})
const streamEvents = (input: { readonly aggregateID: string; readonly after?: Cursor }): Stream.Stream<CursorEvent> =>
Stream.unwrap(
const getOrCreate = (definition: Definition) =>
Effect.gen(function* () {
const synchronized = yield* subscribeSynchronized(input.aggregateID)
let cursor = input.after ?? -1
const read = Effect.suspend(() => readAfter(input.aggregateID, cursor)).pipe(
Effect.tap((events) =>
Effect.sync(() => {
cursor = events.at(-1)?.cursor ?? cursor
}),
),
const existing = typed.get(definition.type)
if (existing) return existing
const pubsub = yield* PubSub.unbounded<Payload>()
typed.set(definition.type, pubsub)
return pubsub
})
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
yield* PubSub.shutdown(all)
yield* Effect.forEach(
synchronized.values(),
(pubsubs) => Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }),
{ discard: true },
)
const historical = yield* read
const live = Stream.fromSubscription(synchronized).pipe(
Stream.mapEffect(() => read),
Stream.flattenIterable,
)
return Stream.concat(Stream.fromIterable(historical), live)
yield* Effect.forEach(typed.values(), PubSub.shutdown, { discard: true })
}),
)
const listen = (listener: Listener): Effect.Effect<Unsubscribe> =>
Effect.sync(() => {
listeners.push(listener)
return Effect.sync(() => {
const index = listeners.indexOf(listener)
if (index >= 0) listeners.splice(index, 1)
function commitSyncEvent(
event: Payload,
input?: {
readonly seq: number
readonly aggregateID: string
readonly ownerID?: string
readonly strictOwner?: boolean
},
) {
return Effect.gen(function* () {
const definition = registry.get(event.type)
const sync = definition?.sync
if (sync) {
if (event.version !== sync.version) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Expected event version ${sync.version}, got ${event.version}`,
}),
)
}
const aggregateID = (event.data as Record<string, unknown>)[sync.aggregate]
if (typeof aggregateID !== "string") {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Expected string aggregate field ${sync.aggregate}`,
}),
)
} else {
if (input && input.aggregateID !== aggregateID) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
}),
)
}
const list = projectors.get(event.type) ?? []
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const committed = yield* db
.transaction(
() =>
Effect.gen(function* () {
const row = yield* db
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.get()
.pipe(Effect.orDie)
const latest = row?.seq ?? -1
if (input && input.seq <= latest) return
if (input && row?.ownerID && row.ownerID !== input.ownerID) {
if (input.strictOwner) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
}),
)
}
return
}
const seq = input?.seq ?? latest + 1
if (input && seq !== latest + 1) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
}),
)
}
for (const guard of commitGuards) {
yield* guard(event)
}
for (const projector of list) {
yield* projector({ ...event, seq } as Payload)
}
const encoded = syncRegistry
.get(versionedType(definition.type, sync.version))!
.encode(event.data)
yield* db
.insert(EventSequenceTable)
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: {
seq,
...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}),
},
})
.run()
.pipe(Effect.orDie)
yield* db
.insert(EventTable)
.values([
{
id: event.id,
aggregate_id: aggregateID,
seq,
type: versionedType(definition.type, sync.version),
data: encoded as Record<string, unknown>,
},
])
.run()
.pipe(Effect.orDie)
return { aggregateID, seq }
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
if (committed) {
yield* Effect.forEach(
synchronized.get(committed.aggregateID) ?? [],
(pubsub) => PubSub.publish(pubsub, undefined),
{ discard: true },
)
}
return committed
}),
)
}
}
})
})
}
const sync = (handler: Sync): Effect.Effect<Unsubscribe> =>
Effect.sync(() => {
syncHandlers.push(handler)
return Effect.sync(() => {
const index = syncHandlers.indexOf(handler)
if (index >= 0) syncHandlers.splice(index, 1)
function publishEvent<D extends Definition>(event: Payload<D>) {
return Effect.gen(function* () {
const durable = registry.get(event.type)?.sync !== undefined
if (durable) {
for (const sync of syncHandlers) {
yield* sync(event as Payload)
}
const committed = yield* commitSyncEvent(event as Payload)
if (committed) event = { ...event, seq: committed.seq }
}
for (const listener of listeners) {
yield* listener(event as Payload)
}
const pubsub = typed.get(event.type)
if (pubsub) yield* PubSub.publish(pubsub, event as Payload)
yield* PubSub.publish(all, event as Payload)
return event
})
})
}
const beforeCommit = (guard: CommitGuard): Effect.Effect<void> =>
Effect.sync(() => {
commitGuards.push(guard)
})
function publish<D extends Definition>(definition: D, data: Data<D>, options?: PublishOptions) {
return Effect.gen(function* () {
const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
const location =
options?.location ??
(serviceLocation
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined)
return yield* publishEvent({
id: options?.id ?? ID.create(),
...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type,
...(definition.sync === undefined ? {} : { version: definition.sync.version }),
...(location ? { location } : {}),
data,
} as Payload<D>)
})
}
const project = <D extends Definition>(definition: D, projector: Projector<D>): Effect.Effect<void> =>
Effect.sync(() => {
const list = projectors.get(definition.type) ?? []
list.push((event) => projector(event as Payload<D>))
projectors.set(definition.type, list)
})
function replay(
event: SerializedEvent,
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
) {
return Effect.gen(function* () {
const definition = syncRegistry.get(event.type)
if (!definition) {
yield* Effect.die(
new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` }),
)
} else {
const payload = {
id: event.id,
type: definition.type,
version: definition.sync.version,
data: definition.decode(event.data),
} as Payload
const committed = yield* commitSyncEvent(payload, {
seq: event.seq,
aggregateID: event.aggregateID,
ownerID: options?.ownerID,
strictOwner: options?.strictOwner,
})
if (committed && options?.publish) {
const published = { ...payload, seq: committed.seq }
for (const listener of listeners) {
yield* listener(published)
}
const pubsub = typed.get(payload.type)
if (pubsub) yield* PubSub.publish(pubsub, published)
yield* PubSub.publish(all, published)
}
}
})
}
return Service.of({ publish, subscribe, all: streamAll, aggregateEvents: streamEvents, sync, listen, beforeCommit, project, replay, replayAll, remove, claim })
}),
)
function replayAll(
events: SerializedEvent[],
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
) {
return Effect.gen(function* () {
const source = events[0]?.aggregateID
if (!source) return undefined
if (events.some((event) => event.aggregateID !== source)) {
yield* Effect.die(
new InvalidSyncEventError({
type: events[0]?.type ?? "unknown",
message: "Replay events must belong to the same aggregate",
}),
)
}
const start = events[0]?.seq ?? 0
for (const [index, event] of events.entries()) {
const seq = start + index
if (event.seq !== seq) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Replay sequence mismatch at index ${index}: expected ${seq}, got ${event.seq}`,
}),
)
}
}
for (const event of events) {
yield* replay(event, options)
}
return source
})
}
function remove(aggregateID: string) {
return db
.transaction(() =>
Effect.gen(function* () {
yield* db.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run()
yield* db.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run()
}),
)
.pipe(Effect.orDie)
}
function claim(aggregateID: string, ownerID: string) {
return db
.update(EventSequenceTable)
.set({ owner_id: ownerID })
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.run()
.pipe(Effect.orDie)
}
const subscribe = <D extends Definition>(definition: D): Stream.Stream<Payload<D>> =>
Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe(
Stream.map((event) => event as Payload<D>),
)
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(all)
const decodeSerializedEvent = (event: SerializedEvent): CursorEvent => {
const definition = syncRegistry.get(event.type)
if (!definition) {
throw new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` })
}
return {
cursor: Cursor.make(event.seq),
event: {
id: event.id,
type: definition.type,
version: definition.sync.version,
seq: event.seq,
data: definition.decode(event.data),
},
}
}
const readAfter = (aggregateID: string, after: number) =>
(options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe(
Effect.andThen(
db
.select()
.from(EventTable)
.where(and(eq(EventTable.aggregate_id, aggregateID), gt(EventTable.seq, after)))
.orderBy(asc(EventTable.seq))
.all(),
),
Effect.orDie,
Effect.map((rows) =>
rows.map((event) =>
decodeSerializedEvent({
id: event.id,
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
data: event.data,
}),
),
),
)
const subscribeSynchronized = (aggregateID: string) =>
Effect.gen(function* () {
const pubsub = yield* PubSub.sliding<void>(1)
const subscription = yield* PubSub.subscribe(pubsub)
yield* Effect.acquireRelease(
Effect.sync(() => {
const pubsubs = synchronized.get(aggregateID) ?? new Set()
pubsubs.add(pubsub)
synchronized.set(aggregateID, pubsubs)
}),
() =>
Effect.sync(() => {
const pubsubs = synchronized.get(aggregateID)
pubsubs?.delete(pubsub)
if (pubsubs?.size === 0) synchronized.delete(aggregateID)
}).pipe(Effect.andThen(PubSub.shutdown(pubsub))),
)
return subscription
})
const streamEvents = (input: {
readonly aggregateID: string
readonly after?: Cursor
}): Stream.Stream<CursorEvent> =>
Stream.unwrap(
Effect.gen(function* () {
const synchronized = yield* subscribeSynchronized(input.aggregateID)
let cursor = input.after ?? -1
const read = Effect.suspend(() => readAfter(input.aggregateID, cursor)).pipe(
Effect.tap((events) =>
Effect.sync(() => {
cursor = events.at(-1)?.cursor ?? cursor
}),
),
)
const historical = yield* read
const live = Stream.fromSubscription(synchronized).pipe(
Stream.mapEffect(() => read),
Stream.flattenIterable,
)
return Stream.concat(Stream.fromIterable(historical), live)
}),
)
const listen = (listener: Listener): Effect.Effect<Unsubscribe> =>
Effect.sync(() => {
listeners.push(listener)
return Effect.sync(() => {
const index = listeners.indexOf(listener)
if (index >= 0) listeners.splice(index, 1)
})
})
const sync = (handler: Sync): Effect.Effect<Unsubscribe> =>
Effect.sync(() => {
syncHandlers.push(handler)
return Effect.sync(() => {
const index = syncHandlers.indexOf(handler)
if (index >= 0) syncHandlers.splice(index, 1)
})
})
const beforeCommit = (guard: CommitGuard): Effect.Effect<void> =>
Effect.sync(() => {
commitGuards.push(guard)
})
const project = <D extends Definition>(definition: D, projector: Projector<D>): Effect.Effect<void> =>
Effect.sync(() => {
const list = projectors.get(definition.type) ?? []
list.push((event) => projector(event as Payload<D>))
projectors.set(definition.type, list)
})
return Service.of({
publish,
subscribe,
all: streamAll,
aggregateEvents: streamEvents,
sync,
listen,
beforeCommit,
project,
replay,
replayAll,
remove,
claim,
})
}),
)
export const layer = layerWith()

View file

@ -52,17 +52,23 @@ export interface RemoveResult {
export interface Interface {
/** Create only while the planned target remains absent. */
readonly create: (input: WriteInput) => Effect.Effect<WriteResult, TargetExistsError | LocationMutation.RevalidationError | FSUtil.Error>
readonly create: (
input: WriteInput,
) => Effect.Effect<WriteResult, TargetExistsError | LocationMutation.RevalidationError | FSUtil.Error>
/** Write after immediately revalidating the planned target. */
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, LocationMutation.RevalidationError | FSUtil.Error>
/** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<WriteResult, LocationMutation.RevalidationError | FSUtil.Error>
readonly writeTextPreservingBom: (
input: TextWriteInput,
) => Effect.Effect<WriteResult, LocationMutation.RevalidationError | FSUtil.Error>
/** Commit only if an existing target still has the expected bytes. */
readonly writeIfUnchanged: (
input: ConditionalWriteInput,
) => Effect.Effect<WriteResult, StaleContentError | LocationMutation.RevalidationError | FSUtil.Error>
/** Remove after immediately revalidating the planned target. */
readonly remove: (input: RemoveInput) => Effect.Effect<RemoveResult, LocationMutation.RevalidationError | FSUtil.Error>
readonly remove: (
input: RemoveInput,
) => Effect.Effect<RemoveResult, LocationMutation.RevalidationError | FSUtil.Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileMutation") {}
@ -92,12 +98,15 @@ export const layer = Layer.effect(
const fs = yield* FSUtil.Service
const mutation = yield* LocationMutation.Service
const locks = KeyedMutex.makeUnsafe<string>()
const withTargetLock = (target: string) => <A, E, R>(effect: Effect.Effect<A, E, R>) =>
locks.withLock(target)(Effect.uninterruptible(effect))
const withTargetLock =
(target: string) =>
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
locks.withLock(target)(Effect.uninterruptible(effect))
const withValidatedTarget = (plan: LocationMutation.Plan) => <A, E, R>(
commit: (target: LocationMutation.Target) => Effect.Effect<A, E, R>,
) => withTargetLock(plan.target.canonical)(mutation.revalidate(plan).pipe(Effect.flatMap(commit)))
const withValidatedTarget =
(plan: LocationMutation.Plan) =>
<A, E, R>(commit: (target: LocationMutation.Target) => Effect.Effect<A, E, R>) =>
withTargetLock(plan.target.canonical)(mutation.revalidate(plan).pipe(Effect.flatMap(commit)))
const writeResult = (target: LocationMutation.Target, existed = target.exists): WriteResult => ({
operation: "write",
@ -138,7 +147,8 @@ export const layer = Layer.effect(
Effect.gen(function* () {
if (target.exists) return yield* new TargetExistsError({ path: target.canonical })
yield* fs.ensureDir(dirname(target.canonical))
if (typeof input.content === "string") yield* fs.writeFileString(target.canonical, input.content, { flag: "wx" })
if (typeof input.content === "string")
yield* fs.writeFileString(target.canonical, input.content, { flag: "wx" })
else yield* fs.writeFile(target.canonical, input.content, { flag: "wx" })
return writeResult(target, false)
}),

View file

@ -447,7 +447,11 @@ export const layer = Layer.effect(
const canonical = yield* fs.realPath(target.absolute).pipe(Effect.orDie)
if (canonical !== target.real) return yield* Effect.die(new Error("Search root changed after approval"))
const info = yield* fs.stat(canonical).pipe(Effect.orDie)
if (info.type !== (target.type === "file" ? "File" : "Directory") || info.dev !== target.dev || Option.getOrUndefined(info.ino) !== target.ino)
if (
info.type !== (target.type === "file" ? "File" : "Directory") ||
info.dev !== target.dev ||
Option.getOrUndefined(info.ino) !== target.ino
)
return yield* Effect.die(new Error("Search root identity changed after approval"))
return target
})

View file

@ -241,7 +241,13 @@ export const layer = Layer.effect(
const directory =
boundary.exists && boundary.type === "Directory" ? boundary.canonical : boundary.authority.canonical
const resource = slash(path.join(directory, "*"))
return { action: "external_directory" as const, directory, resource, save: resource, authority: boundary.authority }
return {
action: "external_directory" as const,
directory,
resource,
save: resource,
authority: boundary.authority,
}
})
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {

View file

@ -83,7 +83,10 @@ export class GrepResult extends Schema.Class<GrepResult>("LocationSearch.GrepRes
export interface Interface {
readonly files: (input: FilesInput, root?: FileSystem.RootTarget) => Effect.Effect<FilesResult, Ripgrep.Error>
readonly grep: (input: GrepInput, root?: FileSystem.RootTarget) => Effect.Effect<GrepResult, Ripgrep.Error | Ripgrep.InvalidPatternError>
readonly grep: (
input: GrepInput,
root?: FileSystem.RootTarget,
) => Effect.Effect<GrepResult, Ripgrep.Error | Ripgrep.InvalidPatternError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/LocationSearch") {}

View file

@ -30,10 +30,10 @@ const DefaultSessions = SessionV2.layer.pipe(
// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence.
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
return Service.of({ sessions: yield* SessionV2.Service })
}),
).pipe(Layer.provide(DefaultSessions))
Service,
Effect.gen(function* () {
return Service.of({ sessions: yield* SessionV2.Service })
}),
).pipe(Layer.provide(DefaultSessions))
// TODO: Add OpenCode.create(...) as the Promise facade over the same embedded API semantics.

View file

@ -3,7 +3,12 @@ export * as Patch from "./patch"
export type Hunk =
| { readonly type: "add"; readonly path: string; readonly contents: string }
| { readonly type: "delete"; readonly path: string }
| { readonly type: "update"; readonly path: string; readonly movePath?: string; readonly chunks: ReadonlyArray<UpdateFileChunk> }
| {
readonly type: "update"
readonly path: string
readonly movePath?: string
readonly chunks: ReadonlyArray<UpdateFileChunk>
}
export interface UpdateFileChunk {
readonly oldLines: ReadonlyArray<string>
@ -166,7 +171,12 @@ function seek(lines: ReadonlyArray<string>, pattern: ReadonlyArray<string>, star
return -1
}
function matches(lines: ReadonlyArray<string>, pattern: ReadonlyArray<string>, offset: number, compare: (left: string, right: string) => boolean) {
function matches(
lines: ReadonlyArray<string>,
pattern: ReadonlyArray<string>,
offset: number,
compare: (left: string, right: string) => boolean,
) {
return pattern.every((line, index) => compare(lines[offset + index]!, line))
}
@ -174,6 +184,14 @@ const exact = (left: string, right: string) => left === right
const rstrip = (left: string, right: string) => left.trimEnd() === right.trimEnd()
const trim = (left: string, right: string) => left.trim() === right.trim()
const normalized = (left: string, right: string) => normalize(left.trim()) === normalize(right.trim())
const normalize = (value: string) => value.replace(/[]/g, "'").replace(/[“”„‟]/g, '"').replace(/[‐‑‒–—―]/g, "-").replace(/…/g, "...").replace(/ /g, " ")
const splitBom = (text: string) => text.startsWith("\uFEFF") ? { bom: true, text: text.slice(1) } : { bom: false, text }
const stripHeredoc = (input: string) => input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/)?.[2] ?? input
const normalize = (value: string) =>
value
.replace(/[]/g, "'")
.replace(/[“”„‟]/g, '"')
.replace(/[‐‑‒–—―]/g, "-")
.replace(/…/g, "...")
.replace(/ /g, " ")
const splitBom = (text: string) =>
text.startsWith("\uFEFF") ? { bom: true, text: text.slice(1) } : { bom: false, text }
const stripHeredoc = (input: string) =>
input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/)?.[2] ?? input

View file

@ -200,9 +200,9 @@ export const layer = Layer.effect(
const item = { request, deferred }
if (pending.has(request.id)) return yield* EffectRuntime.die(`Duplicate pending permission ID: ${request.id}`)
pending.set(request.id, item)
yield* events.publish(Event.Asked, request).pipe(
EffectRuntime.onError(() => EffectRuntime.sync(() => pending.delete(request.id))),
)
yield* events
.publish(Event.Asked, request)
.pipe(EffectRuntime.onError(() => EffectRuntime.sync(() => pending.delete(request.id))))
return item
}),
)
@ -236,69 +236,73 @@ export const layer = Layer.effect(
),
)
const reply = EffectRuntime.fn("PermissionV2.reply")((input: ReplyInput) => EffectRuntime.uninterruptible(EffectRuntime.gen(function* () {
const existing = pending.get(input.requestID)
if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
yield* events.publish(Event.Replied, {
sessionID: existing.request.sessionID,
requestID: existing.request.id,
reply: input.reply,
})
if (input.reply === "reject") {
yield* Deferred.fail(
existing.deferred,
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
)
pending.delete(input.requestID)
for (const [id, item] of pending) {
if (item.request.sessionID !== existing.request.sessionID) continue
const reply = EffectRuntime.fn("PermissionV2.reply")((input: ReplyInput) =>
EffectRuntime.uninterruptible(
EffectRuntime.gen(function* () {
const existing = pending.get(input.requestID)
if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
yield* events.publish(Event.Replied, {
sessionID: item.request.sessionID,
requestID: item.request.id,
reply: "reject",
sessionID: existing.request.sessionID,
requestID: existing.request.id,
reply: input.reply,
})
yield* Deferred.fail(item.deferred, new RejectedError())
pending.delete(id)
}
return
}
if (input.reply === "always" && existing.request.save?.length) {
yield* saved.add({
projectID: location.project.id,
action: existing.request.action,
resources: existing.request.save,
})
}
yield* Deferred.succeed(existing.deferred, undefined)
pending.delete(input.requestID)
if (input.reply !== "always" || !existing.request.save?.length) return
if (input.reply === "reject") {
yield* Deferred.fail(
existing.deferred,
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
)
pending.delete(input.requestID)
for (const [id, item] of pending) {
if (item.request.sessionID !== existing.request.sessionID) continue
yield* events.publish(Event.Replied, {
sessionID: item.request.sessionID,
requestID: item.request.id,
reply: "reject",
})
yield* Deferred.fail(item.deferred, new RejectedError())
pending.delete(id)
}
return
}
const rememberedRules = yield* savedRules()
for (const [id, item] of pending) {
const input = { ...item.request }
const rules = yield* configured(item.request.sessionID).pipe(
EffectRuntime.catchTag("Session.NotFoundError", () => EffectRuntime.succeed(undefined)),
)
if (!rules) continue
if (denied(input, rules)) continue
const effective = [...rules, ...rememberedRules]
if (
!item.request.resources.every(
(resource) => evaluate(item.request.action, resource, effective).effect === "allow",
)
)
continue
yield* events.publish(Event.Replied, {
sessionID: item.request.sessionID,
requestID: item.request.id,
reply: "always",
})
yield* Deferred.succeed(item.deferred, undefined)
pending.delete(id)
}
})))
if (input.reply === "always" && existing.request.save?.length) {
yield* saved.add({
projectID: location.project.id,
action: existing.request.action,
resources: existing.request.save,
})
}
yield* Deferred.succeed(existing.deferred, undefined)
pending.delete(input.requestID)
if (input.reply !== "always" || !existing.request.save?.length) return
const rememberedRules = yield* savedRules()
for (const [id, item] of pending) {
const input = { ...item.request }
const rules = yield* configured(item.request.sessionID).pipe(
EffectRuntime.catchTag("Session.NotFoundError", () => EffectRuntime.succeed(undefined)),
)
if (!rules) continue
if (denied(input, rules)) continue
const effective = [...rules, ...rememberedRules]
if (
!item.request.resources.every(
(resource) => evaluate(item.request.action, resource, effective).effect === "allow",
)
)
continue
yield* events.publish(Event.Replied, {
sessionID: item.request.sessionID,
requestID: item.request.id,
reply: "always",
})
yield* Deferred.succeed(item.deferred, undefined)
pending.delete(id)
}
}),
),
)
const list = EffectRuntime.fn("PermissionV2.list")(function* () {
return Array.from(pending.values(), (item) => item.request)

View file

@ -34,7 +34,7 @@ const RawMatch = Schema.Struct({
}),
})
export type Match = typeof RawMatch.Type["data"]
export type Match = (typeof RawMatch.Type)["data"]
export class Error extends Schema.TaggedErrorClass<Error>()("Ripgrep.Error", {
message: Schema.String,
@ -77,7 +77,8 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
const failure = (message: string, cause?: unknown) => new Error({ message, cause })
const isInvalidPattern = (stderr: string) => stderr.includes("regex parse error") || stderr.includes("error parsing regex")
const isInvalidPattern = (stderr: string) =>
stderr.includes("regex parse error") || stderr.includes("error parsing regex")
export const layer = Layer.effect(
Service,
@ -126,7 +127,13 @@ export const layer = Layer.effect(
}),
)
const abortable = input.signal ? program.pipe(Effect.raceFirst(waitForAbort(input.signal))) : program
return abortable.pipe(Effect.mapError((cause) => cause instanceof Error || cause instanceof InvalidPatternError ? cause : failure("ripgrep execution failed", cause)))
return abortable.pipe(
Effect.mapError((cause) =>
cause instanceof Error || cause instanceof InvalidPatternError
? cause
: failure("ripgrep execution failed", cause),
),
)
}
return Service.of({
@ -143,9 +150,7 @@ export const layer = Layer.effect(
".",
],
parse: (line) => Effect.succeed(line.replace(/^\.\//, "")),
}).pipe(
Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
),
}).pipe(Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause)))),
grep: (input) =>
run<Match>({
...input,
@ -167,11 +172,16 @@ export const layer = Layer.effect(
: Effect.try({
try: () => JSON.parse(line) as unknown,
catch: (cause) => failure("Invalid ripgrep JSON output", cause),
})).pipe(
})
).pipe(
Effect.flatMap((json) => {
if (!json || typeof json !== "object" || !("type" in json) || json.type !== "match") return Effect.succeed(undefined)
if (!json || typeof json !== "object" || !("type" in json) || json.type !== "match")
return Effect.succeed(undefined)
return Schema.decodeUnknownEffect(RawMatch)(json).pipe(
Effect.map((match) => ({ ...match.data, submatches: match.data.submatches.slice(0, MAX_SUBMATCHES) })),
Effect.map((match) => ({
...match.data,
submatches: match.data.submatches.slice(0, MAX_SUBMATCHES),
})),
Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)),
)
}),

View file

@ -6,7 +6,8 @@ export type ExternalID = {
readonly key: string
}
export const externalID = (prefix: string, input: ExternalID) => `${prefix}_${Hash.sha256(JSON.stringify([input.namespace, input.key]))}`
export const externalID = (prefix: string, input: ExternalID) =>
`${prefix}_${Hash.sha256(JSON.stringify([input.namespace, input.key]))}`
/**
* Integer greater than zero.

View file

@ -132,8 +132,14 @@ export interface Interface {
sessionID: SessionSchema.ID
after?: EventV2.Cursor
}) => Stream.Stream<EventV2.CursorEvent<SessionEvent.DurableEvent>, NotFoundError>
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, OperationUnavailableError>
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: ModelV2.Ref }) => Effect.Effect<void, OperationUnavailableError>
readonly switchAgent: (input: {
sessionID: SessionSchema.ID
agent: string
}) => Effect.Effect<void, OperationUnavailableError>
readonly switchModel: (input: {
sessionID: SessionSchema.ID
model: ModelV2.Ref
}) => Effect.Effect<void, OperationUnavailableError>
readonly prompt: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
@ -318,7 +324,9 @@ export const layer = Layer.effect(
? yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.cursor.id)))
.where(
and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.cursor.id)),
)
.get()
.pipe(Effect.orDie)
: undefined

View file

@ -126,7 +126,8 @@ export const equivalent = (
) => input.delivery === expected.delivery && matchesPrompt(input, expected)
const matchesPrompt = (input: Admitted, expected: { readonly sessionID: SessionSchema.ID; readonly prompt: Prompt }) =>
input.sessionID === expected.sessionID && JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt))
input.sessionID === expected.sessionID &&
JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt))
export const guardReservedID = Effect.fn("SessionInput.guardReservedID")(function* (
db: DatabaseService,

View file

@ -318,14 +318,16 @@ export const layer = Layer.effectDiscard(
}),
)
yield* events.project(SessionEvent.AgentSwitched, (event) =>
db.update(SessionTable)
db
.update(SessionTable)
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
)
yield* events.project(SessionEvent.ModelSwitched, (event) =>
db.update(SessionTable)
db
.update(SessionTable)
.set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()

View file

@ -1,4 +1,11 @@
import { ToolOutput as LLMToolOutput, type LLMEvent, type ProviderMetadata, type ToolOutput as LLMToolOutputType, type ToolResultValue, type Usage } from "@opencode-ai/llm"
import {
ToolOutput as LLMToolOutput,
type LLMEvent,
type ProviderMetadata,
type ToolOutput as LLMToolOutputType,
type ToolResultValue,
type Usage,
} from "@opencode-ai/llm"
import { DateTime, Effect } from "effect"
import { EventV2 } from "../../event"
import { ModelV2 } from "../../model"
@ -52,7 +59,15 @@ const settledOutput = (value: LLMToolOutputType | undefined, result: ToolResultV
export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) => {
const tools = new Map<
string,
{ readonly assistantMessageID: EventV2.ID; readonly name: string; inputEnded: boolean; called: boolean; settled: boolean; providerExecuted: boolean; providerMetadata?: ProviderMetadata }
{
readonly assistantMessageID: EventV2.ID
readonly name: string
inputEnded: boolean
called: boolean
settled: boolean
providerExecuted: boolean
providerMetadata?: ProviderMetadata
}
>()
const timestamp = DateTime.now
let assistantMessageID: EventV2.ID | undefined
@ -60,14 +75,19 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
const startAssistant = Effect.fnUntraced(function* () {
if (assistantMessageID !== undefined) return assistantMessageID
assistantMessageID = (yield* events.publish(SessionEvent.Step.Started, { ...input, timestamp: yield* timestamp })).id
assistantMessageID = (yield* events.publish(SessionEvent.Step.Started, { ...input, timestamp: yield* timestamp }))
.id
return assistantMessageID
})
const currentAssistantMessageID = () => assistantMessageID === undefined
? Effect.die("Tool event before assistant step start")
: Effect.succeed(assistantMessageID)
const currentAssistantMessageID = () =>
assistantMessageID === undefined
? Effect.die("Tool event before assistant step start")
: Effect.succeed(assistantMessageID)
const fragments = (name: string, ended: (id: string, value: string, providerMetadata?: ProviderMetadata) => Effect.Effect<void>) => {
const fragments = (
name: string,
ended: (id: string, value: string, providerMetadata?: ProviderMetadata) => Effect.Effect<void>,
) => {
const chunks = new Map<string, string[]>()
const start = (id: string) =>
Effect.suspend(() => {
@ -139,7 +159,14 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
const startToolInput = Effect.fnUntraced(function* (event: { readonly id: string; readonly name: string }) {
if (tools.has(event.id)) return yield* Effect.die(`Duplicate tool input start: ${event.id}`)
const assistantMessageID = yield* currentAssistantMessageID()
tools.set(event.id, { assistantMessageID, name: event.name, inputEnded: false, called: false, settled: false, providerExecuted: false })
tools.set(event.id, {
assistantMessageID,
name: event.name,
inputEnded: false,
called: false,
settled: false,
providerExecuted: false,
})
yield* toolInput.start(event.id)
yield* events.publish(SessionEvent.Tool.Input.Started, {
sessionID: input.sessionID,
@ -153,7 +180,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
const endToolInput = Effect.fnUntraced(function* (event: { readonly id: string; readonly name: string }) {
const tool = tools.get(event.id)
if (!tool) return yield* Effect.die(`Tool input end before start: ${event.id}`)
if (tool.name !== event.name) return yield* Effect.die(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`)
if (tool.name !== event.name)
return yield* Effect.die(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`)
if (tool.inputEnded) return yield* Effect.die(`Duplicate tool input end: ${event.id}`)
yield* toolInput.end(event.id)
})
@ -190,7 +218,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
return
case "text-start":
yield* text.start(event.id)
yield* events.publish(SessionEvent.Text.Started, { sessionID: input.sessionID, timestamp: yield* timestamp, textID: event.id })
yield* events.publish(SessionEvent.Text.Started, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
textID: event.id,
})
return
case "text-delta":
yield* text.append(event.id, event.text)
@ -231,7 +263,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
case "tool-input-delta": {
const tool = tools.get(event.id)
if (!tool) return yield* Effect.die(`Tool input delta before start: ${event.id}`)
if (tool.name !== event.name) return yield* Effect.die(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`)
if (tool.name !== event.name)
return yield* Effect.die(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`)
if (tool.inputEnded) return yield* Effect.die(`Tool input delta after end: ${event.id}`)
yield* toolInput.append(event.id, event.text)
yield* events.publish(SessionEvent.Tool.Input.Delta, {
@ -250,7 +283,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
if (!tools.has(event.id)) yield* startToolInput(event)
const tool = tools.get(event.id)!
if (!tool.inputEnded) yield* endToolInput(event)
if (tool.name !== event.name) return yield* Effect.die(`Tool call name changed for ${event.id}: ${tool.name} -> ${event.name}`)
if (tool.name !== event.name)
return yield* Effect.die(`Tool call name changed for ${event.id}: ${tool.name} -> ${event.name}`)
if (tool.called) return yield* Effect.die(`Duplicate tool call: ${event.id}`)
tool.called = true
tool.providerExecuted = event.providerExecuted === true
@ -272,7 +306,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
case "tool-result": {
const tool = tools.get(event.id)
if (!tool?.called) return yield* Effect.die(`Tool result before call: ${event.id}`)
if (tool.name !== event.name) return yield* Effect.die(`Tool result name changed for ${event.id}: ${tool.name} -> ${event.name}`)
if (tool.name !== event.name)
return yield* Effect.die(`Tool result name changed for ${event.id}: ${tool.name} -> ${event.name}`)
if (tool.settled) {
if (event.result.type === "error") return
return yield* Effect.die(`Duplicate tool result: ${event.id}`)
@ -309,7 +344,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
case "tool-error": {
const tool = tools.get(event.id)
if (!tool?.called) return yield* Effect.die(`Tool error before call: ${event.id}`)
if (tool.name !== event.name) return yield* Effect.die(`Tool error name changed for ${event.id}: ${tool.name} -> ${event.name}`)
if (tool.name !== event.name)
return yield* Effect.die(`Tool error name changed for ${event.id}: ${tool.name} -> ${event.name}`)
if (tool.settled) return yield* Effect.die(`Duplicate tool error: ${event.id}`)
tool.settled = true
yield* events.publish(SessionEvent.Tool.Failed, {

View file

@ -1,4 +1,12 @@
import { Message, ToolCallPart, ToolOutput, ToolResultPart, type ContentPart, type Model, type ProviderMetadata } from "@opencode-ai/llm"
import {
Message,
ToolCallPart,
ToolOutput,
ToolResultPart,
type ContentPart,
type Model,
type ProviderMetadata,
} from "@opencode-ai/llm"
import { SessionMessage } from "../message"
import type { FileAttachment } from "../prompt"
@ -61,7 +69,8 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
}
const assistant = (message: SessionMessage.Assistant, model: Model) => {
const sameModel = String(message.model.providerID) === String(model.provider) && String(message.model.id) === String(model.id)
const sameModel =
String(message.model.providerID) === String(model.provider) && String(message.model.id) === String(model.id)
const content = message.content.flatMap((item): ContentPart[] => {
if (item.type === "text") return [{ type: "text", text: item.text }]
if (item.type === "reasoning")
@ -71,12 +80,12 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => {
? [{ type: "text", text: item.text }]
: []
const call = toolCall(item, sameModel ? item.provider?.metadata : undefined)
const result = toolResult(item, sameModel ? item.provider?.resultMetadata ?? item.provider?.metadata : undefined)
const result = toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined)
return item.provider?.executed === true && result ? [call, result] : [call]
})
const results = message.content
.filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true)
.map((item) => toolResult(item, sameModel ? item.provider?.resultMetadata ?? item.provider?.metadata : undefined))
.map((item) => toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined))
.filter((message) => message !== undefined)
.map(Message.tool)
return [Message.make({ id: message.id, role: "assistant", content, metadata: message.metadata }), ...results]

View file

@ -15,7 +15,7 @@ export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
const create = () => schema.make("ses_" + Identifier.descending())
return {
create,
descending: (id?: string) => id === undefined ? create() : schema.make(id),
descending: (id?: string) => (id === undefined ? create() : schema.make(id)),
fromExternal: (input: ExternalID) => schema.make(externalID("ses", input)),
}
}),

View file

@ -151,11 +151,10 @@ export const layer = Layer.effect(
}),
({ skill, root, files }) =>
Effect.gen(function* () {
yield* Effect.forEach(
files,
(file) => download(file.url, file.destination),
{ concurrency: fileConcurrency, discard: true },
)
yield* Effect.forEach(files, (file) => download(file.url, file.destination), {
concurrency: fileConcurrency,
discard: true,
})
return (yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) ||
(yield* fs.exists(path.join(root, `${skill.name}.md`)).pipe(Effect.orDie))
? [AbsolutePath.make(root)]

View file

@ -33,14 +33,20 @@ export class Page extends Schema.Class<Page>("ToolOutputStore.Page")({
next: NonNegativeInt.pipe(Schema.optional),
}) {}
export class AccessDeniedError extends Schema.TaggedErrorClass<AccessDeniedError>()("ToolOutputStore.AccessDeniedError", {
uri: Schema.String,
sessionID: SessionSchema.ID,
}) {}
export class AccessDeniedError extends Schema.TaggedErrorClass<AccessDeniedError>()(
"ToolOutputStore.AccessDeniedError",
{
uri: Schema.String,
sessionID: SessionSchema.ID,
},
) {}
export class InvalidResourceError extends Schema.TaggedErrorClass<InvalidResourceError>()("ToolOutputStore.InvalidResourceError", {
uri: Schema.String,
}) {}
export class InvalidResourceError extends Schema.TaggedErrorClass<InvalidResourceError>()(
"ToolOutputStore.InvalidResourceError",
{
uri: Schema.String,
},
) {}
export class ResourceNotFoundError extends Schema.TaggedErrorClass<ResourceNotFoundError>()(
"ToolOutputStore.ResourceNotFoundError",
@ -88,7 +94,9 @@ export interface Interface {
readonly limits: () => Effect.Effect<{ readonly maxLines: number; readonly maxBytes: number }>
readonly write: (input: WriteInput) => Effect.Effect<Resource>
readonly truncate: (input: TruncateInput) => Effect.Effect<TruncateResult>
readonly read: (input: ReadInput) => Effect.Effect<Page, AccessDeniedError | InvalidResourceError | ResourceNotFoundError>
readonly read: (
input: ReadInput,
) => Effect.Effect<Page, AccessDeniedError | InvalidResourceError | ResourceNotFoundError>
readonly cleanup: () => Effect.Effect<void>
}
@ -153,11 +161,17 @@ const preview = (text: string, maxLines: number, maxBytes: number) => {
const sampled =
lines.length <= maxLines
? text
: [lines.slice(0, headLines).join("\n"), ...(tailLines > 0 ? [lines.slice(lines.length - tailLines).join("\n")] : [])].join("\n")
: [
lines.slice(0, headLines).join("\n"),
...(tailLines > 0 ? [lines.slice(lines.length - tailLines).join("\n")] : []),
].join("\n")
if (Buffer.byteLength(sampled, "utf-8") <= maxBytes) {
return lines.length <= maxLines
? { head: sampled, tail: "" }
: { head: lines.slice(0, headLines).join("\n"), tail: tailLines > 0 ? lines.slice(lines.length - tailLines).join("\n") : "" }
: {
head: lines.slice(0, headLines).join("\n"),
tail: tailLines > 0 ? lines.slice(lines.length - tailLines).join("\n") : "",
}
}
const headBytes = Math.ceil(maxBytes / 2)
const tailBytes = Math.floor(maxBytes / 2)
@ -192,7 +206,7 @@ export const layer = Layer.effect(
const entries = yield* config.value.entries().pipe(Effect.catch(() => Effect.succeed([] as Config.Entry[])))
const configured = Object.assign(
{},
...entries.flatMap((entry) => entry.type === "document" ? [entry.info.tool_output ?? {}] : []),
...entries.flatMap((entry) => (entry.type === "document" ? [entry.info.tool_output ?? {}] : [])),
)
return { maxLines: configured.max_lines ?? MAX_LINES, maxBytes: configured.max_bytes ?? MAX_BYTES }
})
@ -218,7 +232,12 @@ export const layer = Layer.effect(
Effect.onError(() => fs.remove(contentPath(id)).pipe(Effect.catch(() => Effect.void))),
Effect.orDie,
)
return new Resource({ uri: resourceUri, mime: record.mime, ...(record.name === undefined ? {} : { name: record.name }), size })
return new Resource({
uri: resourceUri,
mime: record.mime,
...(record.name === undefined ? {} : { name: record.name }),
size,
})
})
const truncate = Effect.fn("ToolOutputStore.truncate")(function* (input: TruncateInput) {
@ -281,12 +300,14 @@ export const layer = Layer.effect(
const cleanup = Effect.fn("ToolOutputStore.cleanup")(function* () {
const entries = yield* fs.readDirectory(directory).pipe(Effect.catch(() => Effect.succeed([])))
const cutoff = Date.now() - Duration.toMillis(RETENTION)
const ids = new Set(entries.flatMap((entry) => {
const match = entry.match(/^([0-9a-f]{12}[0-9A-Za-z]{14})\.(?:json|txt)$/)
return match ? [match[1]] : []
}))
const ids = new Set(
entries.flatMap((entry) => {
const match = entry.match(/^([0-9a-f]{12}[0-9A-Za-z]{14})\.(?:json|txt)$/)
return match ? [match[1]] : []
}),
)
const removeIfPresent = (target: string) =>
fs.existsSafe(target).pipe(Effect.flatMap((exists) => exists ? fs.remove(target) : Effect.void))
fs.existsSafe(target).pipe(Effect.flatMap((exists) => (exists ? fs.remove(target) : Effect.void)))
const removePair = (id: string) =>
Effect.gen(function* () {
yield* removeIfPresent(contentPath(id))
@ -298,13 +319,19 @@ export const layer = Layer.effect(
if (!text) {
if (!contentExists) continue
const info = yield* fs.stat(contentPath(id)).pipe(Effect.catch(() => Effect.void))
const modified = info ? info.mtime.pipe(Option.map((date) => date.getTime()), Option.getOrElse(() => 0)) : 0
const modified = info
? info.mtime.pipe(
Option.map((date) => date.getTime()),
Option.getOrElse(() => 0),
)
: 0
if (modified < cutoff) yield* removePair(id)
continue
}
const record = yield* Effect.try({ try: () => JSON.parse(text), catch: () => new globalThis.Error("Invalid metadata") }).pipe(
Effect.catch(() => Effect.succeed(undefined)),
)
const record = yield* Effect.try({
try: () => JSON.parse(text),
catch: () => new globalThis.Error("Invalid metadata"),
}).pipe(Effect.catch(() => Effect.succeed(undefined)))
const info = contentExists ? yield* fs.stat(contentPath(id)).pipe(Effect.catch(() => Effect.void)) : undefined
if (
!contentExists ||

View file

@ -1,6 +1,16 @@
export * as ToolRegistry from "./tool-registry"
import { Tool, ToolFailure, ToolOutput, ToolResultValue as ToolResult, type Tool as TypedTool, type ToolCall, type ToolResultValue, type ToolSchema, type ToolSettlement } from "@opencode-ai/llm"
import {
Tool,
ToolFailure,
ToolOutput,
ToolResultValue as ToolResult,
type Tool as TypedTool,
type ToolCall,
type ToolResultValue,
type ToolSchema,
type ToolSettlement,
} from "@opencode-ai/llm"
import { Context, Effect, Layer, Schema, Scope } from "effect"
import { castDraft, enableMapSet } from "immer"
import { PermissionV2 } from "./permission"
@ -35,10 +45,15 @@ export type AuthorizeInput<Parameters = unknown> = Invocation & {
readonly parameters: Parameters
}
export type Entry<Parameters extends ToolSchema<any> = ToolSchema<any>, Success extends ToolSchema<any> = ToolSchema<any>> = {
export type Entry<
Parameters extends ToolSchema<any> = ToolSchema<any>,
Success extends ToolSchema<any> = ToolSchema<any>,
> = {
readonly tool: TypedTool<Parameters, Success>
readonly authorize?: (input: AuthorizeInput<Schema.Schema.Type<Parameters>>) => Effect.Effect<void, ToolFailure>
readonly execute?: (input: AuthorizeInput<Schema.Schema.Type<Parameters>>) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
readonly execute?: (
input: AuthorizeInput<Schema.Schema.Type<Parameters>>,
) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
}
type Data = {
@ -48,7 +63,10 @@ type Data = {
export type Editor = {
readonly list: () => ReadonlyArray<readonly [string, Entry]>
readonly get: (name: string) => Entry | undefined
readonly set: <Parameters extends ToolSchema<any>, Success extends ToolSchema<any>>(name: string, entry: Entry<Parameters, Success>) => void
readonly set: <Parameters extends ToolSchema<any>, Success extends ToolSchema<any>>(
name: string,
entry: Entry<Parameters, Success>,
) => void
readonly remove: (name: string) => void
}
@ -65,84 +83,91 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
enableMapSet()
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const permission = yield* PermissionV2.Service
const state = State.create<Data, Editor>({
initial: () => ({ entries: new Map() }),
editor: (draft) => ({
list: () => Array.from(draft.entries.entries()) as Array<[string, Entry]>,
get: (name) => draft.entries.get(name) as Entry | undefined,
set: (name, entry) => {
draft.entries.set(name, castDraft(entry) as typeof draft.entries extends Map<string, infer Value> ? Value : never)
},
remove: (name) => {
draft.entries.delete(name)
},
}),
})
Service,
Effect.gen(function* () {
const permission = yield* PermissionV2.Service
const state = State.create<Data, Editor>({
initial: () => ({ entries: new Map() }),
editor: (draft) => ({
list: () => Array.from(draft.entries.entries()) as Array<[string, Entry]>,
get: (name) => draft.entries.get(name) as Entry | undefined,
set: (name, entry) => {
draft.entries.set(
name,
castDraft(entry) as typeof draft.entries extends Map<string, infer Value> ? Value : never,
)
},
remove: (name) => {
draft.entries.delete(name)
},
}),
})
const definitions = Effect.fn("ToolRegistry.definitions")(function* () {
return Tool.toDefinitions(Object.fromEntries(Array.from(state.get().entries, ([name, entry]) => [name, entry.tool])))
})
const definitions = Effect.fn("ToolRegistry.definitions")(function* () {
return Tool.toDefinitions(
Object.fromEntries(Array.from(state.get().entries, ([name, entry]) => [name, entry.tool])),
)
})
const invocation = (input: ExecuteInput): Invocation => ({
...input,
// Source needs the durable owning assistant message ID, which the registry does not receive yet.
assertPermission: (request) => permission.assert({ ...request, sessionID: input.sessionID }),
})
const invocation = (input: ExecuteInput): Invocation => ({
...input,
// Source needs the durable owning assistant message ID, which the registry does not receive yet.
assertPermission: (request) => permission.assert({ ...request, sessionID: input.sessionID }),
})
const settle = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput) {
const entry = state.get().entries.get(input.call.name)
if (!entry) return { result: { type: "error" as const, value: `Unknown tool: ${input.call.name}` } }
if (!entry.execute && !entry.tool.execute)
return { result: { type: "error" as const, value: `Tool has no execute handler: ${input.call.name}` } }
const settle = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput) {
const entry = state.get().entries.get(input.call.name)
if (!entry) return { result: { type: "error" as const, value: `Unknown tool: ${input.call.name}` } }
if (!entry.execute && !entry.tool.execute)
return { result: { type: "error" as const, value: `Tool has no execute handler: ${input.call.name}` } }
return yield* entry.tool._decode(input.call.input).pipe(
Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })),
Effect.flatMap((parameters) => {
const context = { ...invocation(input), parameters }
const execute = entry.execute?.(context) ??
entry.tool.execute!(parameters, { id: input.call.id, name: input.call.name })
return (entry.authorize === undefined ? execute : entry.authorize(context).pipe(Effect.andThen(execute))).pipe(
Effect.flatMap((value) =>
entry.tool._encode(value).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `Tool returned an invalid value for its success schema: ${error.message}`,
}),
),
return yield* entry.tool._decode(input.call.input).pipe(
Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })),
Effect.flatMap((parameters) => {
const context = { ...invocation(input), parameters }
const execute =
entry.execute?.(context) ?? entry.tool.execute!(parameters, { id: input.call.id, name: input.call.name })
return (
entry.authorize === undefined ? execute : entry.authorize(context).pipe(Effect.andThen(execute))
).pipe(
Effect.flatMap((value) =>
entry.tool._encode(value).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `Tool returned an invalid value for its success schema: ${error.message}`,
}),
),
),
Effect.map((value): ToolSettlement => {
if (entry.tool._legacyResult && ToolResult.is(value))
return { result: value, output: ToolOutput.fromResultValue(value) }
const output = entry.tool._project(parameters, input.call.id, value)
const result = ToolOutput.toResultValue(output)
return result.type === "error" ? { result } : { result, output }
}),
)
}),
Effect.catchTag("LLM.ToolFailure", (failure) =>
Effect.succeed({ result: { type: "error" as const, value: failure.message } }),
),
)
})
const execute = Effect.fn("ToolRegistry.execute")(function* (input: ExecuteInput) {
return (yield* settle(input)).result
})
return Service.of({
transform: state.transform,
contribute: Effect.fn("ToolRegistry.contribute")(function* (update) {
const transform = yield* state.transform()
yield* transform(update)
),
Effect.map((value): ToolSettlement => {
if (entry.tool._legacyResult && ToolResult.is(value))
return { result: value, output: ToolOutput.fromResultValue(value) }
const output = entry.tool._project(parameters, input.call.id, value)
const result = ToolOutput.toResultValue(output)
return result.type === "error" ? { result } : { result, output }
}),
)
}),
definitions,
execute,
settle,
})
}),
)
Effect.catchTag("LLM.ToolFailure", (failure) =>
Effect.succeed({ result: { type: "error" as const, value: failure.message } }),
),
)
})
const execute = Effect.fn("ToolRegistry.execute")(function* (input: ExecuteInput) {
return (yield* settle(input)).result
})
return Service.of({
transform: state.transform,
contribute: Effect.fn("ToolRegistry.contribute")(function* (update) {
const transform = yield* state.transform()
yield* transform(update)
}),
definitions,
execute,
settle,
})
}),
)

View file

@ -11,7 +11,9 @@ import { ToolRegistry } from "../tool-registry"
export const name = "apply_patch"
export const Parameters = Schema.Struct({
patchText: Schema.String.annotate({ description: "The full patch text describing add, update, and delete operations" }),
patchText: Schema.String.annotate({
description: "The full patch text describing add, update, and delete operations",
}),
})
export const Applied = Schema.Struct({
@ -24,7 +26,12 @@ export const Success = Schema.Struct({ applied: Schema.Array(Applied) })
export type Success = typeof Success.Type
export const toModelOutput = (output: Success) =>
["Applied patch sequentially:", ...output.applied.map((item) => `${item.type === "add" ? "A" : item.type === "delete" ? "D" : "M"} ${item.resource}`)].join("\n")
[
"Applied patch sequentially:",
...output.applied.map(
(item) => `${item.type === "add" ? "A" : item.type === "delete" ? "D" : "M"} ${item.resource}`,
),
].join("\n")
const definition = Tool.make({
description:
@ -36,9 +43,23 @@ const definition = Tool.make({
type Planned = { readonly hunk: Patch.Hunk; readonly plan: LocationMutation.Plan }
type Prepared =
| { readonly type: "add"; readonly hunk: Extract<Patch.Hunk, { readonly type: "add" }>; readonly plan: LocationMutation.Plan }
| { readonly type: "delete"; readonly hunk: Extract<Patch.Hunk, { readonly type: "delete" }>; readonly plan: LocationMutation.Plan }
| { readonly type: "update"; readonly hunk: Extract<Patch.Hunk, { readonly type: "update" }>; readonly plan: LocationMutation.Plan; readonly source: Uint8Array; readonly content: string }
| {
readonly type: "add"
readonly hunk: Extract<Patch.Hunk, { readonly type: "add" }>
readonly plan: LocationMutation.Plan
}
| {
readonly type: "delete"
readonly hunk: Extract<Patch.Hunk, { readonly type: "delete" }>
readonly plan: LocationMutation.Plan
}
| {
readonly type: "update"
readonly hunk: Extract<Patch.Hunk, { readonly type: "update" }>
readonly plan: LocationMutation.Plan
readonly source: Uint8Array
readonly content: string
}
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
@ -53,9 +74,10 @@ export const layer = Layer.effectDiscard(
execute: ({ parameters, assertPermission }) => {
const applied: Array<typeof Applied.Type> = []
const fail = (path: string, cause: unknown) => {
const prefix = applied.length === 0
? `Unable to apply patch at ${path}`
: `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}`
const prefix =
applied.length === 0
? `Unable to apply patch at ${path}`
: `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}`
return new ToolFailure({ message: prefix, error: cause })
}
return Effect.gen(function* () {
@ -69,7 +91,8 @@ export const layer = Layer.effectDiscard(
if (move) return yield* new ToolFailure({ message: "apply_patch moves are not supported yet" })
const planned: Planned[] = []
for (const hunk of hunks) planned.push({ hunk, plan: yield* mutation.resolve({ path: hunk.path, kind: "file" }) })
for (const hunk of hunks)
planned.push({ hunk, plan: yield* mutation.resolve({ path: hunk.path, kind: "file" }) })
const externalDirectories = new Map<string, LocationMutation.ExternalDirectoryAuthorization>()
for (const { plan } of planned) {
const external = plan.target.externalDirectory
@ -78,7 +101,11 @@ export const layer = Layer.effectDiscard(
for (const external of externalDirectories.values()) {
yield* assertPermission(LocationMutation.externalDirectoryPermission(external))
}
yield* assertPermission({ action: "edit", resources: [...new Set(planned.map(({ plan }) => plan.target.resource))], save: ["*"] })
yield* assertPermission({
action: "edit",
resources: [...new Set(planned.map(({ plan }) => plan.target.resource))],
save: ["*"],
})
const prepared: Prepared[] = []
for (const { hunk, plan } of planned) {
@ -89,33 +116,51 @@ export const layer = Layer.effectDiscard(
continue
}
const target = yield* mutation.revalidate(plan)
if (!target.exists || target.type !== "File") return yield* fail(hunk.path, new Error("Target file does not exist"))
if (!target.exists || target.type !== "File")
return yield* fail(hunk.path, new Error("Target file does not exist"))
if (hunk.type === "delete") {
prepared.push({ type: hunk.type, hunk, plan })
continue
}
const source = yield* fs.readFile(target.canonical)
const update = Patch.derive(hunk.path, hunk.chunks, new TextDecoder("utf-8", { ignoreBOM: true }).decode(source))
const update = Patch.derive(
hunk.path,
hunk.chunks,
new TextDecoder("utf-8", { ignoreBOM: true }).decode(source),
)
prepared.push({ type: hunk.type, hunk, plan, source, content: Patch.joinBom(update.content, update.bom) })
}
yield* Effect.uninterruptible(
Effect.forEach(prepared, (change) =>
Effect.gen(function* () {
if (change.type === "add") {
const result = yield* files.create({ plan: change.plan, content: change.hunk.contents.endsWith("\n") || change.hunk.contents === "" ? change.hunk.contents : `${change.hunk.contents}\n` })
Effect.forEach(
prepared,
(change) =>
Effect.gen(function* () {
if (change.type === "add") {
const result = yield* files.create({
plan: change.plan,
content:
change.hunk.contents.endsWith("\n") || change.hunk.contents === ""
? change.hunk.contents
: `${change.hunk.contents}\n`,
})
applied.push({ type: change.type, resource: result.resource, target: result.target })
return
}
if (change.type === "delete") {
const result = yield* files.remove({ plan: change.plan })
applied.push({ type: change.type, resource: result.resource, target: result.target })
return
}
const result = yield* files.writeIfUnchanged({
plan: change.plan,
expected: change.source,
content: change.content,
})
applied.push({ type: change.type, resource: result.resource, target: result.target })
return
}
if (change.type === "delete") {
const result = yield* files.remove({ plan: change.plan })
applied.push({ type: change.type, resource: result.resource, target: result.target })
return
}
const result = yield* files.writeIfUnchanged({ plan: change.plan, expected: change.source, content: change.content })
applied.push({ type: change.type, resource: result.resource, target: result.target })
}).pipe(Effect.catchCause((cause) => Effect.fail(fail(change.hunk.path, Cause.squash(cause))))),
{ discard: true }),
}).pipe(Effect.catchCause((cause) => Effect.fail(fail(change.hunk.path, Cause.squash(cause))))),
{ discard: true },
),
)
return { applied }
}).pipe(

View file

@ -112,7 +112,9 @@ export const layer = Layer.effectDiscard(
const error = Cause.squash(cause)
return Effect.fail(
error instanceof FileMutation.StaleContentError
? new ToolFailure({ message: "File changed after permission approval. Read it again before editing." })
? new ToolFailure({
message: "File changed after permission approval. Read it again before editing.",
})
: new ToolFailure({ message: `Unable to edit ${parameters.path}`, error }),
)
}),
@ -123,7 +125,9 @@ export const layer = Layer.effectDiscard(
return yield* new ToolFailure({ message: "No changes to apply: oldString and newString are identical." })
}
if (parameters.oldString === "") {
return yield* new ToolFailure({ message: "oldString must not be empty. Use write to create or overwrite a file." })
return yield* new ToolFailure({
message: "oldString must not be empty. Use write to create or overwrite a file.",
})
}
const plan = yield* unableToEdit(mutation.resolve({ path: parameters.path, kind: "file" }))
@ -158,7 +162,11 @@ export const layer = Layer.effectDiscard(
: source.text.replace(oldString, newString)
const next = splitBom(replaced)
const result = yield* unableToEdit(
files.writeIfUnchanged({ plan, expected: source.content, content: joinBom(next.text, source.bom || next.bom) }),
files.writeIfUnchanged({
plan,
expected: source.content,
content: joinBom(next.text, source.bom || next.bom),
}),
)
return { ...result, replacements } satisfies Success
})

View file

@ -10,9 +10,15 @@ export const name = "glob"
export const Parameters = Schema.Struct({
pattern: LocationSearch.FilesInput.fields.pattern.annotate({ description: "Glob pattern to match files against" }),
path: LocationSearch.FilesInput.fields.path.annotate({ description: "Relative directory to search. Defaults to the active Location." }),
reference: LocationSearch.FilesInput.fields.reference.annotate({ description: "Named project reference to search instead of the active Location" }),
limit: LocationSearch.FilesInput.fields.limit.annotate({ description: `Maximum results to return (default: ${LocationSearch.DEFAULT_RESULT_LIMIT})` }),
path: LocationSearch.FilesInput.fields.path.annotate({
description: "Relative directory to search. Defaults to the active Location.",
}),
reference: LocationSearch.FilesInput.fields.reference.annotate({
description: "Named project reference to search instead of the active Location",
}),
limit: LocationSearch.FilesInput.fields.limit.annotate({
description: `Maximum results to return (default: ${LocationSearch.DEFAULT_RESULT_LIMIT})`,
}),
})
type ModelOutput = typeof LocationSearch.FilesResult.Encoded
@ -21,14 +27,18 @@ type ModelOutput = typeof LocationSearch.FilesResult.Encoded
export const toModelOutput = (output: ModelOutput) => {
const lines = output.items.length === 0 ? ["No files found"] : output.items.map((item) => item.resource)
if (output.truncated) {
lines.push("", `(Results are truncated: showing first ${output.items.length} results. Consider using a more specific path or pattern.)`)
lines.push(
"",
`(Results are truncated: showing first ${output.items.length} results. Consider using a more specific path or pattern.)`,
)
}
if (output.partial) lines.push("", "(Results may be incomplete because some discovered files could not be read.)")
return lines.join("\n")
}
const definition = Tool.make({
description: "Find files by glob pattern within the active Location or a named project reference. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
description:
"Find files by glob pattern within the active Location or a named project reference. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
parameters: Parameters,
success: LocationSearch.FilesResult,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
@ -66,7 +76,12 @@ export const layer = Layer.effectDiscard(
return yield* search.files(parameters, root)
}).pipe(
Effect.catchCause((cause) =>
Effect.fail(new ToolFailure({ message: `Unable to find files matching ${parameters.pattern}`, error: Cause.squash(cause) })),
Effect.fail(
new ToolFailure({
message: `Unable to find files matching ${parameters.pattern}`,
error: Cause.squash(cause),
}),
),
),
),
}),

View file

@ -10,11 +10,21 @@ import { ToolRegistry } from "../tool-registry"
export const name = "grep"
export const Parameters = Schema.Struct({
pattern: LocationSearch.GrepInput.fields.pattern.annotate({ description: "Regex pattern to search for in file contents" }),
path: LocationSearch.GrepInput.fields.path.annotate({ description: "Relative file or directory to search. Defaults to the active Location." }),
reference: LocationSearch.GrepInput.fields.reference.annotate({ description: "Named project reference to search instead of the active Location" }),
include: LocationSearch.GrepInput.fields.include.annotate({ description: 'File glob to include in the search (for example, "*.js" or "*.{ts,tsx}")' }),
limit: LocationSearch.GrepInput.fields.limit.annotate({ description: `Maximum matches to return (default: ${LocationSearch.DEFAULT_RESULT_LIMIT})` }),
pattern: LocationSearch.GrepInput.fields.pattern.annotate({
description: "Regex pattern to search for in file contents",
}),
path: LocationSearch.GrepInput.fields.path.annotate({
description: "Relative file or directory to search. Defaults to the active Location.",
}),
reference: LocationSearch.GrepInput.fields.reference.annotate({
description: "Named project reference to search instead of the active Location",
}),
include: LocationSearch.GrepInput.fields.include.annotate({
description: 'File glob to include in the search (for example, "*.js" or "*.{ts,tsx}")',
}),
limit: LocationSearch.GrepInput.fields.limit.annotate({
description: `Maximum matches to return (default: ${LocationSearch.DEFAULT_RESULT_LIMIT})`,
}),
})
type Success = typeof LocationSearch.GrepResult.Encoded
@ -32,14 +42,18 @@ export const toModelOutput = (output: Success) => {
lines.push(` Line ${match.line}: ${match.lines}${match.linePreviewTruncated ? "..." : ""}`)
}
if (output.truncated) {
lines.push("", `(Results are truncated: showing first ${output.items.length} matches. Consider using a more specific path or pattern.)`)
lines.push(
"",
`(Results are truncated: showing first ${output.items.length} matches. Consider using a more specific path or pattern.)`,
)
}
if (output.partial) lines.push("", "(Some paths were inaccessible and skipped)")
return lines.join("\n")
}
const definition = Tool.make({
description: "Search file contents by regular expression within the active Location or a named project reference. Use a relative path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise relative file resources, line numbers, and bounded line previews.",
description:
"Search file contents by regular expression within the active Location or a named project reference. Use a relative path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise relative file resources, line numbers, and bounded line previews.",
parameters: Parameters,
success: LocationSearch.GrepResult,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
@ -79,9 +93,10 @@ export const layer = Layer.effectDiscard(
}).pipe(
Effect.catchCause((cause) => {
const error = Cause.squash(cause)
const message = error instanceof Ripgrep.InvalidPatternError
? `Invalid grep pattern ${JSON.stringify(parameters.pattern)}: ${error.message}`
: `Unable to grep for ${parameters.pattern}`
const message =
error instanceof Ripgrep.InvalidPatternError
? `Invalid grep pattern ${JSON.stringify(parameters.pattern)}: ${error.message}`
: `Unable to grep for ${parameters.pattern}`
return Effect.fail(new ToolFailure({ message, error }))
}),
),

View file

@ -70,7 +70,11 @@ export const layer = Layer.effectDiscard(
const final = yield* filesystem.resolveReadPath(input)
if (final.type !== "file" || final.target.resource !== target.resource || final.target.real !== target.real)
return yield* Effect.die(new Error("File changed after permission approval"))
if (final.target.size > FileSystem.MAX_READ_BYTES || input.offset !== undefined || input.limit !== undefined)
if (
final.target.size > FileSystem.MAX_READ_BYTES ||
input.offset !== undefined ||
input.limit !== undefined
)
return yield* filesystem.readTextPageResolved(final.target, { offset: input.offset, limit: input.limit })
return yield* filesystem.readResolved(final.target, FileSystem.MAX_READ_BYTES)
}).pipe(

View file

@ -150,7 +150,9 @@ export const layer = Layer.effectDiscard(
const { body, contentType } = yield* Effect.gen(function* () {
const response = yield* execute(http, parameters.url, parameters.format).pipe(
Effect.catchIf(isCloudflareChallenge, () => execute(http, parameters.url, parameters.format, "opencode")),
Effect.catchIf(isCloudflareChallenge, () =>
execute(http, parameters.url, parameters.format, "opencode"),
),
)
const contentType = response.headers["content-type"] || ""
const mime = mimeFrom(contentType)

View file

@ -34,7 +34,9 @@ The current year is ${new Date().getFullYear()}. Use this year when searching fo
export const Parameters = Schema.Struct({
query: Schema.String.annotate({ description: "Websearch query" }),
numResults: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_NUM_RESULTS))).annotate({ description: `Number of search results to return (default: 8, maximum: ${MAX_NUM_RESULTS})` }),
numResults: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_NUM_RESULTS))).annotate({
description: `Number of search results to return (default: 8, maximum: ${MAX_NUM_RESULTS})`,
}),
livecrawl: Schema.optional(Schema.Literals(["fallback", "preferred"])).annotate({
description:
"Live crawl mode - 'fallback': use live crawling as backup if cached unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
@ -42,9 +44,11 @@ export const Parameters = Schema.Struct({
type: Schema.optional(Schema.Literals(["auto", "fast", "deep"])).annotate({
description: "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
}),
contextMaxCharacters: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_CONTEXT_CHARACTERS))).annotate({
description: `Maximum characters for context string optimized for models (default: 10000, maximum: ${MAX_CONTEXT_CHARACTERS})`,
}),
contextMaxCharacters: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_CONTEXT_CHARACTERS))).annotate(
{
description: `Maximum characters for context string optimized for models (default: 10000, maximum: ${MAX_CONTEXT_CHARACTERS})`,
},
),
})
export const Provider = Schema.Literals(["exa", "parallel"])
@ -63,13 +67,11 @@ export class ConfigService extends Context.Service<ConfigService, Config>()("@op
/** Isolates the retained product environment contract from the generic tool implementation. */
export const defaultConfigLayer = Layer.sync(ConfigService, () =>
ConfigService.of({
provider: process.env.OPENCODE_WEBSEARCH_PROVIDER === "exa" || process.env.OPENCODE_WEBSEARCH_PROVIDER === "parallel"
? process.env.OPENCODE_WEBSEARCH_PROVIDER
: undefined,
enableExa:
truthy("OPENCODE_EXPERIMENTAL") ||
truthy("OPENCODE_ENABLE_EXA") ||
truthy("OPENCODE_EXPERIMENTAL_EXA"),
provider:
process.env.OPENCODE_WEBSEARCH_PROVIDER === "exa" || process.env.OPENCODE_WEBSEARCH_PROVIDER === "parallel"
? process.env.OPENCODE_WEBSEARCH_PROVIDER
: undefined,
enableExa: truthy("OPENCODE_EXPERIMENTAL") || truthy("OPENCODE_ENABLE_EXA") || truthy("OPENCODE_EXPERIMENTAL_EXA"),
enableParallel: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"),
exaApiKey: process.env.EXA_API_KEY,
parallelApiKey: process.env.PARALLEL_API_KEY,
@ -162,9 +164,15 @@ const callMcp = <F extends Schema.Struct.Fields>(
return yield* Effect.gen(function* () {
const response = yield* HttpClient.filterStatusOk(http).execute(request)
const body = yield* response.text
if (Buffer.byteLength(body, "utf8") > MAX_RESPONSE_BYTES) return yield* Effect.die(new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`))
if (Buffer.byteLength(body, "utf8") > MAX_RESPONSE_BYTES)
return yield* Effect.die(new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`))
return yield* parseResponse(body)
}).pipe(Effect.timeoutOrElse({ duration: Duration.seconds(25), orElse: () => Effect.die(new Error(`${tool} request timed out`)) }))
}).pipe(
Effect.timeoutOrElse({
duration: Duration.seconds(25),
orElse: () => Effect.die(new Error(`${tool} request timed out`)),
}),
)
})
const Success = Schema.Struct({
@ -201,30 +209,31 @@ export const layer = Layer.effectDiscard(
metadata: { ...parameters, provider },
})
const text = provider === "exa"
? yield* callMcp(http, exaUrl(config.exaApiKey), "web_search_exa", ExaArgs, {
query: parameters.query,
type: parameters.type || "auto",
numResults: parameters.numResults || 8,
livecrawl: parameters.livecrawl || "fallback",
contextMaxCharacters: parameters.contextMaxCharacters,
})
: yield* callMcp(
http,
PARALLEL_URL,
"web_search",
ParallelArgs,
{
objective: parameters.query,
search_queries: [parameters.query],
session_id: sessionID,
// V2 invocation context does not safely expose the model yet.
},
{
"User-Agent": `opencode/${InstallationVersion}`,
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
},
)
const text =
provider === "exa"
? yield* callMcp(http, exaUrl(config.exaApiKey), "web_search_exa", ExaArgs, {
query: parameters.query,
type: parameters.type || "auto",
numResults: parameters.numResults || 8,
livecrawl: parameters.livecrawl || "fallback",
contextMaxCharacters: parameters.contextMaxCharacters,
})
: yield* callMcp(
http,
PARALLEL_URL,
"web_search",
ParallelArgs,
{
objective: parameters.query,
search_queries: [parameters.query],
session_id: sessionID,
// V2 invocation context does not safely expose the model yet.
},
{
"User-Agent": `opencode/${InstallationVersion}`,
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
},
)
const truncated = yield* resources.truncate({ sessionID, toolCallID: call.id, content: text ?? NO_RESULTS })
return {
provider,
@ -234,7 +243,12 @@ export const layer = Layer.effectDiscard(
}
}).pipe(
Effect.catchCause((cause) =>
Effect.fail(new ToolFailure({ message: `Unable to search the web for ${parameters.query}`, error: Cause.squash(cause) })),
Effect.fail(
new ToolFailure({
message: `Unable to search the web for ${parameters.query}`,
error: Cause.squash(cause),
}),
),
),
)
},

View file

@ -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)
}

View file

@ -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") {

View file

@ -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)

View file

@ -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")
}),

View file

@ -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)

View file

@ -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)),
),

View file

@ -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",
)
})
})

View file

@ -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),

View file

@ -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")),

View file

@ -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 })
}),
)

View file

@ -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" },
])
}),
)

View file

@ -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([
{

View file

@ -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"),

View file

@ -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([

View file

@ -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))

View file

@ -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(

View file

@ -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)

View file

@ -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([])
}),

View file

@ -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,
})

View file

@ -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 } }))),
)
}),
),
)

View file

@ -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()

View file

@ -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",

View file

@ -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,

View file

@ -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")
}),

View file

@ -105,18 +105,22 @@ const streamWithTools = Effect.gen(function* () {
})
const events = Array.from(yield* LLM.stream(request).pipe(Stream.runCollect))
for (const event of events) {
if (event.type === "tool-call") console.log("tool call", event.name, event.input)
if (event.type === "text-delta") process.stdout.write(event.text)
if (event.type !== "tool-call" || event.providerExecuted) continue
const dispatched = yield* ToolRuntime.dispatch(tools, event)
console.log("tool result", event.name, dispatched.result)
if (event.type === "tool-call") console.log("tool call", event.name, event.input)
if (event.type === "text-delta") process.stdout.write(event.text)
if (event.type !== "tool-call" || event.providerExecuted) continue
const dispatched = yield* ToolRuntime.dispatch(tools, event)
console.log("tool result", event.name, dispatched.result)
// A durable agent would persist these messages before starting another
// raw model turn. This tutorial keeps the boundary visible instead.
const followUp = LLM.updateRequest(request, {
messages: [...request.messages, Message.assistant([event]), Message.tool({ ...event, result: dispatched.result })],
})
console.log("follow-up history messages:", followUp.messages.length)
// A durable agent would persist these messages before starting another
// raw model turn. This tutorial keeps the boundary visible instead.
const followUp = LLM.updateRequest(request, {
messages: [
...request.messages,
Message.assistant([event]),
Message.tool({ ...event, result: dispatched.result }),
],
})
console.log("follow-up history messages:", followUp.messages.length)
}
})

View file

@ -363,11 +363,15 @@ const validateNativeSystemUpdate = Effect.fn("AnthropicMessages.validateNativeSy
const previous = messages[index - 1]
const next = messages[index + 1]
if (!previous)
return yield* invalid("Anthropic Messages chronological system updates cannot be the first message; use LLMRequest.system")
return yield* invalid(
"Anthropic Messages chronological system updates cannot be the first message; use LLMRequest.system",
)
if (previous.role === "system")
return yield* invalid("Anthropic Messages chronological system updates cannot be consecutive")
if (endsInLocalToolUse(previous))
return yield* invalid("Anthropic Messages chronological system updates cannot appear between a local tool call and its tool result")
return yield* invalid(
"Anthropic Messages chronological system updates cannot appear between a local tool call and its tool result",
)
if (previous.role !== "user" && previous.role !== "tool" && !endsInServerToolUse(previous))
return yield* invalid(
"Anthropic Messages chronological system updates must follow a user message, tool result, or assistant server tool use",
@ -375,7 +379,9 @@ const validateNativeSystemUpdate = Effect.fn("AnthropicMessages.validateNativeSy
if (next?.role === "system")
return yield* invalid("Anthropic Messages chronological system updates cannot be consecutive")
if (next && next.role !== "assistant")
return yield* invalid("Anthropic Messages chronological system updates must end the messages array or immediately precede an assistant message")
return yield* invalid(
"Anthropic Messages chronological system updates must end the messages array or immediately precede an assistant message",
)
})
const lowerNativeSystemUpdate = Effect.fn("AnthropicMessages.lowerNativeSystemUpdate")(function* (
@ -409,7 +415,8 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
const part = yield* ProviderShared.wrappedSystemUpdate("Anthropic Messages", message)
const block = { type: "text" as const, text: part.text, cache_control: cacheControl(breakpoints, part.cache) }
const previous = messages.at(-1)
if (previous?.role === "user") messages[messages.length - 1] = { role: "user", content: [...previous.content, block] }
if (previous?.role === "user")
messages[messages.length - 1] = { role: "user", content: [...previous.content, block] }
else messages.push({ role: "user", content: [block] })
continue
}

View file

@ -243,7 +243,10 @@ const bedrockMetadata = (metadata: Record<string, unknown>): ProviderMetadata =>
const reasoningSignature = (part: ReasoningPart) => {
const bedrock = part.providerMetadata?.bedrock
return part.encrypted ?? (ProviderShared.isRecord(bedrock) && typeof bedrock.signature === "string" ? bedrock.signature : undefined)
return (
part.encrypted ??
(ProviderShared.isRecord(bedrock) && typeof bedrock.signature === "string" ? bedrock.signature : undefined)
)
}
const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({
@ -294,7 +297,8 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
const part = yield* ProviderShared.wrappedSystemUpdate("Bedrock Converse", message)
const content = textWithCache(breakpoints, part.text, part.cache)
const previous = messages.at(-1)
if (previous?.role === "user") messages[messages.length - 1] = { role: "user", content: [...previous.content, ...content] }
if (previous?.role === "user")
messages[messages.length - 1] = { role: "user", content: [...previous.content, ...content] }
else messages.push({ role: "user", content })
continue
}
@ -532,7 +536,9 @@ const step = (state: ParserState, event: BedrockEvent) =>
Lifecycle.textEnd(state.lifecycle, events, `text-${index}`),
events,
`reasoning-${index}`,
state.reasoningSignatures[index] ? bedrockMetadata({ signature: state.reasoningSignatures[index] }) : undefined,
state.reasoningSignatures[index]
? bedrockMetadata({ signature: state.reasoningSignatures[index] })
: undefined,
)
events.push(...resultEvents)
return [

View file

@ -204,7 +204,8 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate("Gemini", message)
const previous = contents.at(-1)
if (previous?.role === "user") contents[contents.length - 1] = { role: "user", parts: [...previous.parts, { text: part.text }] }
if (previous?.role === "user")
contents[contents.length - 1] = { role: "user", parts: [...previous.parts, { text: part.text }] }
else contents.push({ role: "user", parts: [{ text: part.text }] })
continue
}
@ -405,7 +406,9 @@ const step = (state: ParserState, event: GeminiEvent) => {
id,
name: part.functionCall.name,
input,
providerMetadata: part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
providerMetadata: part.thoughtSignature
? googleMetadata({ thoughtSignature: part.thoughtSignature })
: undefined,
}),
)
hasToolCalls = true

View file

@ -226,7 +226,9 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
content: content.length === 0 ? null : ProviderShared.joinText(content),
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
reasoning_content:
reasoning.length > 0 ? reasoning.map((part) => part.text).join("") : openAICompatibleReasoningContent(message.native?.openaiCompatible),
reasoning.length > 0
? reasoning.map((part) => part.text).join("")
: openAICompatibleReasoningContent(message.native?.openaiCompatible),
}
})

View file

@ -343,7 +343,10 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Responses", message)
const previous = input.at(-1)
if (previous && "role" in previous && previous.role === "user")
input[input.length - 1] = { role: "user", content: [...previous.content, { type: "input_text", text: part.text }] }
input[input.length - 1] = {
role: "user",
content: [...previous.content, { type: "input_text", text: part.text }],
}
else input.push({ role: "user", content: [{ type: "input_text", text: part.text }] })
continue
}
@ -397,7 +400,8 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
if (part.type === "tool-result" && part.providerExecuted === true) {
flushText()
const itemID = hostedToolItemID(part)
if (store !== false && itemID && !hostedToolReferences.has(itemID)) input.push({ type: "item_reference", id: itemID })
if (store !== false && itemID && !hostedToolReferences.has(itemID))
input.push({ type: "item_reference", id: itemID })
if (itemID) hostedToolReferences.add(itemID)
continue
}

View file

@ -105,7 +105,8 @@ export const parseJson = (route: string, input: string, message: string) =>
*/
export const joinText = (parts: ReadonlyArray<{ readonly text: string }>) => parts.map((part) => part.text).join("\n")
const escapeSystemUpdateText = (text: string) => text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
const escapeSystemUpdateText = (text: string) =>
text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
/**
* Stable fallback representation for chronological `Message.system(...)`

View file

@ -193,7 +193,8 @@ export const ToolOutput = Object.assign(
type: "content",
value: output.content.map((item) => {
if (item.type === "text") return { type: "text", text: item.text }
if (item.source.type !== "data") throw new Error("Unmaterialized tool file source reached provider conversion")
if (item.source.type !== "data")
throw new Error("Unmaterialized tool file source reached provider conversion")
return { type: "media", mediaType: item.mime, data: item.source.data, filename: item.name }
}),
}

View file

@ -1,5 +1,13 @@
import { Effect } from "effect"
import { LLMEvent, type ToolCallPart, ToolFailure, ToolOutput, ToolResultValue, type ToolOutput as ToolOutputType, type ToolResultValue as ToolResultValueType } from "./schema"
import {
LLMEvent,
type ToolCallPart,
ToolFailure,
ToolOutput,
ToolResultValue,
type ToolOutput as ToolOutputType,
type ToolResultValue as ToolResultValueType,
} from "./schema"
import { type AnyTool, type Tools } from "./tool"
export interface ToolSettlement {
@ -52,11 +60,7 @@ const decodeAndExecute = (tool: AnyTool, call: ToolCallPart): Effect.Effect<Tool
),
)
const result = (
call: ToolCallPart,
value: ToolResultValueType | ToolSettlement,
error?: unknown,
): DispatchResult => {
const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement, error?: unknown): DispatchResult => {
const settlement = ToolResultValue.is(value) ? { result: value } : value
return {
result: settlement.result,

View file

@ -1,5 +1,10 @@
import { Effect, JsonSchema, Schema } from "effect"
import type { ToolCallPart, ToolContent, ToolDefinition as ToolDefinitionClass, ToolOutput as ToolOutputType } from "./schema"
import type {
ToolCallPart,
ToolContent,
ToolDefinition as ToolDefinitionClass,
ToolOutput as ToolOutputType,
} from "./schema"
import { ToolDefinition, ToolFailure, ToolOutput, toolText } from "./schema"
/**
@ -51,7 +56,11 @@ export interface Tool<Parameters extends ToolSchema<any>, Success extends ToolSc
/** @internal */
readonly _encode: (value: Schema.Schema.Type<Success>) => Effect.Effect<unknown, Schema.SchemaError>
/** @internal */
readonly _project: (parameters: Schema.Schema.Type<Parameters>, callID: ToolCallPart["id"], output: unknown) => ToolOutputType
readonly _project: (
parameters: Schema.Schema.Type<Parameters>,
callID: ToolCallPart["id"],
output: unknown,
) => ToolOutputType
/** @internal */
readonly _legacyResult: boolean
/** @internal */

View file

@ -117,7 +117,11 @@ const appendText = (
) => {
const last = content.at(-1)
if (last?.type === type) {
content[content.length - 1] = { ...last, text: `${last.text}${text}`, providerMetadata: providerMetadata ?? last.providerMetadata }
content[content.length - 1] = {
...last,
text: `${last.text}${text}`,
providerMetadata: providerMetadata ?? last.providerMetadata,
}
return
}
content.push({ type, text, providerMetadata })

View file

@ -136,7 +136,9 @@ describe("llm constructors", () => {
})
test("builds chronological text-only system updates separately from the initial system prompt", () => {
const update = Message.system([{ type: "text", text: "Use parameterized SQL.", cache: new CacheHint({ type: "ephemeral" }) }])
const update = Message.system([
{ type: "text", text: "Use parameterized SQL.", cache: new CacheHint({ type: "ephemeral" }) },
])
const request = LLM.request({
model: Model.make({ id: "fake-model", provider: "fake", route: chatRoute }),
system: "Initial operator prompt.",

View file

@ -87,7 +87,11 @@ describe("Anthropic Messages route", () => {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({
model,
messages: [Message.user("Before."), Message.system("Treat </system-update> literally."), Message.assistant("After.")],
messages: [
Message.user("Before."),
Message.system("Treat </system-update> literally."),
Message.assistant("After."),
],
cache: "none",
}),
)
@ -127,19 +131,19 @@ describe("Anthropic Messages route", () => {
LLMClient.prepare(LLM.request({ model: opus48, messages, cache: "none" })).pipe(Effect.flip)
expect((yield* placementError([Message.system("First.")])).message).toContain("cannot be the first message")
expect((yield* placementError([Message.user("Before."), Message.system("One."), Message.system("Two.")])).message)
.toContain("cannot be consecutive")
expect((yield* placementError([Message.assistant("Plain."), Message.system("After plain assistant.")])).message)
.toContain("must follow a user message, tool result, or assistant server tool use")
expect(
(
yield* placementError([
Message.user("Use the tool."),
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
Message.system("Too early."),
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
])
).message,
(yield* placementError([Message.user("Before."), Message.system("One."), Message.system("Two.")])).message,
).toContain("cannot be consecutive")
expect(
(yield* placementError([Message.assistant("Plain."), Message.system("After plain assistant.")])).message,
).toContain("must follow a user message, tool result, or assistant server tool use")
expect(
(yield* placementError([
Message.user("Use the tool."),
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
Message.system("Too early."),
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
])).message,
).toContain("cannot appear between a local tool call and its tool result")
}),
)

View file

@ -327,7 +327,10 @@ describe("Bedrock Converse route", () => {
}),
)
expect(prepared.body.messages).toEqual([
{ role: "assistant", content: [{ reasoningContent: { reasoningText: { text: "Let me think.", signature: "sig_1" } } }] },
{
role: "assistant",
content: [{ reasoningContent: { reasoningText: { text: "Let me think.", signature: "sig_1" } } }],
},
])
}),
)

View file

@ -55,12 +55,19 @@ describe("OpenAI Chat route", () => {
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
messages: [Message.user("Before."), Message.system("Treat <admin> & data literally."), Message.assistant("After.")],
messages: [
Message.user("Before."),
Message.system("Treat <admin> & data literally."),
Message.assistant("After."),
],
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: "Before.\n<system-update>\nTreat &lt;admin&gt; &amp; data literally.\n</system-update>" },
{
role: "user",
content: "Before.\n<system-update>\nTreat &lt;admin&gt; &amp; data literally.\n</system-update>",
},
{ role: "assistant", content: "After." },
])
}),
@ -71,7 +78,12 @@ describe("OpenAI Chat route", () => {
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
messages: [Message.assistant([{ type: "reasoning", text: "thinking" }, { type: "text", text: "Hello" }])],
messages: [
Message.assistant([
{ type: "reasoning", text: "thinking" },
{ type: "text", text: "Hello" },
]),
],
}),
)

View file

@ -62,7 +62,11 @@ describe("OpenAI Responses route", () => {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model,
messages: [Message.user("Before."), Message.system("Treat </system-update> literally."), Message.assistant("After.")],
messages: [
Message.user("Before."),
Message.system("Treat </system-update> literally."),
Message.assistant("After."),
],
}),
)

View file

@ -1,6 +1,17 @@
import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { GenerationOptions, LLM, LLMEvent, LLMRequest, LLMResponse, ToolChoice, ToolContent, ToolOutput, toolFileSourceFromUri, toDefinitions } from "../src"
import {
GenerationOptions,
LLM,
LLMEvent,
LLMRequest,
LLMResponse,
ToolChoice,
ToolContent,
ToolOutput,
toolFileSourceFromUri,
toDefinitions,
} from "../src"
import { Auth, LLMClient } from "../src/route"
import * as AnthropicMessages from "../src/protocols/anthropic-messages"
import * as OpenAIChat from "../src/protocols/openai-chat"
@ -188,10 +199,12 @@ describe("LLMClient tools", () => {
execute: () => Effect.succeed({ ok: true }),
})
expect((yield* ToolRuntime.dispatch({ text }, LLMEvent.toolCall({ id: "call_text", name: "text", input: {} }))).output)
.toEqual({ structured: "hello", content: [{ type: "text", text: "hello" }] })
expect((yield* ToolRuntime.dispatch({ json }, LLMEvent.toolCall({ id: "call_json", name: "json", input: {} }))).output)
.toEqual({ structured: { ok: true }, content: [] })
expect(
(yield* ToolRuntime.dispatch({ text }, LLMEvent.toolCall({ id: "call_text", name: "text", input: {} }))).output,
).toEqual({ structured: "hello", content: [{ type: "text", text: "hello" }] })
expect(
(yield* ToolRuntime.dispatch({ json }, LLMEvent.toolCall({ id: "call_json", name: "json", input: {} }))).output,
).toEqual({ structured: { ok: true }, content: [] })
}),
)
@ -204,12 +217,16 @@ describe("LLMClient tools", () => {
source: { type: "data", data: "AAAA" },
mime: "image/png",
})
expect(decode({ type: "file", source: { type: "url", url: "https://example.test/image.png" }, mime: "image/png" })).toEqual({
expect(
decode({ type: "file", source: { type: "url", url: "https://example.test/image.png" }, mime: "image/png" }),
).toEqual({
type: "file",
source: { type: "url", url: "https://example.test/image.png" },
mime: "image/png",
})
expect(decode({ type: "file", source: { type: "file", uri: "file:///tmp/image.png" }, mime: "image/png" })).toEqual({
expect(
decode({ type: "file", source: { type: "file", uri: "file:///tmp/image.png" }, mime: "image/png" }),
).toEqual({
type: "file",
source: { type: "file", uri: "file:///tmp/image.png" },
mime: "image/png",
@ -226,16 +243,29 @@ describe("LLMClient tools", () => {
).toEqual({ type: "content", value: [{ type: "media", mediaType: "image/png", data: "AAAA" }] })
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [{ type: "file", source: { type: "url", url: "https://example.test/image.png" }, mime: "image/png" }]),
ToolOutput.make({}, [
{ type: "file", source: { type: "url", url: "https://example.test/image.png" }, mime: "image/png" },
]),
),
).toEqual({ type: "error", value: 'Tool file source "url" must be materialized to inline data before provider conversion' })
).toEqual({
type: "error",
value: 'Tool file source "url" must be materialized to inline data before provider conversion',
})
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [{ type: "file", source: { type: "file", uri: "file:///tmp/image.png" }, mime: "image/png" }]),
ToolOutput.make({}, [
{ type: "file", source: { type: "file", uri: "file:///tmp/image.png" }, mime: "image/png" },
]),
),
).toEqual({ type: "error", value: 'Tool file source "file" must be materialized to inline data before provider conversion' })
).toEqual({
type: "error",
value: 'Tool file source "file" must be materialized to inline data before provider conversion',
})
expect(toolFileSourceFromUri("data:image/png;base64,AAAA")).toEqual({ type: "data", data: "AAAA" })
expect(toolFileSourceFromUri("https://example.test/image.png")).toEqual({ type: "url", url: "https://example.test/image.png" })
expect(toolFileSourceFromUri("https://example.test/image.png")).toEqual({
type: "url",
url: "https://example.test/image.png",
})
expect(toolFileSourceFromUri("file:///tmp/image.png")).toEqual({ type: "file", uri: "file:///tmp/image.png" })
expect(() => toolFileSourceFromUri("opaque-value")).toThrow("Unsupported tool file URI")
expect(() =>

View file

@ -27,7 +27,9 @@ Tool.make({
parameters: Schema.Struct({ city: Schema.String }),
success: Schema.Struct({ forecast: Schema.NumberFromString }),
execute: () => Effect.succeed({ forecast: 1 }),
toModelOutput: ({ callID, parameters, output }) => [{ type: "text", text: `${callID}:${parameters.city}:${output.forecast}` }],
toModelOutput: ({ callID, parameters, output }) => [
{ type: "text", text: `${callID}:${parameters.city}:${output.forecast}` },
],
})
LLM.stream(request)

View file

@ -43,7 +43,9 @@ function latestTool(assistant: SessionMessageAssistant | undefined, callID?: str
}
function latestText(assistant: SessionMessageAssistant | undefined, textID: string) {
return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text" && item.id === textID)
return assistant?.content.findLast(
(item): item is SessionMessageAssistantText => item.type === "text" && item.id === textID,
)
}
function latestReasoning(assistant: SessionMessageAssistant | undefined, reasoningID: string) {
@ -208,19 +210,28 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break
case "session.next.tool.input.delta":
update(event.properties.sessionID, (draft) => {
const match = latestTool(ownedAssistant(draft, event.properties.assistantMessageID), event.properties.callID)
const match = latestTool(
ownedAssistant(draft, event.properties.assistantMessageID),
event.properties.callID,
)
if (match?.state.status === "pending") match.state.input += event.properties.delta
})
break
case "session.next.tool.input.ended":
update(event.properties.sessionID, (draft) => {
const match = latestTool(ownedAssistant(draft, event.properties.assistantMessageID), event.properties.callID)
const match = latestTool(
ownedAssistant(draft, event.properties.assistantMessageID),
event.properties.callID,
)
if (match?.state.status === "pending") match.state.input = event.properties.text
})
break
case "session.next.tool.called":
update(event.properties.sessionID, (draft) => {
const match = latestTool(ownedAssistant(draft, event.properties.assistantMessageID), event.properties.callID)
const match = latestTool(
ownedAssistant(draft, event.properties.assistantMessageID),
event.properties.callID,
)
if (!match) return
match.time.ran = event.properties.timestamp
match.provider = event.properties.provider
@ -229,7 +240,10 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break
case "session.next.tool.progress":
update(event.properties.sessionID, (draft) => {
const match = latestTool(ownedAssistant(draft, event.properties.assistantMessageID), event.properties.callID)
const match = latestTool(
ownedAssistant(draft, event.properties.assistantMessageID),
event.properties.callID,
)
if (match?.state.status !== "running") return
match.state.structured = event.properties.structured
match.state.content = [...event.properties.content]
@ -237,7 +251,10 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break
case "session.next.tool.success":
update(event.properties.sessionID, (draft) => {
const match = latestTool(ownedAssistant(draft, event.properties.assistantMessageID), event.properties.callID)
const match = latestTool(
ownedAssistant(draft, event.properties.assistantMessageID),
event.properties.callID,
)
if (match?.state.status !== "running") return
match.state = {
status: "completed",
@ -252,7 +269,10 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break
case "session.next.tool.failed":
update(event.properties.sessionID, (draft) => {
const match = latestTool(ownedAssistant(draft, event.properties.assistantMessageID), event.properties.callID)
const match = latestTool(
ownedAssistant(draft, event.properties.assistantMessageID),
event.properties.callID,
)
if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return
match.state = {
status: "error",
@ -287,7 +307,8 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID)
if (match) {
match.text = event.properties.text
if (event.properties.providerMetadata !== undefined) match.providerMetadata = event.properties.providerMetadata
if (event.properties.providerMetadata !== undefined)
match.providerMetadata = event.properties.providerMetadata
}
})
break

View file

@ -1087,7 +1087,8 @@ function toolOutput(content?: Array<ToolTextContent | ToolFileContent>) {
return (content ?? [])
.map((item) => {
if (item.type === "text") return item.text.trim()
const source = item.source.type === "data" ? "inline data" : item.source.type === "url" ? item.source.url : item.source.uri
const source =
item.source.type === "data" ? "inline data" : item.source.type === "url" ? item.source.url : item.source.uri
return `[file ${item.name ?? source}]`
})
.filter(Boolean)

View file

@ -7,7 +7,15 @@ import { asSchema, type ModelMessage, type Tool } from "ai"
import { Cause, Effect, FiberSet, Queue } from "effect"
import * as Stream from "effect/Stream"
import { FetchHttpClient } from "effect/unstable/http"
import { LLMRequest, Tool as NativeTool, ToolFailure, ToolRuntime, toDefinitions, type JsonSchema, type LLMEvent } from "@opencode-ai/llm"
import {
LLMRequest,
Tool as NativeTool,
ToolFailure,
ToolRuntime,
toDefinitions,
type JsonSchema,
type LLMEvent,
} from "@opencode-ai/llm"
import type { LLMClientShape } from "@opencode-ai/llm/route"
import { LLMNative } from "./native-request"
@ -80,17 +88,17 @@ export function stream(input: StreamInput): StreamResult {
// translation belongs here, not split across both packages.
const tools = nativeTools(input.tools, input)
const request = LLMNative.request({
model: input.model,
apiKey: current.apiKey,
baseURL: current.baseURL,
messages: ProviderTransform.message(input.messages, input.model, input.providerOptions ?? {}),
toolChoice: input.toolChoice,
temperature: input.temperature,
topP: input.topP,
topK: input.topK,
maxOutputTokens: input.maxOutputTokens,
providerOptions: ProviderTransform.providerOptions(input.model, input.providerOptions ?? {}),
headers: { ...providerHeaders(input.provider.options.headers), ...input.headers },
model: input.model,
apiKey: current.apiKey,
baseURL: current.baseURL,
messages: ProviderTransform.message(input.messages, input.model, input.providerOptions ?? {}),
toolChoice: input.toolChoice,
temperature: input.temperature,
topP: input.topP,
topK: input.topK,
maxOutputTokens: input.maxOutputTokens,
providerOptions: ProviderTransform.providerOptions(input.model, input.providerOptions ?? {}),
headers: { ...providerHeaders(input.provider.options.headers), ...input.headers },
})
const stream = Stream.scoped(
Stream.unwrap(

View file

@ -426,7 +426,9 @@ export const layer = Layer.effect(
case "tool-input-delta":
{
const toolCall = yield* ensureToolCall(value)
const assistantMessageID = flags.experimentalEventSystem ? yield* requireV2AssistantMessage(toolCall.call) : undefined
const assistantMessageID = flags.experimentalEventSystem
? yield* requireV2AssistantMessage(toolCall.call)
: undefined
if (assistantMessageID) {
yield* events.publish(SessionEvent.Tool.Input.Delta, {
sessionID: ctx.sessionID,
@ -589,12 +591,19 @@ export const layer = Layer.effect(
const content = [
ToolOutput.text({ type: "text", text: output.output }),
...(output.attachments?.map((item: SessionV1.FilePart) =>
ToolOutput.file({ type: "file", source: toolFileSourceFromUri(item.url), mime: item.mime, name: item.filename }),
ToolOutput.file({
type: "file",
source: toolFileSourceFromUri(item.url),
mime: item.mime,
name: item.filename,
}),
) ?? []),
]
const unsupported = content.find((item) => item.type === "file" && item.source.type !== "data")
if (unsupported?.type === "file") {
const error = new Error(`Tool attachment source "${unsupported.source.type}" must be materialized before durable V2 settlement`)
const error = new Error(
`Tool attachment source "${unsupported.source.type}" must be materialized before durable V2 settlement`,
)
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: ctx.sessionID,
assistantMessageID,
@ -611,19 +620,20 @@ export const layer = Layer.effect(
})
yield* failToolCall(value.id, error)
return
} else yield* events.publish(SessionEvent.Tool.Success, {
sessionID: ctx.sessionID,
assistantMessageID,
callID: value.id,
structured: output.metadata,
content,
result: value.result,
provider: {
executed: value.providerExecuted === true || toolCall?.part.metadata?.providerExecuted === true,
...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),
},
timestamp: DateTime.makeUnsafe(Date.now()),
})
} else
yield* events.publish(SessionEvent.Tool.Success, {
sessionID: ctx.sessionID,
assistantMessageID,
callID: value.id,
structured: output.metadata,
content,
result: value.result,
provider: {
executed: value.providerExecuted === true || toolCall?.part.metadata?.providerExecuted === true,
...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),
},
timestamp: DateTime.makeUnsafe(Date.now()),
})
}
yield* completeToolCall(value.id, output)
return

View file

@ -107,7 +107,11 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
await wait(() => {
const assistant = sync.session.message.fromSession("session-1")[0]
return assistant?.type === "assistant" && assistant.content[0]?.type === "tool" && assistant.content[0].state.status === "error"
return (
assistant?.type === "assistant" &&
assistant.content[0]?.type === "tool" &&
assistant.content[0].state.status === "error"
)
})
const assistant = sync.session.message.fromSession("session-1")[0]

View file

@ -47,7 +47,7 @@ const appCache: Partial<Record<string, CachedApp>> = {}
export async function disposeApps() {
const apps = Object.values(appCache)
for (const key of Object.keys(appCache)) delete appCache[key]
await Promise.all(apps.flatMap((app) => app === undefined ? [] : [app.dispose()]))
await Promise.all(apps.flatMap((app) => (app === undefined ? [] : [app.dispose()])))
}
function app(modules: Runtime, options: CallOptions) {

View file

@ -493,10 +493,12 @@ describe("session HttpApi", () => {
direction: "next",
})
const nextMessagePage = yield* request(`/api/session/${session.id}/message?cursor=${messageCursor}`, { headers })
expect((yield* json<{ items: SessionMessage.Message[] }>(nextMessagePage)).items.map((message) => message.id)).toEqual([
firstMessage.id,
])
const nextMessagePage = yield* request(`/api/session/${session.id}/message?cursor=${messageCursor}`, {
headers,
})
expect(
(yield* json<{ items: SessionMessage.Message[] }>(nextMessagePage)).items.map((message) => message.id),
).toEqual([firstMessage.id])
const legacyMessageCursor = Buffer.from(
JSON.stringify({ id: secondMessage.id, time: 1, order: "desc", direction: "next" }),
@ -504,9 +506,9 @@ describe("session HttpApi", () => {
const legacyMessagePage = yield* request(`/api/session/${session.id}/message?cursor=${legacyMessageCursor}`, {
headers,
})
expect((yield* json<{ items: SessionMessage.Message[] }>(legacyMessagePage)).items.map((message) => message.id)).toEqual([
firstMessage.id,
])
expect(
(yield* json<{ items: SessionMessage.Message[] }>(legacyMessagePage)).items.map((message) => message.id),
).toEqual([firstMessage.id])
const messageCursorWithOrder = yield* request(
`/api/session/${session.id}/message?cursor=${messageCursor}&order=asc`,

View file

@ -280,20 +280,18 @@ function recordedNativeLLMLayer(scenario: RecordedScenario) {
)
// Only the HTTP client is recorded; RequestExecutor and the opencode LLM stack remain real.
const recordedHttp = HttpRecorder.cassetteLayer(scenario.cassette, {
directory: FIXTURES_DIR,
mode: shouldRecord ? "record" : "replay",
metadata: {
provider: scenario.providerID,
protocol: scenario.protocol,
route: scenario.protocol,
tags: scenario.tags,
},
redactor: recordingRedactor,
})
directory: FIXTURES_DIR,
mode: shouldRecord ? "record" : "replay",
metadata: {
provider: scenario.providerID,
protocol: scenario.protocol,
route: scenario.protocol,
tags: scenario.tags,
},
redactor: recordingRedactor,
})
const recordedClient = LLMClient.layer.pipe(
Layer.provide(
Layer.mergeAll(RequestExecutor.layer.pipe(Layer.provide(recordedHttp)), WebSocketExecutor.layer),
),
Layer.provide(Layer.mergeAll(RequestExecutor.layer.pipe(Layer.provide(recordedHttp)), WebSocketExecutor.layer)),
)
return Layer.mergeAll(

View file

@ -1004,7 +1004,8 @@ itProviderError.live("session.processor effect tests fail provider-executed erro
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
const settlements: Array<typeof SessionEvent.Tool.Failed.Type> = []
const off = yield* events.listen((event) => {
if (event.type === SessionEvent.Tool.Failed.type) settlements.push(event as typeof SessionEvent.Tool.Failed.Type)
if (event.type === SessionEvent.Tool.Failed.type)
settlements.push(event as typeof SessionEvent.Tool.Failed.Type)
return Effect.void
})
const handle = yield* processors.create({ assistantMessage: msg, sessionID: chat.id, model: mdl })
@ -1059,7 +1060,8 @@ itFragmentFailure.live("session.processor effect tests flush partial v2 fragment
let reasoning: string | undefined
const off = yield* events.listen((event) => {
seen.push(event.type)
if (event.type === SessionEvent.Text.Ended.type) text = (event.data as typeof SessionEvent.Text.Ended.data.Type).text
if (event.type === SessionEvent.Text.Ended.type)
text = (event.data as typeof SessionEvent.Text.Ended.data.Type).text
if (event.type === SessionEvent.Reasoning.Ended.type)
reasoning = (event.data as typeof SessionEvent.Reasoning.Ended.data.Type).text
return Effect.void

View file

@ -46,12 +46,12 @@ export type Event =
| EventSessionError
| EventInstallationUpdated
| EventInstallationUpdateAvailable
| EventPermissionV2Asked
| EventPermissionV2Replied
| EventFileEdited
| EventAccountAdded
| EventAccountRemoved
| EventAccountSwitched
| EventPermissionV2Asked
| EventPermissionV2Replied
| EventFileWatcherUpdated
| EventPtyCreated
| EventPtyUpdated
@ -1163,30 +1163,6 @@ export type GlobalEvent = {
version: string
}
}
| {
id: string
type: "permission.v2.asked"
properties: {
id: string
sessionID: string
action: string
resources: Array<string>
save?: Array<string>
metadata?: {
[key: string]: unknown
}
source?: PermissionV2Source
}
}
| {
id: string
type: "permission.v2.replied"
properties: {
sessionID: string
requestID: string
reply: PermissionV2Reply
}
}
| {
id: string
type: "file.edited"
@ -1217,6 +1193,30 @@ export type GlobalEvent = {
to?: string
}
}
| {
id: string
type: "permission.v2.asked"
properties: {
id: string
sessionID: string
action: string
resources: Array<string>
save?: Array<string>
metadata?: {
[key: string]: unknown
}
source?: PermissionV2Source
}
}
| {
id: string
type: "permission.v2.replied"
properties: {
sessionID: string
requestID: string
reply: PermissionV2Reply
}
}
| {
id: string
type: "file.watcher.updated"
@ -2893,14 +2893,6 @@ export type SessionNextRetryError = {
}
}
export type PermissionV2Source = {
type: "tool"
messageID: string
callID: string
}
export type PermissionV2Reply = "once" | "always" | "reject"
export type AuthOAuthCredential = {
type: "oauth"
refresh: string
@ -2925,6 +2917,14 @@ export type AuthInfo = {
credential: AuthCredential
}
export type PermissionV2Source = {
type: "tool"
messageID: string
callID: string
}
export type PermissionV2Reply = "once" | "always" | "reject"
export type QuestionV2Option = {
/**
* Display text (1-5 words, concise)
@ -4404,32 +4404,6 @@ export type EventInstallationUpdateAvailable = {
}
}
export type EventPermissionV2Asked = {
id: string
type: "permission.v2.asked"
properties: {
id: string
sessionID: string
action: string
resources: Array<string>
save?: Array<string>
metadata?: {
[key: string]: unknown
}
source?: PermissionV2Source
}
}
export type EventPermissionV2Replied = {
id: string
type: "permission.v2.replied"
properties: {
sessionID: string
requestID: string
reply: PermissionV2Reply
}
}
export type EventFileEdited = {
id: string
type: "file.edited"
@ -4464,6 +4438,32 @@ export type EventAccountSwitched = {
}
}
export type EventPermissionV2Asked = {
id: string
type: "permission.v2.asked"
properties: {
id: string
sessionID: string
action: string
resources: Array<string>
save?: Array<string>
metadata?: {
[key: string]: unknown
}
source?: PermissionV2Source
}
}
export type EventPermissionV2Replied = {
id: string
type: "permission.v2.replied"
properties: {
sessionID: string
requestID: string
reply: PermissionV2Reply
}
}
export type EventFileWatcherUpdated = {
id: string
type: "file.watcher.updated"

View file

@ -11655,12 +11655,6 @@
{
"$ref": "#/components/schemas/EventInstallationUpdate-available"
},
{
"$ref": "#/components/schemas/EventPermissionV2Asked"
},
{
"$ref": "#/components/schemas/EventPermissionV2Replied"
},
{
"$ref": "#/components/schemas/EventFileEdited"
},
@ -11673,6 +11667,12 @@
{
"$ref": "#/components/schemas/EventAccountSwitched"
},
{
"$ref": "#/components/schemas/EventPermissionV2Asked"
},
{
"$ref": "#/components/schemas/EventPermissionV2Replied"
},
{
"$ref": "#/components/schemas/EventFileWatcherUpdated"
},
@ -15112,88 +15112,6 @@
"required": ["id", "type", "properties"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["permission.v2.asked"]
},
"properties": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^per"
},
"sessionID": {
"type": "string",
"pattern": "^ses"
},
"action": {
"type": "string"
},
"resources": {
"type": "array",
"items": {
"type": "string"
}
},
"save": {
"type": "array",
"items": {
"type": "string"
}
},
"metadata": {
"type": "object"
},
"source": {
"$ref": "#/components/schemas/PermissionV2Source"
}
},
"required": ["id", "sessionID", "action", "resources"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["permission.v2.replied"]
},
"properties": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"pattern": "^ses"
},
"requestID": {
"type": "string",
"pattern": "^per"
},
"reply": {
"$ref": "#/components/schemas/PermissionV2Reply"
}
},
"required": ["sessionID", "requestID", "reply"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
@ -15296,6 +15214,88 @@
"required": ["id", "type", "properties"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["permission.v2.asked"]
},
"properties": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^per"
},
"sessionID": {
"type": "string",
"pattern": "^ses"
},
"action": {
"type": "string"
},
"resources": {
"type": "array",
"items": {
"type": "string"
}
},
"save": {
"type": "array",
"items": {
"type": "string"
}
},
"metadata": {
"type": "object"
},
"source": {
"$ref": "#/components/schemas/PermissionV2Source"
}
},
"required": ["id", "sessionID", "action", "resources"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["permission.v2.replied"]
},
"properties": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"pattern": "^ses"
},
"requestID": {
"type": "string",
"pattern": "^per"
},
"reply": {
"$ref": "#/components/schemas/PermissionV2Reply"
}
},
"required": ["sessionID", "requestID", "reply"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
@ -20175,27 +20175,6 @@
"required": ["message", "isRetryable"],
"additionalProperties": false
},
"PermissionV2Source": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["tool"]
},
"messageID": {
"type": "string"
},
"callID": {
"type": "string"
}
},
"required": ["type", "messageID", "callID"],
"additionalProperties": false
},
"PermissionV2Reply": {
"type": "string",
"enum": ["once", "always", "reject"]
},
"AuthOAuthCredential": {
"type": "object",
"properties": {
@ -20266,6 +20245,27 @@
"required": ["id", "serviceID", "description", "credential"],
"additionalProperties": false
},
"PermissionV2Source": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["tool"]
},
"messageID": {
"type": "string"
},
"callID": {
"type": "string"
}
},
"required": ["type", "messageID", "callID"],
"additionalProperties": false
},
"PermissionV2Reply": {
"type": "string",
"enum": ["once", "always", "reject"]
},
"QuestionV2Option": {
"type": "object",
"properties": {
@ -24737,88 +24737,6 @@
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventPermissionV2Asked": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["permission.v2.asked"]
},
"properties": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^per"
},
"sessionID": {
"type": "string",
"pattern": "^ses"
},
"action": {
"type": "string"
},
"resources": {
"type": "array",
"items": {
"type": "string"
}
},
"save": {
"type": "array",
"items": {
"type": "string"
}
},
"metadata": {
"type": "object"
},
"source": {
"$ref": "#/components/schemas/PermissionV2Source"
}
},
"required": ["id", "sessionID", "action", "resources"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventPermissionV2Replied": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["permission.v2.replied"]
},
"properties": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"pattern": "^ses"
},
"requestID": {
"type": "string",
"pattern": "^per"
},
"reply": {
"$ref": "#/components/schemas/PermissionV2Reply"
}
},
"required": ["sessionID", "requestID", "reply"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventFileEdited": {
"type": "object",
"properties": {
@ -24921,6 +24839,88 @@
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventPermissionV2Asked": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["permission.v2.asked"]
},
"properties": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^per"
},
"sessionID": {
"type": "string",
"pattern": "^ses"
},
"action": {
"type": "string"
},
"resources": {
"type": "array",
"items": {
"type": "string"
}
},
"save": {
"type": "array",
"items": {
"type": "string"
}
},
"metadata": {
"type": "object"
},
"source": {
"$ref": "#/components/schemas/PermissionV2Source"
}
},
"required": ["id", "sessionID", "action", "resources"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventPermissionV2Replied": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["permission.v2.replied"]
},
"properties": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"pattern": "^ses"
},
"requestID": {
"type": "string",
"pattern": "^per"
},
"reply": {
"$ref": "#/components/schemas/PermissionV2Reply"
}
},
"required": ["sessionID", "requestID", "reply"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventFileWatcherUpdated": {
"type": "object",
"properties": {

View file

@ -232,7 +232,10 @@ export interface Interface {
}
readonly model: {
readonly get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => Effect.Effect<ModelV2.Info, ProviderNotFoundError | ModelNotFoundError>
readonly get: (
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
) => Effect.Effect<ModelV2.Info, ProviderNotFoundError | ModelNotFoundError>
readonly all: () => Effect.Effect<ModelV2.Info[]>
readonly available: () => Effect.Effect<ModelV2.Info[]>
readonly default: () => Effect.Effect<Option.Option<ModelV2.Info>>