diff --git a/packages/core/migration/20260603001617_session_message_projection_indexes/snapshot.json b/packages/core/migration/20260603001617_session_message_projection_indexes/snapshot.json index 34adfe1ff4..e89ee64555 100644 --- a/packages/core/migration/20260603001617_session_message_projection_indexes/snapshot.json +++ b/packages/core/migration/20260603001617_session_message_projection_indexes/snapshot.json @@ -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", diff --git a/packages/core/migration/20260603160727_jittery_ezekiel_stane/snapshot.json b/packages/core/migration/20260603160727_jittery_ezekiel_stane/snapshot.json index 49314e851b..a2ec834e77 100644 --- a/packages/core/migration/20260603160727_jittery_ezekiel_stane/snapshot.json +++ b/packages/core/migration/20260603160727_jittery_ezekiel_stane/snapshot.json @@ -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": [] -} \ No newline at end of file +} diff --git a/packages/core/src/config/plugin/skill.ts b/packages/core/src/config/plugin/skill.ts index 736fc675e6..445ad4e2ea 100644 --- a/packages/core/src/config/plugin/skill.ts +++ b/packages/core/src/config/plugin/skill.ts @@ -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) => { diff --git a/packages/core/src/database/migration/20260603001617_session_message_projection_indexes.ts b/packages/core/src/database/migration/20260603001617_session_message_projection_indexes.ts index 8dfd06fa77..85b5cd9463 100644 --- a/packages/core/src/database/migration/20260603001617_session_message_projection_indexes.ts +++ b/packages/core/src/database/migration/20260603001617_session_message_projection_indexes.ts @@ -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 diff --git a/packages/core/src/database/migration/20260603040000_session_message_projection_order.ts b/packages/core/src/database/migration/20260603040000_session_message_projection_order.ts index 77f44f88db..bb539d93d2 100644 --- a/packages/core/src/database/migration/20260603040000_session_message_projection_order.ts +++ b/packages/core/src/database/migration/20260603040000_session_message_projection_order.ts @@ -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 diff --git a/packages/core/src/database/migration/20260603160727_jittery_ezekiel_stane.ts b/packages/core/src/database/migration/20260603160727_jittery_ezekiel_stane.ts index 07230cc6a8..bcfc1afdd6 100644 --- a/packages/core/src/database/migration/20260603160727_jittery_ezekiel_stane.ts +++ b/packages/core/src/database/migration/20260603160727_jittery_ezekiel_stane.ts @@ -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\`);`, ) diff --git a/packages/core/src/effect/keyed-mutex.ts b/packages/core/src/effect/keyed-mutex.ts index be4220516d..e7c69b7db9 100644 --- a/packages/core/src/effect/keyed-mutex.ts +++ b/packages/core/src/effect/keyed-mutex.ts @@ -20,21 +20,23 @@ export interface KeyedMutex { export const makeUnsafe = (): KeyedMutex => { const locks = new Map() - const withLock = (key: Key) => (effect: Effect.Effect) => - 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) => + (effect: Effect.Effect) => + 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 } } diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 27e682a2a5..79708e4d3d 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -146,7 +146,10 @@ export interface Interface { ) => Effect.Effect> readonly subscribe: (definition: D) => Stream.Stream> readonly all: () => Stream.Stream - readonly aggregateEvents: (input: { readonly aggregateID: string; readonly after?: Cursor }) => Stream.Stream + readonly aggregateEvents: (input: { + readonly aggregateID: string + readonly after?: Cursor + }) => Stream.Stream readonly sync: (handler: Sync) => Effect.Effect readonly listen: (listener: Listener) => Effect.Effect readonly beforeCommit: (guard: CommitGuard) => Effect.Effect @@ -169,394 +172,434 @@ export interface LayerOptions { readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect } -export const layerWith = (options?: LayerOptions) => Layer.effect( - Service, - Effect.gen(function* () { - const all = yield* PubSub.unbounded() - const synchronized = new Map>>() - const typed = new Map>() - const projectors = new Map() - const commitGuards = new Array() - const listeners = new Array() - const syncHandlers = new Array() - const { db } = yield* Database.Service +export const layerWith = (options?: LayerOptions) => + Layer.effect( + Service, + Effect.gen(function* () { + const all = yield* PubSub.unbounded() + const synchronized = new Map>>() + const typed = new Map>() + const projectors = new Map() + const commitGuards = new Array() + const listeners = new Array() + const syncHandlers = new Array() + 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() - 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)[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, - }, - ]) - .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(event: Payload) { - 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(definition: D, data: Data, 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) - }) - } - - 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 = (definition: D): Stream.Stream> => - Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe( - Stream.map((event) => event as Payload), - ) - - const streamAll = (): Stream.Stream => 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(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 => - 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() + 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 => - 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)[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, + }, + ]) + .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 => - Effect.sync(() => { - syncHandlers.push(handler) - return Effect.sync(() => { - const index = syncHandlers.indexOf(handler) - if (index >= 0) syncHandlers.splice(index, 1) + function publishEvent(event: Payload) { + 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 => - Effect.sync(() => { - commitGuards.push(guard) - }) + function publish(definition: D, data: Data, 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) + }) + } - const project = (definition: D, projector: Projector): Effect.Effect => - Effect.sync(() => { - const list = projectors.get(definition.type) ?? [] - list.push((event) => projector(event as Payload)) - 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 = (definition: D): Stream.Stream> => + Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe( + Stream.map((event) => event as Payload), + ) + + const streamAll = (): Stream.Stream => 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(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 => + 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 => + 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 => + 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 => + Effect.sync(() => { + commitGuards.push(guard) + }) + + const project = (definition: D, projector: Projector): Effect.Effect => + Effect.sync(() => { + const list = projectors.get(definition.type) ?? [] + list.push((event) => projector(event as Payload)) + 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() diff --git a/packages/core/src/file-mutation.ts b/packages/core/src/file-mutation.ts index ed5429dd58..9eab32e8a2 100644 --- a/packages/core/src/file-mutation.ts +++ b/packages/core/src/file-mutation.ts @@ -52,17 +52,23 @@ export interface RemoveResult { export interface Interface { /** Create only while the planned target remains absent. */ - readonly create: (input: WriteInput) => Effect.Effect + readonly create: ( + input: WriteInput, + ) => Effect.Effect /** Write after immediately revalidating the planned target. */ readonly write: (input: WriteInput) => Effect.Effect /** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */ - readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect + readonly writeTextPreservingBom: ( + input: TextWriteInput, + ) => Effect.Effect /** Commit only if an existing target still has the expected bytes. */ readonly writeIfUnchanged: ( input: ConditionalWriteInput, ) => Effect.Effect /** Remove after immediately revalidating the planned target. */ - readonly remove: (input: RemoveInput) => Effect.Effect + readonly remove: ( + input: RemoveInput, + ) => Effect.Effect } export class Service extends Context.Service()("@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() - const withTargetLock = (target: string) => (effect: Effect.Effect) => - locks.withLock(target)(Effect.uninterruptible(effect)) + const withTargetLock = + (target: string) => + (effect: Effect.Effect) => + locks.withLock(target)(Effect.uninterruptible(effect)) - const withValidatedTarget = (plan: LocationMutation.Plan) => ( - commit: (target: LocationMutation.Target) => Effect.Effect, - ) => withTargetLock(plan.target.canonical)(mutation.revalidate(plan).pipe(Effect.flatMap(commit))) + const withValidatedTarget = + (plan: LocationMutation.Plan) => + (commit: (target: LocationMutation.Target) => Effect.Effect) => + 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) }), diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index b2b113be83..e8efd1c6be 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -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 }) diff --git a/packages/core/src/location-mutation.ts b/packages/core/src/location-mutation.ts index b000be8a38..3d5364898a 100644 --- a/packages/core/src/location-mutation.ts +++ b/packages/core/src/location-mutation.ts @@ -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) { diff --git a/packages/core/src/location-search.ts b/packages/core/src/location-search.ts index d162ea2073..1d312e7e5b 100644 --- a/packages/core/src/location-search.ts +++ b/packages/core/src/location-search.ts @@ -83,7 +83,10 @@ export class GrepResult extends Schema.Class("LocationSearch.GrepRes export interface Interface { readonly files: (input: FilesInput, root?: FileSystem.RootTarget) => Effect.Effect - readonly grep: (input: GrepInput, root?: FileSystem.RootTarget) => Effect.Effect + readonly grep: ( + input: GrepInput, + root?: FileSystem.RootTarget, + ) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/LocationSearch") {} diff --git a/packages/core/src/opencode.ts b/packages/core/src/opencode.ts index 052536e364..7c71916426 100644 --- a/packages/core/src/opencode.ts +++ b/packages/core/src/opencode.ts @@ -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. diff --git a/packages/core/src/patch.ts b/packages/core/src/patch.ts index 2a0f83e747..a4370d44aa 100644 --- a/packages/core/src/patch.ts +++ b/packages/core/src/patch.ts @@ -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 } + | { + readonly type: "update" + readonly path: string + readonly movePath?: string + readonly chunks: ReadonlyArray + } export interface UpdateFileChunk { readonly oldLines: ReadonlyArray @@ -166,7 +171,12 @@ function seek(lines: ReadonlyArray, pattern: ReadonlyArray, star return -1 } -function matches(lines: ReadonlyArray, pattern: ReadonlyArray, offset: number, compare: (left: string, right: string) => boolean) { +function matches( + lines: ReadonlyArray, + pattern: ReadonlyArray, + 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 diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index 4cc52b96aa..bd4a9527ff 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -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) diff --git a/packages/core/src/ripgrep.ts b/packages/core/src/ripgrep.ts index 8247dd77ba..5d40e68870 100644 --- a/packages/core/src/ripgrep.ts +++ b/packages/core/src/ripgrep.ts @@ -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()("Ripgrep.Error", { message: Schema.String, @@ -77,7 +77,8 @@ export class Service extends Context.Service()("@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({ ...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)), ) }), diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 19928c9069..97b24dbda8 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -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. diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index bdf463ac6a..0dcb2e5e0c 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -132,8 +132,14 @@ export interface Interface { sessionID: SessionSchema.ID after?: EventV2.Cursor }) => Stream.Stream, NotFoundError> - readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect - readonly switchModel: (input: { sessionID: SessionSchema.ID; model: ModelV2.Ref }) => Effect.Effect + readonly switchAgent: (input: { + sessionID: SessionSchema.ID + agent: string + }) => Effect.Effect + readonly switchModel: (input: { + sessionID: SessionSchema.ID + model: ModelV2.Ref + }) => Effect.Effect 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 diff --git a/packages/core/src/session/input.ts b/packages/core/src/session/input.ts index fd937e53fe..e252c92d9b 100644 --- a/packages/core/src/session/input.ts +++ b/packages/core/src/session/input.ts @@ -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, diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index f54490533a..cf201d5c09 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -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() diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index ad212ac961..6c9bec6dd3 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -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) => { + const fragments = ( + name: string, + ended: (id: string, value: string, providerMetadata?: ProviderMetadata) => Effect.Effect, + ) => { const chunks = new Map() 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, { diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index 04bf9858bd..75c8fce6a5 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -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] diff --git a/packages/core/src/session/schema.ts b/packages/core/src/session/schema.ts index 940678b1a1..8509cabee4 100644 --- a/packages/core/src/session/schema.ts +++ b/packages/core/src/session/schema.ts @@ -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)), } }), diff --git a/packages/core/src/skill/discovery.ts b/packages/core/src/skill/discovery.ts index b8d51b9904..d5f2b9510f 100644 --- a/packages/core/src/skill/discovery.ts +++ b/packages/core/src/skill/discovery.ts @@ -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)] diff --git a/packages/core/src/tool-output-store.ts b/packages/core/src/tool-output-store.ts index 90d1ea9427..2385452c25 100644 --- a/packages/core/src/tool-output-store.ts +++ b/packages/core/src/tool-output-store.ts @@ -33,14 +33,20 @@ export class Page extends Schema.Class("ToolOutputStore.Page")({ next: NonNegativeInt.pipe(Schema.optional), }) {} -export class AccessDeniedError extends Schema.TaggedErrorClass()("ToolOutputStore.AccessDeniedError", { - uri: Schema.String, - sessionID: SessionSchema.ID, -}) {} +export class AccessDeniedError extends Schema.TaggedErrorClass()( + "ToolOutputStore.AccessDeniedError", + { + uri: Schema.String, + sessionID: SessionSchema.ID, + }, +) {} -export class InvalidResourceError extends Schema.TaggedErrorClass()("ToolOutputStore.InvalidResourceError", { - uri: Schema.String, -}) {} +export class InvalidResourceError extends Schema.TaggedErrorClass()( + "ToolOutputStore.InvalidResourceError", + { + uri: Schema.String, + }, +) {} export class ResourceNotFoundError extends Schema.TaggedErrorClass()( "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 readonly truncate: (input: TruncateInput) => Effect.Effect - readonly read: (input: ReadInput) => Effect.Effect + readonly read: ( + input: ReadInput, + ) => Effect.Effect readonly cleanup: () => Effect.Effect } @@ -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 || diff --git a/packages/core/src/tool-registry.ts b/packages/core/src/tool-registry.ts index daaf2c3bdc..87c22dedb8 100644 --- a/packages/core/src/tool-registry.ts +++ b/packages/core/src/tool-registry.ts @@ -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 = Invocation & { readonly parameters: Parameters } -export type Entry = ToolSchema, Success extends ToolSchema = ToolSchema> = { +export type Entry< + Parameters extends ToolSchema = ToolSchema, + Success extends ToolSchema = ToolSchema, +> = { readonly tool: TypedTool readonly authorize?: (input: AuthorizeInput>) => Effect.Effect - readonly execute?: (input: AuthorizeInput>) => Effect.Effect, ToolFailure> + readonly execute?: ( + input: AuthorizeInput>, + ) => Effect.Effect, ToolFailure> } type Data = { @@ -48,7 +63,10 @@ type Data = { export type Editor = { readonly list: () => ReadonlyArray readonly get: (name: string) => Entry | undefined - readonly set: , Success extends ToolSchema>(name: string, entry: Entry) => void + readonly set: , Success extends ToolSchema>( + name: string, + entry: Entry, + ) => void readonly remove: (name: string) => void } @@ -65,84 +83,91 @@ export class Service extends Context.Service()("@opencode/v2 enableMapSet() export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const permission = yield* PermissionV2.Service - const state = State.create({ - 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 ? Value : never) - }, - remove: (name) => { - draft.entries.delete(name) - }, - }), - }) + Service, + Effect.gen(function* () { + const permission = yield* PermissionV2.Service + const state = State.create({ + 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 ? 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, + }) + }), +) diff --git a/packages/core/src/tool/apply-patch.ts b/packages/core/src/tool/apply-patch.ts index 88c9a75057..74b66864b5 100644 --- a/packages/core/src/tool/apply-patch.ts +++ b/packages/core/src/tool/apply-patch.ts @@ -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; readonly plan: LocationMutation.Plan } - | { readonly type: "delete"; readonly hunk: Extract; readonly plan: LocationMutation.Plan } - | { readonly type: "update"; readonly hunk: Extract; readonly plan: LocationMutation.Plan; readonly source: Uint8Array; readonly content: string } + | { + readonly type: "add" + readonly hunk: Extract + readonly plan: LocationMutation.Plan + } + | { + readonly type: "delete" + readonly hunk: Extract + readonly plan: LocationMutation.Plan + } + | { + readonly type: "update" + readonly hunk: Extract + 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 = [] 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() 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( diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index 01eed4be99..c16ce9abbd 100644 --- a/packages/core/src/tool/edit.ts +++ b/packages/core/src/tool/edit.ts @@ -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 }) diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index 9b90bbe138..b16308bef8 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -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), + }), + ), ), ), }), diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index 842c6910f9..6bd20093b8 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -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 })) }), ), diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index a9be2ece00..67f62eb388 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -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( diff --git a/packages/core/src/tool/webfetch.ts b/packages/core/src/tool/webfetch.ts index d4d9db7dc6..1f0d71c938 100644 --- a/packages/core/src/tool/webfetch.ts +++ b/packages/core/src/tool/webfetch.ts @@ -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) diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index d43f70afe4..f0438dd3ef 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -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()("@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 = ( 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), + }), + ), ), ) }, diff --git a/packages/core/test/agent.test.ts b/packages/core/test/agent.test.ts index 787b16a605..eaaaaa74fb 100644 --- a/packages/core/test/agent.test.ts +++ b/packages/core/test/agent.test.ts @@ -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) } diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 6ed0b9b3a7..0a3ce9ef57 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -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") { diff --git a/packages/core/test/effect/keyed-mutex.test.ts b/packages/core/test/effect/keyed-mutex.test.ts index e4fb587344..b6638ff4b1 100644 --- a/packages/core/test/effect/keyed-mutex.test.ts +++ b/packages/core/test/effect/keyed-mutex.test.ts @@ -12,7 +12,9 @@ describe("KeyedMutex", () => { const secondStarted = yield* Deferred.make() 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() 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) diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index 06c40a5ffb..a51c9f093f 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -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() - 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() + 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") }), diff --git a/packages/core/test/file-mutation.test.ts b/packages/core/test/file-mutation.test.ts index 72b7608c9a..ccc695439e 100644 --- a/packages/core/test/file-mutation.test.ts +++ b/packages/core/test/file-mutation.test.ts @@ -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) diff --git a/packages/core/test/location-search.test.ts b/packages/core/test/location-search.test.ts index 2ee2df1fd9..b3e105c8ea 100644 --- a/packages/core/test/location-search.test.ts +++ b/packages/core/test/location-search.test.ts @@ -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)), ), diff --git a/packages/core/test/patch.test.ts b/packages/core/test/patch.test.ts index fa91010f1f..10560bf928 100644 --- a/packages/core/test/patch.test.ts +++ b/packages/core/test/patch.test.ts @@ -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", + ) }) }) diff --git a/packages/core/test/permission.test.ts b/packages/core/test/permission.test.ts index 21df457de7..5bd13d844a 100644 --- a/packages/core/test/permission.test.ts +++ b/packages/core/test/permission.test.ts @@ -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), diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 5ce1912869..7d373cd75a 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -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) => + const unavailable = ( + effect: Effect.Effect, + ) => effect.pipe( Effect.flip, Effect.map((error) => (error instanceof SessionV2.OperationUnavailableError ? error.operation : "not-found")), diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index cbdeea4b23..1493bad841 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -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 }) }), ) diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index d97ab281d0..431261749e 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -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" }, + ]) }), ) diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index c6dd230505..de6cc025dd 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -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([ { diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index 68d851861a..e4b5eb2903 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -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"), diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index daa24403d4..640cf3cedc 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -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([ diff --git a/packages/core/test/session-tool-progress.test.ts b/packages/core/test/session-tool-progress.test.ts index 0e9e0f872d..f69743e701 100644 --- a/packages/core/test/session-tool-progress.test.ts +++ b/packages/core/test/session-tool-progress.test.ts @@ -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)) diff --git a/packages/core/test/skill-discovery.test.ts b/packages/core/test/skill-discovery.test.ts index fc83de19c7..5fcecae4c7 100644 --- a/packages/core/test/skill-discovery.test.ts +++ b/packages/core/test/skill-discovery.test.ts @@ -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( diff --git a/packages/core/test/tool-apply-patch.test.ts b/packages/core/test/tool-apply-patch.test.ts index 6d070fe152..e461335060 100644 --- a/packages/core/test/tool-apply-patch.test.ts +++ b/packages/core/test/tool-apply-patch.test.ts @@ -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) diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index 558e1f0b10..2299408540 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -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([]) }), diff --git a/packages/core/test/tool-glob.test.ts b/packages/core/test/tool-glob.test.ts index 8fe24b2d6a..09bb54c2bb 100644 --- a/packages/core/test/tool-glob.test.ts +++ b/packages/core/test/tool-glob.test.ts @@ -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, }) diff --git a/packages/core/test/tool-grep.test.ts b/packages/core/test/tool-grep.test.ts index d4b72acf57..4459db4dba 100644 --- a/packages/core/test/tool-grep.test.ts +++ b/packages/core/test/tool-grep.test.ts @@ -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 } }))), + ) }), ), ) diff --git a/packages/core/test/tool-output-store.test.ts b/packages/core/test/tool-output-store.test.ts index 3aa4ad9bc4..aa1eaa09a0 100644 --- a/packages/core/test/tool-output-store.test.ts +++ b/packages/core/test/tool-output-store.test.ts @@ -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() diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index 4b8ca69a53..c0a56f5001 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -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", diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index a63ae25315..8297478166 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -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, diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index f282080f3d..b5d06e94d5 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -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") }), diff --git a/packages/llm/example/tutorial.ts b/packages/llm/example/tutorial.ts index f233497cff..407e2a555e 100644 --- a/packages/llm/example/tutorial.ts +++ b/packages/llm/example/tutorial.ts @@ -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) } }) diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index 0be0644fb6..330089ae95 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -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 } diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index d437a004a4..2b3a2e9510 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -243,7 +243,10 @@ const bedrockMetadata = (metadata: Record): 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 [ diff --git a/packages/llm/src/protocols/gemini.ts b/packages/llm/src/protocols/gemini.ts index 480ce17b89..93159a1b64 100644 --- a/packages/llm/src/protocols/gemini.ts +++ b/packages/llm/src/protocols/gemini.ts @@ -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 diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index e15a2b588e..b9bf1fc7a3 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -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), } }) diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 99fc91a18f..2f354427fe 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -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 } diff --git a/packages/llm/src/protocols/shared.ts b/packages/llm/src/protocols/shared.ts index a14fd20c54..ca325ff823 100644 --- a/packages/llm/src/protocols/shared.ts +++ b/packages/llm/src/protocols/shared.ts @@ -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("&", "&").replaceAll("<", "<").replaceAll(">", ">") +const escapeSystemUpdateText = (text: string) => + text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">") /** * Stable fallback representation for chronological `Message.system(...)` diff --git a/packages/llm/src/schema/messages.ts b/packages/llm/src/schema/messages.ts index beff70fe2b..8a0a91b56b 100644 --- a/packages/llm/src/schema/messages.ts +++ b/packages/llm/src/schema/messages.ts @@ -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 } }), } diff --git a/packages/llm/src/tool-runtime.ts b/packages/llm/src/tool-runtime.ts index c8d5e2d87b..d69bbb9d47 100644 --- a/packages/llm/src/tool-runtime.ts +++ b/packages/llm/src/tool-runtime.ts @@ -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 { +const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement, error?: unknown): DispatchResult => { const settlement = ToolResultValue.is(value) ? { result: value } : value return { result: settlement.result, diff --git a/packages/llm/src/tool.ts b/packages/llm/src/tool.ts index 5179d68252..6fd052c7b8 100644 --- a/packages/llm/src/tool.ts +++ b/packages/llm/src/tool.ts @@ -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, Success extends ToolSc /** @internal */ readonly _encode: (value: Schema.Schema.Type) => Effect.Effect /** @internal */ - readonly _project: (parameters: Schema.Schema.Type, callID: ToolCallPart["id"], output: unknown) => ToolOutputType + readonly _project: ( + parameters: Schema.Schema.Type, + callID: ToolCallPart["id"], + output: unknown, + ) => ToolOutputType /** @internal */ readonly _legacyResult: boolean /** @internal */ diff --git a/packages/llm/test/lib/tool-runtime.ts b/packages/llm/test/lib/tool-runtime.ts index 1808cd79ed..28ebc47c71 100644 --- a/packages/llm/test/lib/tool-runtime.ts +++ b/packages/llm/test/lib/tool-runtime.ts @@ -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 }) diff --git a/packages/llm/test/llm.test.ts b/packages/llm/test/llm.test.ts index 22329986ca..633a4662da 100644 --- a/packages/llm/test/llm.test.ts +++ b/packages/llm/test/llm.test.ts @@ -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.", diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index c55316c814..50fced0de3 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -87,7 +87,11 @@ describe("Anthropic Messages route", () => { const prepared = yield* LLMClient.prepare( LLM.request({ model, - messages: [Message.user("Before."), Message.system("Treat literally."), Message.assistant("After.")], + messages: [ + Message.user("Before."), + Message.system("Treat 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") }), ) diff --git a/packages/llm/test/provider/bedrock-converse.test.ts b/packages/llm/test/provider/bedrock-converse.test.ts index effd208627..d6ba144c81 100644 --- a/packages/llm/test/provider/bedrock-converse.test.ts +++ b/packages/llm/test/provider/bedrock-converse.test.ts @@ -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" } } }], + }, ]) }), ) diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index 3048ca7f62..5d39502b31 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -55,12 +55,19 @@ describe("OpenAI Chat route", () => { const prepared = yield* LLMClient.prepare( LLM.request({ model, - messages: [Message.user("Before."), Message.system("Treat & data literally."), Message.assistant("After.")], + messages: [ + Message.user("Before."), + Message.system("Treat & data literally."), + Message.assistant("After."), + ], }), ) expect(prepared.body.messages).toEqual([ - { role: "user", content: "Before.\n\nTreat <admin> & data literally.\n" }, + { + role: "user", + content: "Before.\n\nTreat <admin> & data literally.\n", + }, { role: "assistant", content: "After." }, ]) }), @@ -71,7 +78,12 @@ describe("OpenAI Chat route", () => { const prepared = yield* LLMClient.prepare( 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" }, + ]), + ], }), ) diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 3f8cda99c1..72de93ed11 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -62,7 +62,11 @@ describe("OpenAI Responses route", () => { const prepared = yield* LLMClient.prepare( LLM.request({ model, - messages: [Message.user("Before."), Message.system("Treat literally."), Message.assistant("After.")], + messages: [ + Message.user("Before."), + Message.system("Treat literally."), + Message.assistant("After."), + ], }), ) diff --git a/packages/llm/test/tool-runtime.test.ts b/packages/llm/test/tool-runtime.test.ts index 47adcb42ae..5194c8f6e5 100644 --- a/packages/llm/test/tool-runtime.test.ts +++ b/packages/llm/test/tool-runtime.test.ts @@ -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(() => diff --git a/packages/llm/test/tool.types.ts b/packages/llm/test/tool.types.ts index 5876b9f454..2bd33df545 100644 --- a/packages/llm/test/tool.types.ts +++ b/packages/llm/test/tool.types.ts @@ -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) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx index 59d4fff6a3..e34933df81 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx @@ -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 diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx index 23726e972a..9f2008d2b5 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx @@ -1087,7 +1087,8 @@ function toolOutput(content?: Array) { 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) diff --git a/packages/opencode/src/session/llm/native-runtime.ts b/packages/opencode/src/session/llm/native-runtime.ts index e1981912ce..bac385c591 100644 --- a/packages/opencode/src/session/llm/native-runtime.ts +++ b/packages/opencode/src/session/llm/native-runtime.ts @@ -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( diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 2bdcdf7921..30318bd859 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -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 diff --git a/packages/opencode/test/cli/tui/sync-v2.test.tsx b/packages/opencode/test/cli/tui/sync-v2.test.tsx index 04e624004e..9cc495e466 100644 --- a/packages/opencode/test/cli/tui/sync-v2.test.tsx +++ b/packages/opencode/test/cli/tui/sync-v2.test.tsx @@ -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] diff --git a/packages/opencode/test/server/httpapi-exercise/backend.ts b/packages/opencode/test/server/httpapi-exercise/backend.ts index 76818b5965..a10b008972 100644 --- a/packages/opencode/test/server/httpapi-exercise/backend.ts +++ b/packages/opencode/test/server/httpapi-exercise/backend.ts @@ -47,7 +47,7 @@ const appCache: Partial> = {} 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) { diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 70e3112910..0394d07302 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -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`, diff --git a/packages/opencode/test/session/llm-native-recorded.test.ts b/packages/opencode/test/session/llm-native-recorded.test.ts index 8b57b89c64..857409ad9d 100644 --- a/packages/opencode/test/session/llm-native-recorded.test.ts +++ b/packages/opencode/test/session/llm-native-recorded.test.ts @@ -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( diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 540193388a..87e6a039d0 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -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 = [] 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 diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 732985cef5..29f2da070e 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -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 - save?: Array - 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 + save?: Array + 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 - save?: Array - 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 + save?: Array + 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" diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 1fa02fa1fb..a8236b7f6f 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -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": { diff --git a/specs/v2/provider-model.md b/specs/v2/provider-model.md index a80ec1dc43..c63a9a8d49 100644 --- a/specs/v2/provider-model.md +++ b/specs/v2/provider-model.md @@ -232,7 +232,10 @@ export interface Interface { } readonly model: { - readonly get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => Effect.Effect + readonly get: ( + providerID: ProviderV2.ID, + modelID: ModelV2.ID, + ) => Effect.Effect readonly all: () => Effect.Effect readonly available: () => Effect.Effect readonly default: () => Effect.Effect>