chore: generate

This commit is contained in:
opencode-agent[bot]
2026-06-04 03:03:39 +00:00
parent 540e83b6c8
commit 1e882a15c3
87 changed files with 2360 additions and 1658 deletions
@@ -1205,13 +1205,9 @@
"table": "session_share" "table": "session_share"
}, },
{ {
"columns": [ "columns": ["project_id"],
"project_id"
],
"tableTo": "project", "tableTo": "project",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1220,13 +1216,9 @@
"table": "workspace" "table": "workspace"
}, },
{ {
"columns": [ "columns": ["active_account_id"],
"active_account_id"
],
"tableTo": "account", "tableTo": "account",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "SET NULL", "onDelete": "SET NULL",
"nameExplicit": false, "nameExplicit": false,
@@ -1235,13 +1227,9 @@
"table": "account_state" "table": "account_state"
}, },
{ {
"columns": [ "columns": ["aggregate_id"],
"aggregate_id"
],
"tableTo": "event_sequence", "tableTo": "event_sequence",
"columnsTo": [ "columnsTo": ["aggregate_id"],
"aggregate_id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1250,13 +1238,9 @@
"table": "event" "table": "event"
}, },
{ {
"columns": [ "columns": ["project_id"],
"project_id"
],
"tableTo": "project", "tableTo": "project",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1265,13 +1249,9 @@
"table": "permission" "table": "permission"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"tableTo": "session", "tableTo": "session",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1280,13 +1260,9 @@
"table": "message" "table": "message"
}, },
{ {
"columns": [ "columns": ["message_id"],
"message_id"
],
"tableTo": "message", "tableTo": "message",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1295,13 +1271,9 @@
"table": "part" "table": "part"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"tableTo": "session", "tableTo": "session",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1310,13 +1282,9 @@
"table": "session_message" "table": "session_message"
}, },
{ {
"columns": [ "columns": ["project_id"],
"project_id"
],
"tableTo": "project", "tableTo": "project",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1325,13 +1293,9 @@
"table": "session" "table": "session"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"tableTo": "session", "tableTo": "session",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1340,13 +1304,9 @@
"table": "todo" "table": "todo"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"tableTo": "session", "tableTo": "session",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1355,137 +1315,105 @@
"table": "session_share" "table": "session_share"
}, },
{ {
"columns": [ "columns": ["email", "url"],
"email",
"url"
],
"nameExplicit": false, "nameExplicit": false,
"name": "control_account_pk", "name": "control_account_pk",
"entityType": "pks", "entityType": "pks",
"table": "control_account" "table": "control_account"
}, },
{ {
"columns": [ "columns": ["session_id", "position"],
"session_id",
"position"
],
"nameExplicit": false, "nameExplicit": false,
"name": "todo_pk", "name": "todo_pk",
"entityType": "pks", "entityType": "pks",
"table": "todo" "table": "todo"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "workspace_pk", "name": "workspace_pk",
"table": "workspace", "table": "workspace",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["name"],
"name"
],
"nameExplicit": false, "nameExplicit": false,
"name": "data_migration_pk", "name": "data_migration_pk",
"table": "data_migration", "table": "data_migration",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "account_state_pk", "name": "account_state_pk",
"table": "account_state", "table": "account_state",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "account_pk", "name": "account_pk",
"table": "account", "table": "account",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["aggregate_id"],
"aggregate_id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "event_sequence_pk", "name": "event_sequence_pk",
"table": "event_sequence", "table": "event_sequence",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "event_pk", "name": "event_pk",
"table": "event", "table": "event",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "permission_pk", "name": "permission_pk",
"table": "permission", "table": "permission",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "project_pk", "name": "project_pk",
"table": "project", "table": "project",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "message_pk", "name": "message_pk",
"table": "message", "table": "message",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "part_pk", "name": "part_pk",
"table": "part", "table": "part",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "session_message_pk", "name": "session_message_pk",
"table": "session_message", "table": "session_message",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "session_pk", "name": "session_pk",
"table": "session", "table": "session",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "session_share_pk", "name": "session_share_pk",
"table": "session_share", "table": "session_share",
@@ -1333,13 +1333,9 @@
"table": "session_share" "table": "session_share"
}, },
{ {
"columns": [ "columns": ["project_id"],
"project_id"
],
"tableTo": "project", "tableTo": "project",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1348,13 +1344,9 @@
"table": "workspace" "table": "workspace"
}, },
{ {
"columns": [ "columns": ["active_account_id"],
"active_account_id"
],
"tableTo": "account", "tableTo": "account",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "SET NULL", "onDelete": "SET NULL",
"nameExplicit": false, "nameExplicit": false,
@@ -1363,13 +1355,9 @@
"table": "account_state" "table": "account_state"
}, },
{ {
"columns": [ "columns": ["aggregate_id"],
"aggregate_id"
],
"tableTo": "event_sequence", "tableTo": "event_sequence",
"columnsTo": [ "columnsTo": ["aggregate_id"],
"aggregate_id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1378,13 +1366,9 @@
"table": "event" "table": "event"
}, },
{ {
"columns": [ "columns": ["project_id"],
"project_id"
],
"tableTo": "project", "tableTo": "project",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1393,13 +1377,9 @@
"table": "permission" "table": "permission"
}, },
{ {
"columns": [ "columns": ["project_id"],
"project_id"
],
"tableTo": "project", "tableTo": "project",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1408,13 +1388,9 @@
"table": "project_directory" "table": "project_directory"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"tableTo": "session", "tableTo": "session",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1423,13 +1399,9 @@
"table": "message" "table": "message"
}, },
{ {
"columns": [ "columns": ["message_id"],
"message_id"
],
"tableTo": "message", "tableTo": "message",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1438,13 +1410,9 @@
"table": "part" "table": "part"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"tableTo": "session", "tableTo": "session",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1453,13 +1421,9 @@
"table": "session_input" "table": "session_input"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"tableTo": "session", "tableTo": "session",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1468,13 +1432,9 @@
"table": "session_message" "table": "session_message"
}, },
{ {
"columns": [ "columns": ["project_id"],
"project_id"
],
"tableTo": "project", "tableTo": "project",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1483,13 +1443,9 @@
"table": "session" "table": "session"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"tableTo": "session", "tableTo": "session",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1498,13 +1454,9 @@
"table": "todo" "table": "todo"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"tableTo": "session", "tableTo": "session",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1513,156 +1465,119 @@
"table": "session_share" "table": "session_share"
}, },
{ {
"columns": [ "columns": ["email", "url"],
"email",
"url"
],
"nameExplicit": false, "nameExplicit": false,
"name": "control_account_pk", "name": "control_account_pk",
"entityType": "pks", "entityType": "pks",
"table": "control_account" "table": "control_account"
}, },
{ {
"columns": [ "columns": ["project_id", "directory"],
"project_id",
"directory"
],
"nameExplicit": false, "nameExplicit": false,
"name": "project_directory_pk", "name": "project_directory_pk",
"entityType": "pks", "entityType": "pks",
"table": "project_directory" "table": "project_directory"
}, },
{ {
"columns": [ "columns": ["session_id", "position"],
"session_id",
"position"
],
"nameExplicit": false, "nameExplicit": false,
"name": "todo_pk", "name": "todo_pk",
"entityType": "pks", "entityType": "pks",
"table": "todo" "table": "todo"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "workspace_pk", "name": "workspace_pk",
"table": "workspace", "table": "workspace",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["name"],
"name"
],
"nameExplicit": false, "nameExplicit": false,
"name": "data_migration_pk", "name": "data_migration_pk",
"table": "data_migration", "table": "data_migration",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "account_state_pk", "name": "account_state_pk",
"table": "account_state", "table": "account_state",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "account_pk", "name": "account_pk",
"table": "account", "table": "account",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["aggregate_id"],
"aggregate_id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "event_sequence_pk", "name": "event_sequence_pk",
"table": "event_sequence", "table": "event_sequence",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "event_pk", "name": "event_pk",
"table": "event", "table": "event",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "permission_pk", "name": "permission_pk",
"table": "permission", "table": "permission",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "project_pk", "name": "project_pk",
"table": "project", "table": "project",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "message_pk", "name": "message_pk",
"table": "message", "table": "message",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "part_pk", "name": "part_pk",
"table": "part", "table": "part",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["seq"],
"seq"
],
"nameExplicit": false, "nameExplicit": false,
"name": "session_input_pk", "name": "session_input_pk",
"table": "session_input", "table": "session_input",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "session_message_pk", "name": "session_message_pk",
"table": "session_message", "table": "session_message",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "session_pk", "name": "session_pk",
"table": "session", "table": "session",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "session_share_pk", "name": "session_share_pk",
"table": "session_share", "table": "session_share",
@@ -1943,9 +1858,7 @@
"table": "todo" "table": "todo"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "session_input_id_unique", "name": "session_input_id_unique",
"entityType": "uniques", "entityType": "uniques",
+3 -1
View File
@@ -19,7 +19,9 @@ export const Plugin = PluginV2.define({
const transform = yield* skill.transform() const transform = yield* skill.transform()
const entries = yield* config.entries() const entries = yield* config.entries()
const items = entries.flatMap((entry) => 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) => { yield* transform((editor) => {
@@ -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_idx\`;`)
yield* tx.run(`DROP INDEX IF EXISTS \`session_message_session_type_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 \`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(
yield* tx.run(`CREATE INDEX \`session_message_session_type_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`time_created\`,\`id\`);`) `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 } satisfies DatabaseMigration.Migration
@@ -6,13 +6,20 @@ export default {
up(tx) { up(tx) {
return Effect.gen(function* () { return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session_message\` ADD COLUMN \`seq\` integer NOT NULL DEFAULT 0;`) 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);`) yield* tx.run(
const unmatched = yield* tx.get<{ count: number }>(`SELECT COUNT(*) AS \`count\` FROM \`session_message\` WHERE \`seq\` = 0;`) `UPDATE \`session_message\` SET \`seq\` = COALESCE((SELECT \`seq\` + 1 FROM \`event\` WHERE \`event\`.\`id\` = \`session_message\`.\`id\`), 0);`,
if ((unmatched?.count ?? 0) > 0) return yield* Effect.die("Cannot migrate session_message projections without matching durable events") )
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(`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(`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_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 } satisfies DatabaseMigration.Migration
@@ -6,7 +6,9 @@ export default {
up(tx) { up(tx) {
return Effect.gen(function* () { return Effect.gen(function* () {
yield* tx.run(`DROP INDEX IF EXISTS \`session_input_session_pending_seq_idx\`;`) 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( yield* tx.run(
`CREATE INDEX IF NOT EXISTS \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`seq\`);`, `CREATE INDEX IF NOT EXISTS \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`seq\`);`,
) )
+3 -1
View File
@@ -20,7 +20,9 @@ export interface KeyedMutex<in Key> {
export const makeUnsafe = <Key>(): KeyedMutex<Key> => { export const makeUnsafe = <Key>(): KeyedMutex<Key> => {
const locks = new Map<Key, { readonly semaphore: Semaphore.Semaphore; users: number }>() const locks = new Map<Key, { readonly semaphore: Semaphore.Semaphore; users: number }>()
const withLock = (key: Key) => <A, E, R>(effect: Effect.Effect<A, E, R>) => const withLock =
(key: Key) =>
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.suspend(() => { Effect.suspend(() => {
const current = locks.get(key) const current = locks.get(key)
const entry = current ?? { semaphore: Semaphore.makeUnsafe(1), users: 0 } const entry = current ?? { semaphore: Semaphore.makeUnsafe(1), users: 0 }
+56 -13
View File
@@ -146,7 +146,10 @@ export interface Interface {
) => Effect.Effect<Payload<D>> ) => Effect.Effect<Payload<D>>
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>> readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
readonly all: () => Stream.Stream<Payload> readonly all: () => Stream.Stream<Payload>
readonly aggregateEvents: (input: { readonly aggregateID: string; readonly after?: Cursor }) => Stream.Stream<CursorEvent> readonly aggregateEvents: (input: {
readonly aggregateID: string
readonly after?: Cursor
}) => Stream.Stream<CursorEvent>
readonly sync: (handler: Sync) => Effect.Effect<Unsubscribe> readonly sync: (handler: Sync) => Effect.Effect<Unsubscribe>
readonly listen: (listener: Listener) => Effect.Effect<Unsubscribe> readonly listen: (listener: Listener) => Effect.Effect<Unsubscribe>
readonly beforeCommit: (guard: CommitGuard) => Effect.Effect<void> readonly beforeCommit: (guard: CommitGuard) => Effect.Effect<void>
@@ -169,7 +172,8 @@ export interface LayerOptions {
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void> readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
} }
export const layerWith = (options?: LayerOptions) => Layer.effect( export const layerWith = (options?: LayerOptions) =>
Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const all = yield* PubSub.unbounded<Payload>() const all = yield* PubSub.unbounded<Payload>()
@@ -193,16 +197,23 @@ export const layerWith = (options?: LayerOptions) => Layer.effect(
yield* Effect.addFinalizer(() => yield* Effect.addFinalizer(() =>
Effect.gen(function* () { Effect.gen(function* () {
yield* PubSub.shutdown(all) yield* PubSub.shutdown(all)
yield* Effect.forEach(synchronized.values(), (pubsubs) => yield* Effect.forEach(
Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }), synchronized.values(),
{ discard: true }) (pubsubs) => Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }),
{ discard: true },
)
yield* Effect.forEach(typed.values(), PubSub.shutdown, { discard: true }) yield* Effect.forEach(typed.values(), PubSub.shutdown, { discard: true })
}), }),
) )
function commitSyncEvent( function commitSyncEvent(
event: Payload, event: Payload,
input?: { readonly seq: number; readonly aggregateID: string; readonly ownerID?: string; readonly strictOwner?: boolean }, input?: {
readonly seq: number
readonly aggregateID: string
readonly ownerID?: string
readonly strictOwner?: boolean
},
) { ) {
return Effect.gen(function* () { return Effect.gen(function* () {
const definition = registry.get(event.type) const definition = registry.get(event.type)
@@ -274,13 +285,18 @@ export const layerWith = (options?: LayerOptions) => Layer.effect(
for (const projector of list) { for (const projector of list) {
yield* projector({ ...event, seq } as Payload) yield* projector({ ...event, seq } as Payload)
} }
const encoded = syncRegistry.get(versionedType(definition.type, sync.version))!.encode(event.data) const encoded = syncRegistry
.get(versionedType(definition.type, sync.version))!
.encode(event.data)
yield* db yield* db
.insert(EventSequenceTable) .insert(EventSequenceTable)
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }]) .values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
.onConflictDoUpdate({ .onConflictDoUpdate({
target: EventSequenceTable.aggregate_id, target: EventSequenceTable.aggregate_id,
set: { seq, ...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}) }, set: {
seq,
...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}),
},
}) })
.run() .run()
.pipe(Effect.orDie) .pipe(Effect.orDie)
@@ -356,7 +372,10 @@ export const layerWith = (options?: LayerOptions) => Layer.effect(
}) })
} }
function replay(event: SerializedEvent, options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean }) { function replay(
event: SerializedEvent,
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
) {
return Effect.gen(function* () { return Effect.gen(function* () {
const definition = syncRegistry.get(event.type) const definition = syncRegistry.get(event.type)
if (!definition) { if (!definition) {
@@ -370,7 +389,12 @@ export const layerWith = (options?: LayerOptions) => Layer.effect(
version: definition.sync.version, version: definition.sync.version,
data: definition.decode(event.data), data: definition.decode(event.data),
} as Payload } as Payload
const committed = yield* commitSyncEvent(payload, { seq: event.seq, aggregateID: event.aggregateID, ownerID: options?.ownerID, strictOwner: options?.strictOwner }) const committed = yield* commitSyncEvent(payload, {
seq: event.seq,
aggregateID: event.aggregateID,
ownerID: options?.ownerID,
strictOwner: options?.strictOwner,
})
if (committed && options?.publish) { if (committed && options?.publish) {
const published = { ...payload, seq: committed.seq } const published = { ...payload, seq: committed.seq }
for (const listener of listeners) { for (const listener of listeners) {
@@ -384,7 +408,10 @@ export const layerWith = (options?: LayerOptions) => Layer.effect(
}) })
} }
function replayAll(events: SerializedEvent[], options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean }) { function replayAll(
events: SerializedEvent[],
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
) {
return Effect.gen(function* () { return Effect.gen(function* () {
const source = events[0]?.aggregateID const source = events[0]?.aggregateID
if (!source) return undefined if (!source) return undefined
@@ -503,7 +530,10 @@ export const layerWith = (options?: LayerOptions) => Layer.effect(
return subscription return subscription
}) })
const streamEvents = (input: { readonly aggregateID: string; readonly after?: Cursor }): Stream.Stream<CursorEvent> => const streamEvents = (input: {
readonly aggregateID: string
readonly after?: Cursor
}): Stream.Stream<CursorEvent> =>
Stream.unwrap( Stream.unwrap(
Effect.gen(function* () { Effect.gen(function* () {
const synchronized = yield* subscribeSynchronized(input.aggregateID) const synchronized = yield* subscribeSynchronized(input.aggregateID)
@@ -554,7 +584,20 @@ export const layerWith = (options?: LayerOptions) => Layer.effect(
projectors.set(definition.type, list) projectors.set(definition.type, list)
}) })
return Service.of({ publish, subscribe, all: streamAll, aggregateEvents: streamEvents, sync, listen, beforeCommit, project, replay, replayAll, remove, claim }) return Service.of({
publish,
subscribe,
all: streamAll,
aggregateEvents: streamEvents,
sync,
listen,
beforeCommit,
project,
replay,
replayAll,
remove,
claim,
})
}), }),
) )
+18 -8
View File
@@ -52,17 +52,23 @@ export interface RemoveResult {
export interface Interface { export interface Interface {
/** Create only while the planned target remains absent. */ /** Create only while the planned target remains absent. */
readonly create: (input: WriteInput) => Effect.Effect<WriteResult, TargetExistsError | LocationMutation.RevalidationError | FSUtil.Error> readonly create: (
input: WriteInput,
) => Effect.Effect<WriteResult, TargetExistsError | LocationMutation.RevalidationError | FSUtil.Error>
/** Write after immediately revalidating the planned target. */ /** Write after immediately revalidating the planned target. */
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, LocationMutation.RevalidationError | FSUtil.Error> readonly write: (input: WriteInput) => Effect.Effect<WriteResult, LocationMutation.RevalidationError | FSUtil.Error>
/** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */ /** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<WriteResult, LocationMutation.RevalidationError | FSUtil.Error> readonly writeTextPreservingBom: (
input: TextWriteInput,
) => Effect.Effect<WriteResult, LocationMutation.RevalidationError | FSUtil.Error>
/** Commit only if an existing target still has the expected bytes. */ /** Commit only if an existing target still has the expected bytes. */
readonly writeIfUnchanged: ( readonly writeIfUnchanged: (
input: ConditionalWriteInput, input: ConditionalWriteInput,
) => Effect.Effect<WriteResult, StaleContentError | LocationMutation.RevalidationError | FSUtil.Error> ) => Effect.Effect<WriteResult, StaleContentError | LocationMutation.RevalidationError | FSUtil.Error>
/** Remove after immediately revalidating the planned target. */ /** Remove after immediately revalidating the planned target. */
readonly remove: (input: RemoveInput) => Effect.Effect<RemoveResult, LocationMutation.RevalidationError | FSUtil.Error> readonly remove: (
input: RemoveInput,
) => Effect.Effect<RemoveResult, LocationMutation.RevalidationError | FSUtil.Error>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileMutation") {} export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileMutation") {}
@@ -92,12 +98,15 @@ export const layer = Layer.effect(
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const mutation = yield* LocationMutation.Service const mutation = yield* LocationMutation.Service
const locks = KeyedMutex.makeUnsafe<string>() const locks = KeyedMutex.makeUnsafe<string>()
const withTargetLock = (target: string) => <A, E, R>(effect: Effect.Effect<A, E, R>) => const withTargetLock =
(target: string) =>
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
locks.withLock(target)(Effect.uninterruptible(effect)) locks.withLock(target)(Effect.uninterruptible(effect))
const withValidatedTarget = (plan: LocationMutation.Plan) => <A, E, R>( const withValidatedTarget =
commit: (target: LocationMutation.Target) => Effect.Effect<A, E, R>, (plan: LocationMutation.Plan) =>
) => withTargetLock(plan.target.canonical)(mutation.revalidate(plan).pipe(Effect.flatMap(commit))) <A, E, R>(commit: (target: LocationMutation.Target) => Effect.Effect<A, E, R>) =>
withTargetLock(plan.target.canonical)(mutation.revalidate(plan).pipe(Effect.flatMap(commit)))
const writeResult = (target: LocationMutation.Target, existed = target.exists): WriteResult => ({ const writeResult = (target: LocationMutation.Target, existed = target.exists): WriteResult => ({
operation: "write", operation: "write",
@@ -138,7 +147,8 @@ export const layer = Layer.effect(
Effect.gen(function* () { Effect.gen(function* () {
if (target.exists) return yield* new TargetExistsError({ path: target.canonical }) if (target.exists) return yield* new TargetExistsError({ path: target.canonical })
yield* fs.ensureDir(dirname(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" }) else yield* fs.writeFile(target.canonical, input.content, { flag: "wx" })
return writeResult(target, false) return writeResult(target, false)
}), }),
+5 -1
View File
@@ -447,7 +447,11 @@ export const layer = Layer.effect(
const canonical = yield* fs.realPath(target.absolute).pipe(Effect.orDie) 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")) if (canonical !== target.real) return yield* Effect.die(new Error("Search root changed after approval"))
const info = yield* fs.stat(canonical).pipe(Effect.orDie) 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 yield* Effect.die(new Error("Search root identity changed after approval"))
return target return target
}) })
+7 -1
View File
@@ -241,7 +241,13 @@ export const layer = Layer.effect(
const directory = const directory =
boundary.exists && boundary.type === "Directory" ? boundary.canonical : boundary.authority.canonical boundary.exists && boundary.type === "Directory" ? boundary.canonical : boundary.authority.canonical
const resource = slash(path.join(directory, "*")) 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) { const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
+4 -1
View File
@@ -83,7 +83,10 @@ export class GrepResult extends Schema.Class<GrepResult>("LocationSearch.GrepRes
export interface Interface { export interface Interface {
readonly files: (input: FilesInput, root?: FileSystem.RootTarget) => Effect.Effect<FilesResult, Ripgrep.Error> readonly files: (input: FilesInput, root?: FileSystem.RootTarget) => Effect.Effect<FilesResult, Ripgrep.Error>
readonly grep: (input: GrepInput, root?: FileSystem.RootTarget) => Effect.Effect<GrepResult, Ripgrep.Error | Ripgrep.InvalidPatternError> readonly grep: (
input: GrepInput,
root?: FileSystem.RootTarget,
) => Effect.Effect<GrepResult, Ripgrep.Error | Ripgrep.InvalidPatternError>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/LocationSearch") {} export class Service extends Context.Service<Service, Interface>()("@opencode/v2/LocationSearch") {}
+23 -5
View File
@@ -3,7 +3,12 @@ export * as Patch from "./patch"
export type Hunk = export type Hunk =
| { readonly type: "add"; readonly path: string; readonly contents: string } | { readonly type: "add"; readonly path: string; readonly contents: string }
| { readonly type: "delete"; readonly path: string } | { readonly type: "delete"; readonly path: string }
| { readonly type: "update"; readonly path: string; readonly movePath?: string; readonly chunks: ReadonlyArray<UpdateFileChunk> } | {
readonly type: "update"
readonly path: string
readonly movePath?: string
readonly chunks: ReadonlyArray<UpdateFileChunk>
}
export interface UpdateFileChunk { export interface UpdateFileChunk {
readonly oldLines: ReadonlyArray<string> readonly oldLines: ReadonlyArray<string>
@@ -166,7 +171,12 @@ function seek(lines: ReadonlyArray<string>, pattern: ReadonlyArray<string>, star
return -1 return -1
} }
function matches(lines: ReadonlyArray<string>, pattern: ReadonlyArray<string>, offset: number, compare: (left: string, right: string) => boolean) { function matches(
lines: ReadonlyArray<string>,
pattern: ReadonlyArray<string>,
offset: number,
compare: (left: string, right: string) => boolean,
) {
return pattern.every((line, index) => compare(lines[offset + index]!, line)) 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 rstrip = (left: string, right: string) => left.trimEnd() === right.trimEnd()
const trim = (left: string, right: string) => left.trim() === right.trim() const trim = (left: string, right: string) => left.trim() === right.trim()
const normalized = (left: string, right: string) => normalize(left.trim()) === normalize(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 normalize = (value: string) =>
const splitBom = (text: string) => text.startsWith("\uFEFF") ? { bom: true, text: text.slice(1) } : { bom: false, text } value
const stripHeredoc = (input: string) => input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/)?.[2] ?? input .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
+9 -5
View File
@@ -200,9 +200,9 @@ export const layer = Layer.effect(
const item = { request, deferred } const item = { request, deferred }
if (pending.has(request.id)) return yield* EffectRuntime.die(`Duplicate pending permission ID: ${request.id}`) if (pending.has(request.id)) return yield* EffectRuntime.die(`Duplicate pending permission ID: ${request.id}`)
pending.set(request.id, item) pending.set(request.id, item)
yield* events.publish(Event.Asked, request).pipe( yield* events
EffectRuntime.onError(() => EffectRuntime.sync(() => pending.delete(request.id))), .publish(Event.Asked, request)
) .pipe(EffectRuntime.onError(() => EffectRuntime.sync(() => pending.delete(request.id))))
return item return item
}), }),
) )
@@ -236,7 +236,9 @@ export const layer = Layer.effect(
), ),
) )
const reply = EffectRuntime.fn("PermissionV2.reply")((input: ReplyInput) => EffectRuntime.uninterruptible(EffectRuntime.gen(function* () { const reply = EffectRuntime.fn("PermissionV2.reply")((input: ReplyInput) =>
EffectRuntime.uninterruptible(
EffectRuntime.gen(function* () {
const existing = pending.get(input.requestID) const existing = pending.get(input.requestID)
if (!existing) return yield* new NotFoundError({ requestID: input.requestID }) if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
yield* events.publish(Event.Replied, { yield* events.publish(Event.Replied, {
@@ -298,7 +300,9 @@ export const layer = Layer.effect(
yield* Deferred.succeed(item.deferred, undefined) yield* Deferred.succeed(item.deferred, undefined)
pending.delete(id) pending.delete(id)
} }
}))) }),
),
)
const list = EffectRuntime.fn("PermissionV2.list")(function* () { const list = EffectRuntime.fn("PermissionV2.list")(function* () {
return Array.from(pending.values(), (item) => item.request) return Array.from(pending.values(), (item) => item.request)
+19 -9
View File
@@ -34,7 +34,7 @@ const RawMatch = Schema.Struct({
}), }),
}) })
export type Match = typeof RawMatch.Type["data"] export type Match = (typeof RawMatch.Type)["data"]
export class Error extends Schema.TaggedErrorClass<Error>()("Ripgrep.Error", { export class Error extends Schema.TaggedErrorClass<Error>()("Ripgrep.Error", {
message: Schema.String, message: Schema.String,
@@ -77,7 +77,8 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
const failure = (message: string, cause?: unknown) => new Error({ message, cause }) const 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( export const layer = Layer.effect(
Service, Service,
@@ -126,7 +127,13 @@ export const layer = Layer.effect(
}), }),
) )
const abortable = input.signal ? program.pipe(Effect.raceFirst(waitForAbort(input.signal))) : program 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({ return Service.of({
@@ -143,9 +150,7 @@ export const layer = Layer.effect(
".", ".",
], ],
parse: (line) => Effect.succeed(line.replace(/^\.\//, "")), parse: (line) => Effect.succeed(line.replace(/^\.\//, "")),
}).pipe( }).pipe(Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause)))),
Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
),
grep: (input) => grep: (input) =>
run<Match>({ run<Match>({
...input, ...input,
@@ -167,11 +172,16 @@ export const layer = Layer.effect(
: Effect.try({ : Effect.try({
try: () => JSON.parse(line) as unknown, try: () => JSON.parse(line) as unknown,
catch: (cause) => failure("Invalid ripgrep JSON output", cause), catch: (cause) => failure("Invalid ripgrep JSON output", cause),
})).pipe( })
).pipe(
Effect.flatMap((json) => { 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( 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)), Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)),
) )
}), }),
+2 -1
View File
@@ -6,7 +6,8 @@ export type ExternalID = {
readonly key: string 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. * Integer greater than zero.
+11 -3
View File
@@ -132,8 +132,14 @@ export interface Interface {
sessionID: SessionSchema.ID sessionID: SessionSchema.ID
after?: EventV2.Cursor after?: EventV2.Cursor
}) => Stream.Stream<EventV2.CursorEvent<SessionEvent.DurableEvent>, NotFoundError> }) => Stream.Stream<EventV2.CursorEvent<SessionEvent.DurableEvent>, NotFoundError>
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, OperationUnavailableError> readonly switchAgent: (input: {
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: ModelV2.Ref }) => Effect.Effect<void, OperationUnavailableError> sessionID: SessionSchema.ID
agent: string
}) => Effect.Effect<void, OperationUnavailableError>
readonly switchModel: (input: {
sessionID: SessionSchema.ID
model: ModelV2.Ref
}) => Effect.Effect<void, OperationUnavailableError>
readonly prompt: (input: { readonly prompt: (input: {
id?: SessionMessage.ID id?: SessionMessage.ID
sessionID: SessionSchema.ID sessionID: SessionSchema.ID
@@ -318,7 +324,9 @@ export const layer = Layer.effect(
? yield* db ? yield* db
.select({ seq: SessionMessageTable.seq }) .select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable) .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() .get()
.pipe(Effect.orDie) .pipe(Effect.orDie)
: undefined : undefined
+2 -1
View File
@@ -126,7 +126,8 @@ export const equivalent = (
) => input.delivery === expected.delivery && matchesPrompt(input, expected) ) => input.delivery === expected.delivery && matchesPrompt(input, expected)
const matchesPrompt = (input: Admitted, expected: { readonly sessionID: SessionSchema.ID; readonly prompt: Prompt }) => 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* ( export const guardReservedID = Effect.fn("SessionInput.guardReservedID")(function* (
db: DatabaseService, db: DatabaseService,
+4 -2
View File
@@ -318,14 +318,16 @@ export const layer = Layer.effectDiscard(
}), }),
) )
yield* events.project(SessionEvent.AgentSwitched, (event) => yield* events.project(SessionEvent.AgentSwitched, (event) =>
db.update(SessionTable) db
.update(SessionTable)
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) .set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
.where(eq(SessionTable.id, event.data.sessionID)) .where(eq(SessionTable.id, event.data.sessionID))
.run() .run()
.pipe(Effect.orDie, Effect.andThen(run(db, event))), .pipe(Effect.orDie, Effect.andThen(run(db, event))),
) )
yield* events.project(SessionEvent.ModelSwitched, (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) }) .set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
.where(eq(SessionTable.id, event.data.sessionID)) .where(eq(SessionTable.id, event.data.sessionID))
.run() .run()
@@ -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 { DateTime, Effect } from "effect"
import { EventV2 } from "../../event" import { EventV2 } from "../../event"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
@@ -52,7 +59,15 @@ const settledOutput = (value: LLMToolOutputType | undefined, result: ToolResultV
export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) => { export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) => {
const tools = new Map< const tools = new Map<
string, 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 const timestamp = DateTime.now
let assistantMessageID: EventV2.ID | undefined let assistantMessageID: EventV2.ID | undefined
@@ -60,14 +75,19 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
const startAssistant = Effect.fnUntraced(function* () { const startAssistant = Effect.fnUntraced(function* () {
if (assistantMessageID !== undefined) return assistantMessageID 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 return assistantMessageID
}) })
const currentAssistantMessageID = () => assistantMessageID === undefined const currentAssistantMessageID = () =>
assistantMessageID === undefined
? Effect.die("Tool event before assistant step start") ? Effect.die("Tool event before assistant step start")
: Effect.succeed(assistantMessageID) : Effect.succeed(assistantMessageID)
const fragments = (name: string, ended: (id: string, value: string, providerMetadata?: ProviderMetadata) => Effect.Effect<void>) => { const fragments = (
name: string,
ended: (id: string, value: string, providerMetadata?: ProviderMetadata) => Effect.Effect<void>,
) => {
const chunks = new Map<string, string[]>() const chunks = new Map<string, string[]>()
const start = (id: string) => const start = (id: string) =>
Effect.suspend(() => { 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 }) { 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}`) if (tools.has(event.id)) return yield* Effect.die(`Duplicate tool input start: ${event.id}`)
const assistantMessageID = yield* currentAssistantMessageID() 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* toolInput.start(event.id)
yield* events.publish(SessionEvent.Tool.Input.Started, { yield* events.publish(SessionEvent.Tool.Input.Started, {
sessionID: input.sessionID, 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 endToolInput = Effect.fnUntraced(function* (event: { readonly id: string; readonly name: string }) {
const tool = tools.get(event.id) const tool = tools.get(event.id)
if (!tool) return yield* Effect.die(`Tool input end before start: ${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}`) if (tool.inputEnded) return yield* Effect.die(`Duplicate tool input end: ${event.id}`)
yield* toolInput.end(event.id) yield* toolInput.end(event.id)
}) })
@@ -190,7 +218,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
return return
case "text-start": case "text-start":
yield* text.start(event.id) 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 return
case "text-delta": case "text-delta":
yield* text.append(event.id, event.text) yield* text.append(event.id, event.text)
@@ -231,7 +263,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
case "tool-input-delta": { case "tool-input-delta": {
const tool = tools.get(event.id) const tool = tools.get(event.id)
if (!tool) return yield* Effect.die(`Tool input delta before start: ${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}`) if (tool.inputEnded) return yield* Effect.die(`Tool input delta after end: ${event.id}`)
yield* toolInput.append(event.id, event.text) yield* toolInput.append(event.id, event.text)
yield* events.publish(SessionEvent.Tool.Input.Delta, { 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) if (!tools.has(event.id)) yield* startToolInput(event)
const tool = tools.get(event.id)! const tool = tools.get(event.id)!
if (!tool.inputEnded) yield* endToolInput(event) 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}`) if (tool.called) return yield* Effect.die(`Duplicate tool call: ${event.id}`)
tool.called = true tool.called = true
tool.providerExecuted = event.providerExecuted === true tool.providerExecuted = event.providerExecuted === true
@@ -272,7 +306,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
case "tool-result": { case "tool-result": {
const tool = tools.get(event.id) const tool = tools.get(event.id)
if (!tool?.called) return yield* Effect.die(`Tool result before call: ${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 (tool.settled) {
if (event.result.type === "error") return if (event.result.type === "error") return
return yield* Effect.die(`Duplicate tool result: ${event.id}`) return yield* Effect.die(`Duplicate tool result: ${event.id}`)
@@ -309,7 +344,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
case "tool-error": { case "tool-error": {
const tool = tools.get(event.id) const tool = tools.get(event.id)
if (!tool?.called) return yield* Effect.die(`Tool error before call: ${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}`) if (tool.settled) return yield* Effect.die(`Duplicate tool error: ${event.id}`)
tool.settled = true tool.settled = true
yield* events.publish(SessionEvent.Tool.Failed, { yield* events.publish(SessionEvent.Tool.Failed, {
@@ -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 { SessionMessage } from "../message"
import type { FileAttachment } from "../prompt" import type { FileAttachment } from "../prompt"
@@ -61,7 +69,8 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
} }
const assistant = (message: SessionMessage.Assistant, model: Model) => { 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[] => { const content = message.content.flatMap((item): ContentPart[] => {
if (item.type === "text") return [{ type: "text", text: item.text }] if (item.type === "text") return [{ type: "text", text: item.text }]
if (item.type === "reasoning") if (item.type === "reasoning")
@@ -71,12 +80,12 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => {
? [{ type: "text", text: item.text }] ? [{ type: "text", text: item.text }]
: [] : []
const call = toolCall(item, sameModel ? item.provider?.metadata : undefined) 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] return item.provider?.executed === true && result ? [call, result] : [call]
}) })
const results = message.content const results = message.content
.filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true) .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) .filter((message) => message !== undefined)
.map(Message.tool) .map(Message.tool)
return [Message.make({ id: message.id, role: "assistant", content, metadata: message.metadata }), ...results] return [Message.make({ id: message.id, role: "assistant", content, metadata: message.metadata }), ...results]
+1 -1
View File
@@ -15,7 +15,7 @@ export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
const create = () => schema.make("ses_" + Identifier.descending()) const create = () => schema.make("ses_" + Identifier.descending())
return { return {
create, 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)), fromExternal: (input: ExternalID) => schema.make(externalID("ses", input)),
} }
}), }),
+4 -5
View File
@@ -151,11 +151,10 @@ export const layer = Layer.effect(
}), }),
({ skill, root, files }) => ({ skill, root, files }) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* Effect.forEach( yield* Effect.forEach(files, (file) => download(file.url, file.destination), {
files, concurrency: fileConcurrency,
(file) => download(file.url, file.destination), discard: true,
{ concurrency: fileConcurrency, discard: true }, })
)
return (yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) || return (yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) ||
(yield* fs.exists(path.join(root, `${skill.name}.md`)).pipe(Effect.orDie)) (yield* fs.exists(path.join(root, `${skill.name}.md`)).pipe(Effect.orDie))
? [AbsolutePath.make(root)] ? [AbsolutePath.make(root)]
+43 -16
View File
@@ -33,14 +33,20 @@ export class Page extends Schema.Class<Page>("ToolOutputStore.Page")({
next: NonNegativeInt.pipe(Schema.optional), next: NonNegativeInt.pipe(Schema.optional),
}) {} }) {}
export class AccessDeniedError extends Schema.TaggedErrorClass<AccessDeniedError>()("ToolOutputStore.AccessDeniedError", { export class AccessDeniedError extends Schema.TaggedErrorClass<AccessDeniedError>()(
"ToolOutputStore.AccessDeniedError",
{
uri: Schema.String, uri: Schema.String,
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
}) {} },
) {}
export class InvalidResourceError extends Schema.TaggedErrorClass<InvalidResourceError>()("ToolOutputStore.InvalidResourceError", { export class InvalidResourceError extends Schema.TaggedErrorClass<InvalidResourceError>()(
"ToolOutputStore.InvalidResourceError",
{
uri: Schema.String, uri: Schema.String,
}) {} },
) {}
export class ResourceNotFoundError extends Schema.TaggedErrorClass<ResourceNotFoundError>()( export class ResourceNotFoundError extends Schema.TaggedErrorClass<ResourceNotFoundError>()(
"ToolOutputStore.ResourceNotFoundError", "ToolOutputStore.ResourceNotFoundError",
@@ -88,7 +94,9 @@ export interface Interface {
readonly limits: () => Effect.Effect<{ readonly maxLines: number; readonly maxBytes: number }> readonly limits: () => Effect.Effect<{ readonly maxLines: number; readonly maxBytes: number }>
readonly write: (input: WriteInput) => Effect.Effect<Resource> readonly write: (input: WriteInput) => Effect.Effect<Resource>
readonly truncate: (input: TruncateInput) => Effect.Effect<TruncateResult> readonly truncate: (input: TruncateInput) => Effect.Effect<TruncateResult>
readonly read: (input: ReadInput) => Effect.Effect<Page, AccessDeniedError | InvalidResourceError | ResourceNotFoundError> readonly read: (
input: ReadInput,
) => Effect.Effect<Page, AccessDeniedError | InvalidResourceError | ResourceNotFoundError>
readonly cleanup: () => Effect.Effect<void> readonly cleanup: () => Effect.Effect<void>
} }
@@ -153,11 +161,17 @@ const preview = (text: string, maxLines: number, maxBytes: number) => {
const sampled = const sampled =
lines.length <= maxLines lines.length <= maxLines
? text ? 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) { if (Buffer.byteLength(sampled, "utf-8") <= maxBytes) {
return lines.length <= maxLines return lines.length <= maxLines
? { head: sampled, tail: "" } ? { 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 headBytes = Math.ceil(maxBytes / 2)
const tailBytes = Math.floor(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 entries = yield* config.value.entries().pipe(Effect.catch(() => Effect.succeed([] as Config.Entry[])))
const configured = Object.assign( 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 } 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.onError(() => fs.remove(contentPath(id)).pipe(Effect.catch(() => Effect.void))),
Effect.orDie, 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) { 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 cleanup = Effect.fn("ToolOutputStore.cleanup")(function* () {
const entries = yield* fs.readDirectory(directory).pipe(Effect.catch(() => Effect.succeed([]))) const entries = yield* fs.readDirectory(directory).pipe(Effect.catch(() => Effect.succeed([])))
const cutoff = Date.now() - Duration.toMillis(RETENTION) const cutoff = Date.now() - Duration.toMillis(RETENTION)
const ids = new Set(entries.flatMap((entry) => { const ids = new Set(
entries.flatMap((entry) => {
const match = entry.match(/^([0-9a-f]{12}[0-9A-Za-z]{14})\.(?:json|txt)$/) const match = entry.match(/^([0-9a-f]{12}[0-9A-Za-z]{14})\.(?:json|txt)$/)
return match ? [match[1]] : [] return match ? [match[1]] : []
})) }),
)
const removeIfPresent = (target: string) => 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) => const removePair = (id: string) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* removeIfPresent(contentPath(id)) yield* removeIfPresent(contentPath(id))
@@ -298,13 +319,19 @@ export const layer = Layer.effect(
if (!text) { if (!text) {
if (!contentExists) continue if (!contentExists) continue
const info = yield* fs.stat(contentPath(id)).pipe(Effect.catch(() => Effect.void)) 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) if (modified < cutoff) yield* removePair(id)
continue continue
} }
const record = yield* Effect.try({ try: () => JSON.parse(text), catch: () => new globalThis.Error("Invalid metadata") }).pipe( const record = yield* Effect.try({
Effect.catch(() => Effect.succeed(undefined)), 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 const info = contentExists ? yield* fs.stat(contentPath(id)).pipe(Effect.catch(() => Effect.void)) : undefined
if ( if (
!contentExists || !contentExists ||
+34 -9
View File
@@ -1,6 +1,16 @@
export * as ToolRegistry from "./tool-registry" 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 { Context, Effect, Layer, Schema, Scope } from "effect"
import { castDraft, enableMapSet } from "immer" import { castDraft, enableMapSet } from "immer"
import { PermissionV2 } from "./permission" import { PermissionV2 } from "./permission"
@@ -35,10 +45,15 @@ export type AuthorizeInput<Parameters = unknown> = Invocation & {
readonly parameters: Parameters readonly parameters: Parameters
} }
export type Entry<Parameters extends ToolSchema<any> = ToolSchema<any>, Success extends ToolSchema<any> = ToolSchema<any>> = { export type Entry<
Parameters extends ToolSchema<any> = ToolSchema<any>,
Success extends ToolSchema<any> = ToolSchema<any>,
> = {
readonly tool: TypedTool<Parameters, Success> readonly tool: TypedTool<Parameters, Success>
readonly authorize?: (input: AuthorizeInput<Schema.Schema.Type<Parameters>>) => Effect.Effect<void, ToolFailure> readonly authorize?: (input: AuthorizeInput<Schema.Schema.Type<Parameters>>) => Effect.Effect<void, ToolFailure>
readonly execute?: (input: AuthorizeInput<Schema.Schema.Type<Parameters>>) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure> readonly execute?: (
input: AuthorizeInput<Schema.Schema.Type<Parameters>>,
) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
} }
type Data = { type Data = {
@@ -48,7 +63,10 @@ type Data = {
export type Editor = { export type Editor = {
readonly list: () => ReadonlyArray<readonly [string, Entry]> readonly list: () => ReadonlyArray<readonly [string, Entry]>
readonly get: (name: string) => Entry | undefined readonly get: (name: string) => Entry | undefined
readonly set: <Parameters extends ToolSchema<any>, Success extends ToolSchema<any>>(name: string, entry: Entry<Parameters, Success>) => void readonly set: <Parameters extends ToolSchema<any>, Success extends ToolSchema<any>>(
name: string,
entry: Entry<Parameters, Success>,
) => void
readonly remove: (name: string) => void readonly remove: (name: string) => void
} }
@@ -74,7 +92,10 @@ export const layer = Layer.effect(
list: () => Array.from(draft.entries.entries()) as Array<[string, Entry]>, list: () => Array.from(draft.entries.entries()) as Array<[string, Entry]>,
get: (name) => draft.entries.get(name) as Entry | undefined, get: (name) => draft.entries.get(name) as Entry | undefined,
set: (name, entry) => { set: (name, entry) => {
draft.entries.set(name, castDraft(entry) as typeof draft.entries extends Map<string, infer Value> ? Value : never) draft.entries.set(
name,
castDraft(entry) as typeof draft.entries extends Map<string, infer Value> ? Value : never,
)
}, },
remove: (name) => { remove: (name) => {
draft.entries.delete(name) draft.entries.delete(name)
@@ -83,7 +104,9 @@ export const layer = Layer.effect(
}) })
const definitions = Effect.fn("ToolRegistry.definitions")(function* () { const definitions = Effect.fn("ToolRegistry.definitions")(function* () {
return Tool.toDefinitions(Object.fromEntries(Array.from(state.get().entries, ([name, entry]) => [name, entry.tool]))) return Tool.toDefinitions(
Object.fromEntries(Array.from(state.get().entries, ([name, entry]) => [name, entry.tool])),
)
}) })
const invocation = (input: ExecuteInput): Invocation => ({ const invocation = (input: ExecuteInput): Invocation => ({
@@ -102,9 +125,11 @@ export const layer = Layer.effect(
Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })), Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })),
Effect.flatMap((parameters) => { Effect.flatMap((parameters) => {
const context = { ...invocation(input), parameters } const context = { ...invocation(input), parameters }
const execute = entry.execute?.(context) ?? const execute =
entry.tool.execute!(parameters, { id: input.call.id, name: input.call.name }) 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( return (
entry.authorize === undefined ? execute : entry.authorize(context).pipe(Effect.andThen(execute))
).pipe(
Effect.flatMap((value) => Effect.flatMap((value) =>
entry.tool._encode(value).pipe( entry.tool._encode(value).pipe(
Effect.mapError( Effect.mapError(
+59 -14
View File
@@ -11,7 +11,9 @@ import { ToolRegistry } from "../tool-registry"
export const name = "apply_patch" export const name = "apply_patch"
export const Parameters = Schema.Struct({ 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({ 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 type Success = typeof Success.Type
export const toModelOutput = (output: Success) => 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({ const definition = Tool.make({
description: description:
@@ -36,9 +43,23 @@ const definition = Tool.make({
type Planned = { readonly hunk: Patch.Hunk; readonly plan: LocationMutation.Plan } type Planned = { readonly hunk: Patch.Hunk; readonly plan: LocationMutation.Plan }
type Prepared = type Prepared =
| { readonly type: "add"; readonly hunk: Extract<Patch.Hunk, { readonly type: "add" }>; readonly plan: LocationMutation.Plan } | {
| { readonly type: "delete"; readonly hunk: Extract<Patch.Hunk, { readonly type: "delete" }>; readonly plan: LocationMutation.Plan } readonly type: "add"
| { readonly type: "update"; readonly hunk: Extract<Patch.Hunk, { readonly type: "update" }>; readonly plan: LocationMutation.Plan; readonly source: Uint8Array; readonly content: string } readonly hunk: Extract<Patch.Hunk, { readonly type: "add" }>
readonly plan: LocationMutation.Plan
}
| {
readonly type: "delete"
readonly hunk: Extract<Patch.Hunk, { readonly type: "delete" }>
readonly plan: LocationMutation.Plan
}
| {
readonly type: "update"
readonly hunk: Extract<Patch.Hunk, { readonly type: "update" }>
readonly plan: LocationMutation.Plan
readonly source: Uint8Array
readonly content: string
}
export const layer = Layer.effectDiscard( export const layer = Layer.effectDiscard(
Effect.gen(function* () { Effect.gen(function* () {
@@ -53,7 +74,8 @@ export const layer = Layer.effectDiscard(
execute: ({ parameters, assertPermission }) => { execute: ({ parameters, assertPermission }) => {
const applied: Array<typeof Applied.Type> = [] const applied: Array<typeof Applied.Type> = []
const fail = (path: string, cause: unknown) => { const fail = (path: string, cause: unknown) => {
const prefix = applied.length === 0 const prefix =
applied.length === 0
? `Unable to apply patch at ${path}` ? `Unable to apply patch at ${path}`
: `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}` : `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}`
return new ToolFailure({ message: prefix, error: cause }) return new ToolFailure({ message: prefix, error: cause })
@@ -69,7 +91,8 @@ export const layer = Layer.effectDiscard(
if (move) return yield* new ToolFailure({ message: "apply_patch moves are not supported yet" }) if (move) return yield* new ToolFailure({ message: "apply_patch moves are not supported yet" })
const planned: Planned[] = [] const planned: Planned[] = []
for (const hunk of hunks) planned.push({ hunk, plan: yield* mutation.resolve({ path: hunk.path, kind: "file" }) }) for (const hunk of hunks)
planned.push({ hunk, plan: yield* mutation.resolve({ path: hunk.path, kind: "file" }) })
const externalDirectories = new Map<string, LocationMutation.ExternalDirectoryAuthorization>() const externalDirectories = new Map<string, LocationMutation.ExternalDirectoryAuthorization>()
for (const { plan } of planned) { for (const { plan } of planned) {
const external = plan.target.externalDirectory const external = plan.target.externalDirectory
@@ -78,7 +101,11 @@ export const layer = Layer.effectDiscard(
for (const external of externalDirectories.values()) { for (const external of externalDirectories.values()) {
yield* assertPermission(LocationMutation.externalDirectoryPermission(external)) 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[] = [] const prepared: Prepared[] = []
for (const { hunk, plan } of planned) { for (const { hunk, plan } of planned) {
@@ -89,21 +116,34 @@ export const layer = Layer.effectDiscard(
continue continue
} }
const target = yield* mutation.revalidate(plan) 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") { if (hunk.type === "delete") {
prepared.push({ type: hunk.type, hunk, plan }) prepared.push({ type: hunk.type, hunk, plan })
continue continue
} }
const source = yield* fs.readFile(target.canonical) 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) }) prepared.push({ type: hunk.type, hunk, plan, source, content: Patch.joinBom(update.content, update.bom) })
} }
yield* Effect.uninterruptible( yield* Effect.uninterruptible(
Effect.forEach(prepared, (change) => Effect.forEach(
prepared,
(change) =>
Effect.gen(function* () { Effect.gen(function* () {
if (change.type === "add") { 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` }) 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 }) applied.push({ type: change.type, resource: result.resource, target: result.target })
return return
} }
@@ -112,10 +152,15 @@ export const layer = Layer.effectDiscard(
applied.push({ type: change.type, resource: result.resource, target: result.target }) applied.push({ type: change.type, resource: result.resource, target: result.target })
return return
} }
const result = yield* files.writeIfUnchanged({ plan: change.plan, expected: change.source, content: change.content }) 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 }) applied.push({ type: change.type, resource: result.resource, target: result.target })
}).pipe(Effect.catchCause((cause) => Effect.fail(fail(change.hunk.path, Cause.squash(cause))))), }).pipe(Effect.catchCause((cause) => Effect.fail(fail(change.hunk.path, Cause.squash(cause))))),
{ discard: true }), { discard: true },
),
) )
return { applied } return { applied }
}).pipe( }).pipe(
+11 -3
View File
@@ -112,7 +112,9 @@ export const layer = Layer.effectDiscard(
const error = Cause.squash(cause) const error = Cause.squash(cause)
return Effect.fail( return Effect.fail(
error instanceof FileMutation.StaleContentError 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 }), : 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." }) return yield* new ToolFailure({ message: "No changes to apply: oldString and newString are identical." })
} }
if (parameters.oldString === "") { 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" })) 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) : source.text.replace(oldString, newString)
const next = splitBom(replaced) const next = splitBom(replaced)
const result = yield* unableToEdit( 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 return { ...result, replacements } satisfies Success
}) })
+21 -6
View File
@@ -10,9 +10,15 @@ export const name = "glob"
export const Parameters = Schema.Struct({ export const Parameters = Schema.Struct({
pattern: LocationSearch.FilesInput.fields.pattern.annotate({ description: "Glob pattern to match files against" }), 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." }), path: LocationSearch.FilesInput.fields.path.annotate({
reference: LocationSearch.FilesInput.fields.reference.annotate({ description: "Named project reference to search instead of the active Location" }), description: "Relative directory to search. Defaults to the active Location.",
limit: LocationSearch.FilesInput.fields.limit.annotate({ description: `Maximum results to return (default: ${LocationSearch.DEFAULT_RESULT_LIMIT})` }), }),
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 type ModelOutput = typeof LocationSearch.FilesResult.Encoded
@@ -21,14 +27,18 @@ type ModelOutput = typeof LocationSearch.FilesResult.Encoded
export const toModelOutput = (output: ModelOutput) => { export const toModelOutput = (output: ModelOutput) => {
const lines = output.items.length === 0 ? ["No files found"] : output.items.map((item) => item.resource) const lines = output.items.length === 0 ? ["No files found"] : output.items.map((item) => item.resource)
if (output.truncated) { 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.)") if (output.partial) lines.push("", "(Results may be incomplete because some discovered files could not be read.)")
return lines.join("\n") return lines.join("\n")
} }
const definition = Tool.make({ 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, parameters: Parameters,
success: LocationSearch.FilesResult, success: LocationSearch.FilesResult,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })], toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
@@ -66,7 +76,12 @@ export const layer = Layer.effectDiscard(
return yield* search.files(parameters, root) return yield* search.files(parameters, root)
}).pipe( }).pipe(
Effect.catchCause((cause) => 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),
}),
),
), ),
), ),
}), }),
+23 -8
View File
@@ -10,11 +10,21 @@ import { ToolRegistry } from "../tool-registry"
export const name = "grep" export const name = "grep"
export const Parameters = Schema.Struct({ export const Parameters = Schema.Struct({
pattern: LocationSearch.GrepInput.fields.pattern.annotate({ description: "Regex pattern to search for in file contents" }), pattern: LocationSearch.GrepInput.fields.pattern.annotate({
path: LocationSearch.GrepInput.fields.path.annotate({ description: "Relative file or directory to search. Defaults to the active Location." }), description: "Regex pattern to search for in file contents",
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}")' }), path: LocationSearch.GrepInput.fields.path.annotate({
limit: LocationSearch.GrepInput.fields.limit.annotate({ description: `Maximum matches to return (default: ${LocationSearch.DEFAULT_RESULT_LIMIT})` }), 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 type Success = typeof LocationSearch.GrepResult.Encoded
@@ -32,14 +42,18 @@ export const toModelOutput = (output: Success) => {
lines.push(` Line ${match.line}: ${match.lines}${match.linePreviewTruncated ? "..." : ""}`) lines.push(` Line ${match.line}: ${match.lines}${match.linePreviewTruncated ? "..." : ""}`)
} }
if (output.truncated) { 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)") if (output.partial) lines.push("", "(Some paths were inaccessible and skipped)")
return lines.join("\n") return lines.join("\n")
} }
const definition = Tool.make({ 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, parameters: Parameters,
success: LocationSearch.GrepResult, success: LocationSearch.GrepResult,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })], toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
@@ -79,7 +93,8 @@ export const layer = Layer.effectDiscard(
}).pipe( }).pipe(
Effect.catchCause((cause) => { Effect.catchCause((cause) => {
const error = Cause.squash(cause) const error = Cause.squash(cause)
const message = error instanceof Ripgrep.InvalidPatternError const message =
error instanceof Ripgrep.InvalidPatternError
? `Invalid grep pattern ${JSON.stringify(parameters.pattern)}: ${error.message}` ? `Invalid grep pattern ${JSON.stringify(parameters.pattern)}: ${error.message}`
: `Unable to grep for ${parameters.pattern}` : `Unable to grep for ${parameters.pattern}`
return Effect.fail(new ToolFailure({ message, error })) return Effect.fail(new ToolFailure({ message, error }))
+5 -1
View File
@@ -70,7 +70,11 @@ export const layer = Layer.effectDiscard(
const final = yield* filesystem.resolveReadPath(input) const final = yield* filesystem.resolveReadPath(input)
if (final.type !== "file" || final.target.resource !== target.resource || final.target.real !== target.real) 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")) 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.readTextPageResolved(final.target, { offset: input.offset, limit: input.limit })
return yield* filesystem.readResolved(final.target, FileSystem.MAX_READ_BYTES) return yield* filesystem.readResolved(final.target, FileSystem.MAX_READ_BYTES)
}).pipe( }).pipe(
+3 -1
View File
@@ -150,7 +150,9 @@ export const layer = Layer.effectDiscard(
const { body, contentType } = yield* Effect.gen(function* () { const { body, contentType } = yield* Effect.gen(function* () {
const response = yield* execute(http, parameters.url, parameters.format).pipe( 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 contentType = response.headers["content-type"] || ""
const mime = mimeFrom(contentType) const mime = mimeFrom(contentType)
+26 -12
View File
@@ -34,7 +34,9 @@ The current year is ${new Date().getFullYear()}. Use this year when searching fo
export const Parameters = Schema.Struct({ export const Parameters = Schema.Struct({
query: Schema.String.annotate({ description: "Websearch query" }), 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({ livecrawl: Schema.optional(Schema.Literals(["fallback", "preferred"])).annotate({
description: description:
"Live crawl mode - 'fallback': use live crawling as backup if cached unavailable, 'preferred': prioritize live crawling (default: 'fallback')", "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({ type: Schema.optional(Schema.Literals(["auto", "fast", "deep"])).annotate({
description: "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search", description: "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
}), }),
contextMaxCharacters: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_CONTEXT_CHARACTERS))).annotate({ 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})`, description: `Maximum characters for context string optimized for models (default: 10000, maximum: ${MAX_CONTEXT_CHARACTERS})`,
}), },
),
}) })
export const Provider = Schema.Literals(["exa", "parallel"]) export const Provider = Schema.Literals(["exa", "parallel"])
@@ -63,13 +67,11 @@ export class ConfigService extends Context.Service<ConfigService, Config>()("@op
/** Isolates the retained product environment contract from the generic tool implementation. */ /** Isolates the retained product environment contract from the generic tool implementation. */
export const defaultConfigLayer = Layer.sync(ConfigService, () => export const defaultConfigLayer = Layer.sync(ConfigService, () =>
ConfigService.of({ ConfigService.of({
provider: process.env.OPENCODE_WEBSEARCH_PROVIDER === "exa" || process.env.OPENCODE_WEBSEARCH_PROVIDER === "parallel" provider:
process.env.OPENCODE_WEBSEARCH_PROVIDER === "exa" || process.env.OPENCODE_WEBSEARCH_PROVIDER === "parallel"
? process.env.OPENCODE_WEBSEARCH_PROVIDER ? process.env.OPENCODE_WEBSEARCH_PROVIDER
: undefined, : undefined,
enableExa: enableExa: truthy("OPENCODE_EXPERIMENTAL") || truthy("OPENCODE_ENABLE_EXA") || truthy("OPENCODE_EXPERIMENTAL_EXA"),
truthy("OPENCODE_EXPERIMENTAL") ||
truthy("OPENCODE_ENABLE_EXA") ||
truthy("OPENCODE_EXPERIMENTAL_EXA"),
enableParallel: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"), enableParallel: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"),
exaApiKey: process.env.EXA_API_KEY, exaApiKey: process.env.EXA_API_KEY,
parallelApiKey: process.env.PARALLEL_API_KEY, parallelApiKey: process.env.PARALLEL_API_KEY,
@@ -162,9 +164,15 @@ const callMcp = <F extends Schema.Struct.Fields>(
return yield* Effect.gen(function* () { return yield* Effect.gen(function* () {
const response = yield* HttpClient.filterStatusOk(http).execute(request) const response = yield* HttpClient.filterStatusOk(http).execute(request)
const body = yield* response.text 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) 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({ const Success = Schema.Struct({
@@ -201,7 +209,8 @@ export const layer = Layer.effectDiscard(
metadata: { ...parameters, provider }, metadata: { ...parameters, provider },
}) })
const text = provider === "exa" const text =
provider === "exa"
? yield* callMcp(http, exaUrl(config.exaApiKey), "web_search_exa", ExaArgs, { ? yield* callMcp(http, exaUrl(config.exaApiKey), "web_search_exa", ExaArgs, {
query: parameters.query, query: parameters.query,
type: parameters.type || "auto", type: parameters.type || "auto",
@@ -234,7 +243,12 @@ export const layer = Layer.effectDiscard(
} }
}).pipe( }).pipe(
Effect.catchCause((cause) => 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),
}),
),
), ),
) )
}, },
+9 -1
View File
@@ -114,7 +114,15 @@ describe("AgentV2", () => {
) )
const agents = yield* agent.all() 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) { for (const item of agents) {
expect(item.permissions.some((rule) => rule.action === "bash" && rule.effect !== "deny")).toBe(false) expect(item.permissions.some((rule) => rule.action === "bash" && rule.effect !== "deny")).toBe(false)
} }
@@ -34,7 +34,10 @@ describe("DatabaseMigration", () => {
const layers = [Database.layerFromPath(filename), Database.layerFromPath(filename)] const layers = [Database.layerFromPath(filename), Database.layerFromPath(filename)]
await Effect.runPromise( 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") { if (process.platform === "linux") {
@@ -12,7 +12,9 @@ describe("KeyedMutex", () => {
const secondStarted = yield* Deferred.make<void>() const secondStarted = yield* Deferred.make<void>()
const first = yield* mutex 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) .pipe(Effect.forkChild)
yield* Deferred.await(firstStarted) yield* Deferred.await(firstStarted)
const second = yield* mutex.withLock("shared")(Deferred.succeed(secondStarted, undefined)).pipe(Effect.forkChild) const second = yield* mutex.withLock("shared")(Deferred.succeed(secondStarted, undefined)).pipe(Effect.forkChild)
@@ -53,7 +55,9 @@ describe("KeyedMutex", () => {
const releaseFirst = yield* Deferred.make<void>() const releaseFirst = yield* Deferred.make<void>()
const first = yield* mutex 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) .pipe(Effect.forkChild)
yield* Deferred.await(firstStarted) yield* Deferred.await(firstStarted)
const interrupted = yield* mutex.withLock("shared")(Effect.void).pipe(Effect.forkChild) const interrupted = yield* mutex.withLock("shared")(Effect.void).pipe(Effect.forkChild)
+42 -13
View File
@@ -13,7 +13,9 @@ import { testEffect } from "./lib/effect"
const locationLayer = Layer.succeed( const locationLayer = Layer.succeed(
Location.Service, 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 eventLayer = Layer.mergeAll(EventV2.defaultLayer, Database.defaultLayer)
const it = testEffect(eventLayer.pipe(Layer.provideMerge(locationLayer))) 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)).toBe(EventV2.ID.fromExternal(input))
expect(EventV2.ID.fromExternal(input)).toMatch(/^evt_[a-f0-9]{64}$/) 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({ ...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.type).toBe("test.message")
expect(event).not.toHaveProperty("version") expect(event).not.toHaveProperty("version")
expect(event.data).toEqual({ text: "hello" }) 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() const aggregateID = EventV2.ID.create()
yield* events.publish(SyncMessage, { id: aggregateID, text: "zero" }) yield* events.publish(SyncMessage, { id: aggregateID, text: "zero" })
yield* events.publish(SyncMessage, { id: aggregateID, text: "one" }) 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* Effect.yieldNow
yield* events.publish(SyncMessage, { id: aggregateID, text: "two" }) yield* events.publish(SyncMessage, { id: aggregateID, text: "two" })
@@ -309,11 +318,18 @@ describe("EventV2", () => {
const events = yield* EventV2.Service const events = yield* EventV2.Service
const aggregateID = EventV2.ID.create() const aggregateID = EventV2.ID.create()
yield* events.publish(SyncMessage, { id: aggregateID, text: "zero" }) 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" }) 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(0), "zero"],
[EventV2.Cursor.make(1), "one"], [EventV2.Cursor.make(1), "one"],
]) ])
@@ -336,7 +352,9 @@ describe("EventV2", () => {
yield* Effect.gen(function* () { yield* Effect.gen(function* () {
const events = yield* EventV2.Service const events = yield* EventV2.Service
const aggregateID = EventV2.ID.create() 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) yield* Deferred.await(readStarted)
pause = false pause = false
@@ -355,7 +373,9 @@ describe("EventV2", () => {
const events = yield* EventV2.Service const events = yield* EventV2.Service
const aggregateID = EventV2.ID.create() const aggregateID = EventV2.ID.create()
const count = 64 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 yield* Effect.yieldNow
for (let index = 0; index < count; index++) { 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( 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* () { Effect.gen(function* () {
const events = yield* EventV2.Service const events = yield* EventV2.Service
const aggregateID = EventV2.ID.create() 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* Effect.yieldNow
yield* events.publish(Message, { text: "live only" }) yield* events.publish(Message, { text: "live only" })
@@ -450,7 +475,9 @@ describe("EventV2", () => {
}), }),
) )
it.effect("replay rejects an envelope aggregate that differs from its payload without mutating the payload aggregate", () => it.effect(
"replay rejects an envelope aggregate that differs from its payload without mutating the payload aggregate",
() =>
Effect.gen(function* () { Effect.gen(function* () {
const events = yield* EventV2.Service const events = yield* EventV2.Service
const { db } = yield* Database.Service const { db } = yield* Database.Service
@@ -750,7 +777,8 @@ describe("EventV2", () => {
{ ownerID: "owner-1" }, { ownerID: "owner-1" },
) )
const exit = yield* events.replay( const exit = yield* events
.replay(
{ {
id: EventV2.ID.create(), id: EventV2.ID.create(),
type: EventV2.versionedType(SyncMessage.type, 1), type: EventV2.versionedType(SyncMessage.type, 1),
@@ -759,7 +787,8 @@ describe("EventV2", () => {
data: { id: aggregateID, text: "conflict" }, data: { id: aggregateID, text: "conflict" },
}, },
{ ownerID: "owner-2", strictOwner: true }, { ownerID: "owner-2", strictOwner: true },
).pipe(Effect.exit) )
.pipe(Effect.exit)
expect(String(exit)).toContain("Replay owner mismatch") expect(String(exit)).toContain("Replay owner mismatch")
}), }),
+51 -9
View File
@@ -89,7 +89,9 @@ describe("FileMutation", () => {
const plan = yield* (yield* LocationMutation.Service).resolve({ path: "appeared.txt" }) const plan = yield* (yield* LocationMutation.Service).resolve({ path: "appeared.txt" })
yield* Effect.promise(() => fs.writeFile(targetPath, "winner")) 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", _tag: "LocationMutation.RevalidationError",
}) })
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("winner") 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 plan = yield* (yield* LocationMutation.Service).resolve({ path: "remove.txt" })
const result = yield* (yield* FileMutation.Service).remove({ plan }) const result = yield* (yield* FileMutation.Service).remove({ plan })
expect(result).toEqual({ operation: "remove", target: plan.target.canonical, resource: "remove.txt", existed: true }) expect(result).toEqual({
expect(yield* Effect.promise(() => fs.stat(targetPath).then(() => true, () => false))).toBe(false) 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)), }).pipe(provide(directory)),
), ),
) )
@@ -119,7 +133,12 @@ describe("FileMutation", () => {
const plan = yield* (yield* LocationMutation.Service).resolve({ path: targetPath }) const plan = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const result = yield* (yield* FileMutation.Service).write({ plan, content: "external" }) 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") expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("external")
}).pipe(provide(directory)), }).pipe(provide(directory)),
), ),
@@ -135,8 +154,20 @@ describe("FileMutation", () => {
const plan = yield* (yield* LocationMutation.Service).resolve({ path: targetPath }) const plan = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const result = yield* (yield* FileMutation.Service).remove({ plan }) const result = yield* (yield* FileMutation.Service).remove({ plan })
expect(result).toEqual({ operation: "remove", target: plan.target.canonical, resource: plan.target.resource, existed: true }) expect(result).toEqual({
expect(yield* Effect.promise(() => fs.stat(targetPath).then(() => true, () => false))).toBe(false) 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)), }).pipe(provide(directory)),
), ),
), ),
@@ -155,10 +186,19 @@ describe("FileMutation", () => {
await fs.symlink(outside, parent) 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", _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)), }).pipe(provide(directory)),
), ),
), ),
@@ -233,7 +273,9 @@ describe("FileMutation", () => {
const expected = new TextEncoder().encode("initial") const expected = new TextEncoder().encode("initial")
const first = yield* files.writeIfUnchanged({ plan, expected, content: "first" }).pipe(Effect.forkChild) const first = yield* files.writeIfUnchanged({ plan, expected, content: "first" }).pipe(Effect.forkChild)
yield* Deferred.await(firstStarted) 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* Deferred.succeed(releaseFirst, undefined)
yield* Fiber.join(first) yield* Fiber.join(first)
+9 -6
View File
@@ -138,10 +138,9 @@ describe("LocationSearch", () => {
RelativePath.make("visible.txt"), RelativePath.make("visible.txt"),
]) ])
expect((yield* search.files({ pattern: ".env" })).items).toEqual([]) expect((yield* search.files({ pattern: ".env" })).items).toEqual([])
expect((yield* search.grep({ pattern: "needle", include: "*" })).items.map((item) => item.path).sort()).toEqual([ expect((yield* search.grep({ pattern: "needle", include: "*" })).items.map((item) => item.path).sort()).toEqual(
RelativePath.make("nested/visible.txt"), [RelativePath.make("nested/visible.txt"), RelativePath.make("visible.txt")],
RelativePath.make("visible.txt"), )
])
}).pipe(provide(directory)), }).pipe(provide(directory)),
), ),
) )
@@ -191,7 +190,9 @@ describe("LocationSearch", () => {
it.live("rejects oversized ripgrep JSON records before durable projection", () => it.live("rejects oversized ripgrep JSON records before durable projection", () =>
withTmp((directory) => withTmp((directory) =>
Effect.gen(function* () { 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) const exit = yield* (yield* LocationSearch.Service).grep({ pattern: "needle" }).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true) expect(Exit.isFailure(exit)).toBe(true)
@@ -243,7 +244,9 @@ describe("LocationSearch", () => {
await fs.symlink(outside, source) 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 })) yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
}).pipe(provide(directory)), }).pipe(provide(directory)),
), ),
+22 -11
View File
@@ -4,10 +4,17 @@ import { Patch } from "@opencode-ai/core/patch"
describe("Patch", () => { describe("Patch", () => {
test("parses add, update, and delete hunks", () => { test("parses add, update, and delete hunks", () => {
expect( 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([ ).toEqual([
{ type: "add", path: "add.txt", contents: "added" }, { 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" }, { type: "delete", path: "delete.txt" },
]) ])
}) })
@@ -19,11 +26,7 @@ describe("Patch", () => {
}) })
test("derives fuzzy line updates while preserving BOM", () => { test("derives fuzzy line updates while preserving BOM", () => {
const update = Patch.derive( const update = Patch.derive("update.txt", [{ oldLines: [" old "], newLines: ["new"] }], "\uFEFFold\n")
"update.txt",
[{ oldLines: [" old "], newLines: ["new"] }],
"\uFEFFold\n",
)
expect(update).toEqual({ content: "new\n", bom: true }) expect(update).toEqual({ content: "new\n", bom: true })
expect(Patch.joinBom(update.content, update.bom)).toBe("\uFEFFnew\n") expect(Patch.joinBom(update.content, update.bom)).toBe("\uFEFFnew\n")
}) })
@@ -39,7 +42,9 @@ describe("Patch", () => {
}) })
test("parses the EOF marker inside update chunks", () => { 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", type: "update",
path: "update.txt", path: "update.txt",
@@ -50,8 +55,14 @@ describe("Patch", () => {
}) })
test("rejects malformed hunk bodies", () => { 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*** Add File: add.txt\nmissing plus\n*** End Patch")).toThrow(
expect(() => Patch.parse("*** Begin Patch\n*** Update File: update.txt\n*** End Patch")).toThrow("expected at least one @@ chunk") "Invalid add file line",
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*** 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",
)
}) })
}) })
+7 -1
View File
@@ -25,7 +25,13 @@ const current = Layer.succeed(
) )
const events = EventV2.layer.pipe(Layer.provide(database)) const events = EventV2.layer.pipe(Layer.provide(database))
const store = SessionStore.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 saved = PermissionSaved.layer.pipe(Layer.provide(database))
const layer = PermissionV2.locationLayer.pipe( const layer = PermissionV2.locationLayer.pipe(
Layer.provideMerge(database), Layer.provideMerge(database),
+3 -1
View File
@@ -234,7 +234,9 @@ describe("SessionV2.create", () => {
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* SessionV2.Service const session = yield* SessionV2.Service
const created = yield* session.create({ location }) const created = yield* session.create({ location })
const unavailable = (effect: Effect.Effect<void, SessionV2.NotFoundError | SessionV2.OperationUnavailableError>) => const unavailable = (
effect: Effect.Effect<void, SessionV2.NotFoundError | SessionV2.OperationUnavailableError>,
) =>
effect.pipe( effect.pipe(
Effect.flip, Effect.flip,
Effect.map((error) => (error instanceof SessionV2.OperationUnavailableError ? error.operation : "not-found")), Effect.map((error) => (error instanceof SessionV2.OperationUnavailableError ? error.operation : "not-found")),
+29 -12
View File
@@ -87,19 +87,15 @@ describe("SessionProjector", () => {
}) })
expect(secondPage.map((message) => (message.type === "user" ? message.text : message.type))).toEqual(["second"]) expect(secondPage.map((message) => (message.type === "user" ? message.text : message.type))).toEqual(["second"])
expect( expect(
( (yield* sessions.messages({
yield* sessions.messages({
sessionID, sessionID,
limit: 1, limit: 1,
order: "asc", order: "asc",
cursor: { id: secondPage[0]!.id, direction: "previous" }, cursor: { id: secondPage[0]!.id, direction: "previous" },
}) })).map((message) => (message.type === "user" ? message.text : message.type)),
).map((message) => (message.type === "user" ? message.text : message.type)),
).toEqual(["first"]) ).toEqual(["first"])
expect( expect(
(yield* sessions.context(sessionID)).map((message) => (yield* sessions.context(sessionID)).map((message) => (message.type === "user" ? message.text : message.type)),
message.type === "user" ? message.text : message.type,
),
).toEqual(["first", "second"]) ).toEqual(["first", "second"])
}).pipe( }).pipe(
Effect.provide( Effect.provide(
@@ -222,7 +218,9 @@ describe("SessionProjector", () => {
summary: "summary", summary: "summary",
include: "msg-1", 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", agent: "build",
model, model,
time_updated: DateTime.toEpochMillis(created), 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", () => it.effect("rejects a Prompted delivery mode that conflicts with an admitted inbox row", () =>
Effect.gen(function* () { Effect.gen(function* () {
const { db } = yield* Database.Service 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
yield* db.insert(SessionTable).values({ id: sessionID, project_id: Project.ID.global, slug: "test", directory: "/project", title: "test", version: "test" }).run().pipe(Effect.orDie) .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 events = yield* EventV2.Service
const id = SessionMessage.ID.make("evt_delivery_conflict") const id = SessionMessage.ID.make("evt_delivery_conflict")
const prompt = new Prompt({ text: "admitted" }) const prompt = new Prompt({ text: "admitted" })
yield* SessionInput.admit(db, { id, sessionID, prompt, delivery: "queue" }) 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(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 })
}), }),
) )
+3 -1
View File
@@ -383,7 +383,9 @@ describe("SessionV2.prompt", () => {
yield* SessionInput.promoteSteers(db, events, sessionID) yield* SessionInput.promoteSteers(db, events, sessionID)
expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 0 }) expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 0 })
expect(yield* session.messages({ sessionID })).toMatchObject([{ id: messageID, type: "user", text: "Reserved prompt" }]) expect(yield* session.messages({ sessionID })).toMatchObject([
{ id: messageID, type: "user", text: "Reserved prompt" },
])
}), }),
) )
@@ -19,8 +19,14 @@ describe("toLLMMessages", () => {
test("maps every top-level V2 Session message type", () => { 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 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 reference = new ReferenceAttachment({ name: "docs", kind: "local", uri: "file:///docs" })
const messages = toLLMMessages([ const messages = toLLMMessages(
new SessionMessage.AgentSwitched({ id: id("agent"), type: "agent-switched", agent: "build", time: { created } }), [
new SessionMessage.AgentSwitched({
id: id("agent"),
type: "agent-switched",
agent: "build",
time: { created },
}),
new SessionMessage.ModelSwitched({ new SessionMessage.ModelSwitched({
id: id("model"), id: id("model"),
type: "model-switched", type: "model-switched",
@@ -58,7 +64,9 @@ describe("toLLMMessages", () => {
summary: "Earlier work", summary: "Earlier work",
time: { created }, time: { created },
}), }),
], model) ],
model,
)
expect(messages.map((message) => message.role)).toEqual(["user", "user", "user", "user"]) expect(messages.map((message) => message.role)).toEqual(["user", "user", "user", "user"])
expect(messages[0]).toEqual( expect(messages[0]).toEqual(
@@ -80,7 +88,8 @@ describe("toLLMMessages", () => {
}) })
test("expands assistant tool calls and settled outcomes into canonical tool messages", () => { test("expands assistant tool calls and settled outcomes into canonical tool messages", () => {
const messages = toLLMMessages([ const messages = toLLMMessages(
[
new SessionMessage.Assistant({ new SessionMessage.Assistant({
id: id("assistant"), id: id("assistant"),
type: "assistant", type: "assistant",
@@ -167,7 +176,9 @@ describe("toLLMMessages", () => {
], ],
time: { created, completed: created }, time: { created, completed: created },
}), }),
], model) ],
model,
)
expect(messages.map((message) => message.role)).toEqual(["assistant", "tool"]) expect(messages.map((message) => message.role)).toEqual(["assistant", "tool"])
expect(messages[0]?.content).toEqual([ expect(messages[0]?.content).toEqual([
@@ -211,7 +222,10 @@ describe("toLLMMessages", () => {
name: "write", name: "write",
providerExecuted: true, providerExecuted: true,
providerMetadata: { fake: { continuation: "failed" } }, 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([ expect(messages[1]?.content).toEqual([
@@ -231,7 +245,8 @@ describe("toLLMMessages", () => {
}) })
test("restores OpenAI encrypted reasoning metadata", () => { test("restores OpenAI encrypted reasoning metadata", () => {
const messages = toLLMMessages([ const messages = toLLMMessages(
[
new SessionMessage.Assistant({ new SessionMessage.Assistant({
id: id("assistant-openai-reasoning"), id: id("assistant-openai-reasoning"),
type: "assistant", type: "assistant",
@@ -247,7 +262,9 @@ describe("toLLMMessages", () => {
], ],
time: { created, completed: created }, time: { created, completed: created },
}), }),
], model) ],
model,
)
expect(messages[0]?.content).toEqual([ expect(messages[0]?.content).toEqual([
{ {
@@ -13,7 +13,9 @@ const permission = Layer.succeed(
PermissionV2.Service.of({ PermissionV2.Service.of({
assert: (input) => assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe( 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"), ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"), reply: () => Effect.die("unused"),
+5 -1
View File
@@ -1979,7 +1979,11 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () { Effect.gen(function* () {
yield* setup yield* setup
const session = yield* SessionV2.Service 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() const failure = providerUnavailable()
responseStream = Stream.concat( responseStream = Stream.concat(
Stream.fromIterable([ Stream.fromIterable([
+105 -16
View File
@@ -32,36 +32,125 @@ describe("Tool.Progress", () => {
const { db } = yield* Database.Service const { db } = yield* Database.Service
const service = yield* EventV2.Service const service = yield* EventV2.Service
const sessionID = SessionV2.ID.make("ses_tool_progress_projector") 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
yield* db.insert(SessionTable).values({ id: sessionID, project_id: Project.ID.global, slug: "progress", directory: "/project", title: "progress", version: "test" }).run().pipe(Effect.orDie) .insert(ProjectTable)
const assistantMessageID = (yield* service.publish(SessionEvent.Step.Started, { sessionID, timestamp, agent: "build", model })).id .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 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") if (!row) return yield* Effect.die("Missing projected assistant")
return Schema.decodeUnknownSync(SessionMessage.Assistant)({ ...row.data, id: row.id, type: row.type }) return Schema.decodeUnknownSync(SessionMessage.Assistant)({ ...row.data, id: row.id, type: row.type })
}) })
const start = (callID: string) => Effect.gen(function* () { const start = (callID: string) =>
yield* service.publish(SessionEvent.Tool.Input.Started, { sessionID, timestamp, assistantMessageID, callID, name: "bash" }) Effect.gen(function* () {
yield* service.publish(SessionEvent.Tool.Called, { sessionID, timestamp, assistantMessageID, callID, tool: "bash", input: { command: "pwd" }, provider: { executed: false } }) 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") 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") }) yield* service.publish(SessionEvent.Tool.Progress, {
expect((yield* readAssistant).content[0]).toMatchObject({ state: { status: "running", structured: { phase: "checkpoint" }, content: content("saved") } }) 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 } }) const success = yield* service.publish(SessionEvent.Tool.Success, {
expect((yield* readAssistant).content[0]).toMatchObject({ state: { status: "completed", structured: { phase: "done" }, content: content("complete") } }) 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* start("call-failed")
yield* service.publish(SessionEvent.Tool.Progress, { sessionID, timestamp, assistantMessageID, callID: "call-failed", structured: { phase: "checkpoint" }, content: content("before failure") }) yield* service.publish(SessionEvent.Tool.Progress, {
const failed = yield* service.publish(SessionEvent.Tool.Failed, { sessionID, timestamp, assistantMessageID, callID: "call-failed", error: { type: "unknown", message: "boom" }, provider: { executed: false } }) sessionID,
expect((yield* readAssistant).content[1]).toMatchObject({ state: { status: "error", structured: { phase: "checkpoint" }, content: content("before failure"), error: { type: "unknown", message: "boom" } } }) 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)(success)).toBe(true)
expect(Schema.is(SessionEvent.Durable)(failed)).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.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.Success.type, 1))
expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Failed.type, 1)) expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Failed.type, 1))
+2 -5
View File
@@ -86,13 +86,10 @@ describe("SkillDiscovery.pull", () => {
}) })
test("downloads safe nested files under the skill root", async () => { test("downloads safe nested files under the skill root", async () => {
const result = await pull( const result = await pull([{ name: "deploy", files: ["SKILL.md", "references/guide.md"] }], {
[{ name: "deploy", files: ["SKILL.md", "references/guide.md"] }],
{
[`${base}deploy/SKILL.md`]: "# Deploy", [`${base}deploy/SKILL.md`]: "# Deploy",
[`${base}deploy/references/guide.md`]: "# Guide", [`${base}deploy/references/guide.md`]: "# Guide",
}, })
)
try { try {
expect(result.directories).toHaveLength(1) expect(result.directories).toHaveLength(1)
expect(result.requests.toSorted()).toEqual( expect(result.requests.toSorted()).toEqual(
+65 -17
View File
@@ -33,7 +33,9 @@ const permission = Layer.succeed(
assertions.push(input) assertions.push(input)
if (input.action === "edit") editApproved = true if (input.action === "edit") editApproved = true
}).pipe( }).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"), ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"), reply: () => Effect.die("unused"),
@@ -67,7 +69,10 @@ const filesystem = Layer.effect(
remove: (target, options) => { remove: (target, options) => {
if (failRemoveTarget && path.basename(target) === failRemoveTarget) return Effect.die("forced remove failure") if (failRemoveTarget && path.basename(target) === failRemoveTarget) return Effect.die("forced remove failure")
if (blockRemoveTarget && path.basename(target) === blockRemoveTarget && removeStarted && releaseRemove) 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) 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 } }, 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) const it = testEffect(Layer.empty)
describe("ApplyPatchTool", () => { describe("ApplyPatchTool", () => {
@@ -109,13 +120,17 @@ describe("ApplyPatchTool", () => {
reset() reset()
const update = path.join(tmp.path, "update.txt") const update = path.join(tmp.path, "update.txt")
const remove = path.join(tmp.path, "remove.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( Effect.andThen(
withTool(tmp.path, (registry) => withTool(tmp.path, (registry) =>
Effect.gen(function* () { Effect.gen(function* () {
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["apply_patch"]) expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["apply_patch"])
const settled = yield* registry.settle( 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({ expect(settled.result).toEqual({
type: "text", type: "text",
@@ -132,7 +147,9 @@ describe("ApplyPatchTool", () => {
{ sessionID, action: "edit", resources: ["nested/new.txt", "update.txt", "remove.txt"], save: ["*"] }, { sessionID, action: "edit", resources: ["nested/new.txt", "update.txt", "remove.txt"], save: ["*"] },
]) ])
expect(readsBeforeEditApproval).toBe(0) 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* Effect.promise(() => fs.readFile(update, "utf8"))).toBe("after\n")
expect(yield* exists(remove)).toBe(false) expect(yield* exists(remove)).toBe(false)
}), }),
@@ -155,7 +172,11 @@ describe("ApplyPatchTool", () => {
withTool(tmp.path, (registry) => withTool(tmp.path, (registry) =>
Effect.gen(function* () { Effect.gen(function* () {
expect( 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" }) ).toEqual({ type: "error", value: "apply_patch moves are not supported yet" })
expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false) expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false)
expect(assertions).toEqual([]) expect(assertions).toEqual([])
@@ -179,7 +200,9 @@ describe("ApplyPatchTool", () => {
withTool(active.path, (registry) => withTool(active.path, (registry) =>
Effect.gen(function* () { Effect.gen(function* () {
expect( 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" }) ).toMatchObject({ type: "text" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"]) expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
expect(readsBeforeEditApproval).toBe(0) expect(readsBeforeEditApproval).toBe(0)
@@ -190,7 +213,9 @@ describe("ApplyPatchTool", () => {
) )
}, },
([active, outside]) => ([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() reset()
const first = path.join(outside.path, "first.txt") const first = path.join(outside.path, "first.txt")
const second = path.join(outside.path, "second.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( Effect.andThen(
withTool(active.path, (registry) => withTool(active.path, (registry) =>
Effect.gen(function* () { Effect.gen(function* () {
expect( 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" }) ).toMatchObject({ type: "text" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"]) expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
expect(assertions[0]?.resources).toEqual([ expect(assertions[0]?.resources).toEqual([
@@ -218,7 +249,9 @@ describe("ApplyPatchTool", () => {
) )
}, },
([active, outside]) => ([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) => return withTool(tmp.path, (registry) =>
Effect.gen(function* () { Effect.gen(function* () {
expect( 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" }) ).toEqual({ type: "error", value: "Unable to apply patch at missing.txt" })
expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false) expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false)
}), }),
@@ -251,7 +288,9 @@ describe("ApplyPatchTool", () => {
withTool(tmp.path, (registry) => withTool(tmp.path, (registry) =>
Effect.gen(function* () { Effect.gen(function* () {
expect( 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" }) ).toEqual({ type: "error", value: "Unable to apply patch at existing.txt" })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("sentinel\n") expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("sentinel\n")
}), }),
@@ -276,8 +315,13 @@ describe("ApplyPatchTool", () => {
withTool(tmp.path, (registry) => withTool(tmp.path, (registry) =>
Effect.gen(function* () { Effect.gen(function* () {
expect( expect(
yield* registry.execute(call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch")), yield* registry.execute(
).toEqual({ type: "error", value: "Patch partially applied before failing at second.txt. Applied: first.txt" }) 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(first)).toBe(false)
expect(yield* exists(second)).toBe(true) 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* Effect.promise(() => Promise.all([fs.writeFile(first, "first"), fs.writeFile(second, "second")]))
yield* withTool(tmp.path, (registry) => yield* withTool(tmp.path, (registry) =>
Effect.gen(function* () { 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!) yield* Deferred.await(removeStarted!)
const interrupt = yield* Fiber.interrupt(run).pipe(Effect.forkChild) const interrupt = yield* Fiber.interrupt(run).pipe(Effect.forkChild)
yield* Deferred.succeed(releaseRemove!, undefined) yield* Deferred.succeed(releaseRemove!, undefined)
+7 -2
View File
@@ -59,7 +59,9 @@ const filesystem = Layer.effect(
return FSUtil.Service.of({ return FSUtil.Service.of({
...fs, ...fs,
readFile: (target) => readFile: (target) =>
fs.readFile(target).pipe( fs
.readFile(target)
.pipe(
Effect.tap((content) => Effect.tap((content) =>
Effect.sync(() => reads++).pipe(Effect.andThen(Effect.suspend(() => afterRead(target, content)))), Effect.sync(() => reads++).pipe(Effect.andThen(Effect.suspend(() => afterRead(target, content)))),
), ),
@@ -369,7 +371,10 @@ describe("EditTool", () => {
), ),
Effect.andThen((result) => Effect.andThen((result) =>
Effect.gen(function* () { 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(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("newer\n")
expect(writes).toEqual([]) expect(writes).toEqual([])
}), }),
+24 -3
View File
@@ -153,7 +153,14 @@ describe("GlobTool", () => {
Effect.gen(function* () { Effect.gen(function* () {
reset() reset()
result = new LocationSearch.FilesResult({ 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, truncated: false,
partial: false, partial: false,
}) })
@@ -172,7 +179,14 @@ describe("GlobTool", () => {
Effect.gen(function* () { Effect.gen(function* () {
reset() reset()
result = new LocationSearch.FilesResult({ 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, truncated: false,
partial: false, partial: false,
}) })
@@ -197,7 +211,14 @@ describe("GlobTool", () => {
it.effect("formats bounded and partial results without discarding structured output", () => it.effect("formats bounded and partial results without discarding structured output", () =>
Effect.sync(() => { Effect.sync(() => {
const output = new LocationSearch.FilesResult({ 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, truncated: true,
partial: true, partial: true,
}) })
+25 -7
View File
@@ -43,7 +43,7 @@ const filesystem = Layer.succeed(
real: `/project/${input.path ?? "."}`, real: `/project/${input.path ?? "."}`,
directory: "/project", directory: "/project",
root: "/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, reference: input.reference,
type: "directory", type: "directory",
dev: 1, dev: 1,
@@ -87,7 +87,12 @@ const permission = Layer.succeed(
}), }),
) )
const registry = ToolRegistry.layer.pipe(Layer.provide(permission)) 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 it = testEffect(Layer.mergeAll(registry, filesystem, search, permission, grep))
const sessionID = SessionV2.ID.make("ses_grep_tool_test") const sessionID = SessionV2.ID.make("ses_grep_tool_test")
@@ -136,7 +141,12 @@ function provideLive(directory: string, projectReferences = references({})) {
Layer.provide(dependencies), Layer.provide(dependencies),
) )
const registry = ToolRegistry.layer.pipe(Layer.provide(permission)) 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) return Layer.mergeAll(registry, filesystem, search, permission, grep)
} }
@@ -178,7 +188,9 @@ describe("GrepTool", () => {
resources: ["guide"], resources: ["guide"],
metadata: { root: "manual:docs", reference: "manual", path: RelativePath.make("docs"), include: "*.md" }, 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.output?.structured).toEqual(result)
expect(settlement.result).toEqual({ expect(settlement.result).toEqual({
type: "text", 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", () => it.effect("returns a useful tool error for an invalid regex", () =>
Effect.gen(function* () { Effect.gen(function* () {
reset() 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({ expect(yield* execute({ pattern: "[" })).toEqual({
type: "error", type: "error",
@@ -261,7 +277,9 @@ describe("GrepTool", () => {
type: "text", type: "text",
value: "Found 1 matches\ndocs:guide.md:\n Line 1: needle docs\n", 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 } }))),
)
}), }),
), ),
) )
+30 -10
View File
@@ -47,9 +47,10 @@ describe("ToolOutputStore", () => {
it.live("returns under-limit text unchanged without writing a resource", () => it.live("returns under-limit text unchanged without writing a resource", () =>
withStore(({ store }) => withStore(({ store }) =>
Effect.gen(function* () { Effect.gen(function* () {
expect( expect(yield* store.truncate({ sessionID, toolCallID: "call-short", content: "line one\nline two" })).toEqual({
yield* store.truncate({ sessionID, toolCallID: "call-short", content: "line one\nline two" }), content: "line one\nline two",
).toEqual({ content: "line one\nline two", truncated: false }) truncated: false,
})
}), }),
), ),
) )
@@ -92,7 +93,12 @@ describe("ToolOutputStore", () => {
it.live("keeps one-line previews bounded", () => it.live("keeps one-line previews bounded", () =>
withStore(({ store }) => withStore(({ store }) =>
Effect.gen(function* () { 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) expect(result.truncated).toBe(true)
if (!result.truncated) throw new Error("expected truncation") if (!result.truncated) throw new Error("expected truncation")
@@ -105,7 +111,12 @@ describe("ToolOutputStore", () => {
it.live("pages reads within the bounded managed-resource limit", () => it.live("pages reads within the bounded managed-resource limit", () =>
withStore(({ root, store, fs }) => withStore(({ root, store, fs }) =>
Effect.gen(function* () { 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 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 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 }) 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(second).toMatchObject({ content: "4567", offset: 4, truncated: true, next: 8 })
expect(last).toMatchObject({ content: "89", offset: 8, truncated: false }) 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(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, sessionID,
toolCallID: "call-page", toolCallID: "call-page",
}) })
@@ -165,9 +182,9 @@ describe("ToolOutputStore", () => {
({ store }) => ({ store }) =>
Effect.gen(function* () { Effect.gen(function* () {
expect(yield* store.limits()).toEqual({ maxLines: 2, maxBytes: 1_000 }) expect(yield* store.limits()).toEqual({ maxLines: 2, maxBytes: 1_000 })
expect((yield* store.truncate({ sessionID, toolCallID: "call-config", content: "one\ntwo\nthree" })).truncated).toBe( expect(
true, (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 }) }), 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 unrelatedManaged = path.join(directory, "unrelated.txt")
const record = JSON.parse(yield* fs.readFileString(oldMetadata)) 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(unrelated, "keep")
yield* fs.writeFileString(unrelatedManaged, "keep") yield* fs.writeFileString(unrelatedManaged, "keep")
yield* store.cleanup() yield* store.cleanup()
+6 -1
View File
@@ -362,7 +362,12 @@ describe("ReadTool", () => {
expect( expect(
yield* registry.execute({ yield* registry.execute({
sessionID, 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({ ).toEqual({
type: "json", type: "json",
+6 -1
View File
@@ -45,7 +45,12 @@ describe("SkillTool", () => {
let bootWaited = false let bootWaited = false
const boot = Layer.succeed( const boot = Layer.succeed(
PluginBoot.Service, PluginBoot.Service,
PluginBoot.Service.of({ wait: () => Effect.sync(() => { bootWaited = true }) }), PluginBoot.Service.of({
wait: () =>
Effect.sync(() => {
bootWaited = true
}),
}),
) )
const permission = Layer.succeed( const permission = Layer.succeed(
PermissionV2.Service, PermissionV2.Service,
+34 -8
View File
@@ -28,7 +28,9 @@ describe("WebSearchTool provider selection", () => {
}) })
test("supports an explicit operational override", () => { 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") 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 () => { 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( expect(
"search results", 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", type: "tool-call",
id: "call-exa", id: "call-exa",
name: "websearch", 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" }) ).toEqual({ type: "text", value: "exa results" })
@@ -161,7 +171,14 @@ describe("WebSearchTool contribution", () => {
action: "websearch", action: "websearch",
resources: ["effect typescript"], resources: ["effect typescript"],
save: ["*"], 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([ expect(requests).toEqual([
@@ -174,7 +191,13 @@ describe("WebSearchTool contribution", () => {
method: "tools/call", method: "tools/call",
params: { params: {
name: "web_search_exa", 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(requests[0]?.body).not.toHaveProperty("params.arguments.model_name")
expect(settled).toEqual({ expect(settled).toEqual({
result: { type: "text", value: "parallel results" }, 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") expect(JSON.stringify(settled)).not.toContain("parallel-secret")
}), }),
+5 -1
View File
@@ -114,7 +114,11 @@ const streamWithTools = Effect.gen(function* () {
// A durable agent would persist these messages before starting another // A durable agent would persist these messages before starting another
// raw model turn. This tutorial keeps the boundary visible instead. // raw model turn. This tutorial keeps the boundary visible instead.
const followUp = LLM.updateRequest(request, { const followUp = LLM.updateRequest(request, {
messages: [...request.messages, Message.assistant([event]), Message.tool({ ...event, result: dispatched.result })], messages: [
...request.messages,
Message.assistant([event]),
Message.tool({ ...event, result: dispatched.result }),
],
}) })
console.log("follow-up history messages:", followUp.messages.length) console.log("follow-up history messages:", followUp.messages.length)
} }
@@ -363,11 +363,15 @@ const validateNativeSystemUpdate = Effect.fn("AnthropicMessages.validateNativeSy
const previous = messages[index - 1] const previous = messages[index - 1]
const next = messages[index + 1] const next = messages[index + 1]
if (!previous) 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") if (previous.role === "system")
return yield* invalid("Anthropic Messages chronological system updates cannot be consecutive") return yield* invalid("Anthropic Messages chronological system updates cannot be consecutive")
if (endsInLocalToolUse(previous)) 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)) if (previous.role !== "user" && previous.role !== "tool" && !endsInServerToolUse(previous))
return yield* invalid( return yield* invalid(
"Anthropic Messages chronological system updates must follow a user message, tool result, or assistant server tool use", "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") if (next?.role === "system")
return yield* invalid("Anthropic Messages chronological system updates cannot be consecutive") return yield* invalid("Anthropic Messages chronological system updates cannot be consecutive")
if (next && next.role !== "assistant") 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* ( 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 part = yield* ProviderShared.wrappedSystemUpdate("Anthropic Messages", message)
const block = { type: "text" as const, text: part.text, cache_control: cacheControl(breakpoints, part.cache) } const block = { type: "text" as const, text: part.text, cache_control: cacheControl(breakpoints, part.cache) }
const previous = messages.at(-1) 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] }) else messages.push({ role: "user", content: [block] })
continue continue
} }
@@ -243,7 +243,10 @@ const bedrockMetadata = (metadata: Record<string, unknown>): ProviderMetadata =>
const reasoningSignature = (part: ReasoningPart) => { const reasoningSignature = (part: ReasoningPart) => {
const bedrock = part.providerMetadata?.bedrock 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 => ({ 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 part = yield* ProviderShared.wrappedSystemUpdate("Bedrock Converse", message)
const content = textWithCache(breakpoints, part.text, part.cache) const content = textWithCache(breakpoints, part.text, part.cache)
const previous = messages.at(-1) 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 }) else messages.push({ role: "user", content })
continue continue
} }
@@ -532,7 +536,9 @@ const step = (state: ParserState, event: BedrockEvent) =>
Lifecycle.textEnd(state.lifecycle, events, `text-${index}`), Lifecycle.textEnd(state.lifecycle, events, `text-${index}`),
events, events,
`reasoning-${index}`, `reasoning-${index}`,
state.reasoningSignatures[index] ? bedrockMetadata({ signature: state.reasoningSignatures[index] }) : undefined, state.reasoningSignatures[index]
? bedrockMetadata({ signature: state.reasoningSignatures[index] })
: undefined,
) )
events.push(...resultEvents) events.push(...resultEvents)
return [ return [
+5 -2
View File
@@ -204,7 +204,8 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
if (message.role === "system") { if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate("Gemini", message) const part = yield* ProviderShared.wrappedSystemUpdate("Gemini", message)
const previous = contents.at(-1) 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 }] }) else contents.push({ role: "user", parts: [{ text: part.text }] })
continue continue
} }
@@ -405,7 +406,9 @@ const step = (state: ParserState, event: GeminiEvent) => {
id, id,
name: part.functionCall.name, name: part.functionCall.name,
input, input,
providerMetadata: part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined, providerMetadata: part.thoughtSignature
? googleMetadata({ thoughtSignature: part.thoughtSignature })
: undefined,
}), }),
) )
hasToolCalls = true hasToolCalls = true
+3 -1
View File
@@ -226,7 +226,9 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
content: content.length === 0 ? null : ProviderShared.joinText(content), content: content.length === 0 ? null : ProviderShared.joinText(content),
tool_calls: toolCalls.length === 0 ? undefined : toolCalls, tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
reasoning_content: 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),
} }
}) })
@@ -343,7 +343,10 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Responses", message) const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Responses", message)
const previous = input.at(-1) const previous = input.at(-1)
if (previous && "role" in previous && previous.role === "user") 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 }] }) else input.push({ role: "user", content: [{ type: "input_text", text: part.text }] })
continue continue
} }
@@ -397,7 +400,8 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
if (part.type === "tool-result" && part.providerExecuted === true) { if (part.type === "tool-result" && part.providerExecuted === true) {
flushText() flushText()
const itemID = hostedToolItemID(part) 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) if (itemID) hostedToolReferences.add(itemID)
continue continue
} }
+2 -1
View File
@@ -105,7 +105,8 @@ export const parseJson = (route: string, input: string, message: string) =>
*/ */
export const joinText = (parts: ReadonlyArray<{ readonly text: string }>) => parts.map((part) => part.text).join("\n") export const joinText = (parts: ReadonlyArray<{ readonly text: string }>) => parts.map((part) => part.text).join("\n")
const escapeSystemUpdateText = (text: string) => text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;") const escapeSystemUpdateText = (text: string) =>
text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
/** /**
* Stable fallback representation for chronological `Message.system(...)` * Stable fallback representation for chronological `Message.system(...)`
+2 -1
View File
@@ -193,7 +193,8 @@ export const ToolOutput = Object.assign(
type: "content", type: "content",
value: output.content.map((item) => { value: output.content.map((item) => {
if (item.type === "text") return { type: "text", text: item.text } 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 } return { type: "media", mediaType: item.mime, data: item.source.data, filename: item.name }
}), }),
} }
+10 -6
View File
@@ -1,5 +1,13 @@
import { Effect } from "effect" 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" import { type AnyTool, type Tools } from "./tool"
export interface ToolSettlement { export interface ToolSettlement {
@@ -52,11 +60,7 @@ const decodeAndExecute = (tool: AnyTool, call: ToolCallPart): Effect.Effect<Tool
), ),
) )
const result = ( const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement, error?: unknown): DispatchResult => {
call: ToolCallPart,
value: ToolResultValueType | ToolSettlement,
error?: unknown,
): DispatchResult => {
const settlement = ToolResultValue.is(value) ? { result: value } : value const settlement = ToolResultValue.is(value) ? { result: value } : value
return { return {
result: settlement.result, result: settlement.result,
+11 -2
View File
@@ -1,5 +1,10 @@
import { Effect, JsonSchema, Schema } from "effect" 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" import { ToolDefinition, ToolFailure, ToolOutput, toolText } from "./schema"
/** /**
@@ -51,7 +56,11 @@ export interface Tool<Parameters extends ToolSchema<any>, Success extends ToolSc
/** @internal */ /** @internal */
readonly _encode: (value: Schema.Schema.Type<Success>) => Effect.Effect<unknown, Schema.SchemaError> readonly _encode: (value: Schema.Schema.Type<Success>) => Effect.Effect<unknown, Schema.SchemaError>
/** @internal */ /** @internal */
readonly _project: (parameters: Schema.Schema.Type<Parameters>, callID: ToolCallPart["id"], output: unknown) => ToolOutputType readonly _project: (
parameters: Schema.Schema.Type<Parameters>,
callID: ToolCallPart["id"],
output: unknown,
) => ToolOutputType
/** @internal */ /** @internal */
readonly _legacyResult: boolean readonly _legacyResult: boolean
/** @internal */ /** @internal */
+5 -1
View File
@@ -117,7 +117,11 @@ const appendText = (
) => { ) => {
const last = content.at(-1) const last = content.at(-1)
if (last?.type === type) { 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 return
} }
content.push({ type, text, providerMetadata }) content.push({ type, text, providerMetadata })
+3 -1
View File
@@ -136,7 +136,9 @@ describe("llm constructors", () => {
}) })
test("builds chronological text-only system updates separately from the initial system prompt", () => { 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({ const request = LLM.request({
model: Model.make({ id: "fake-model", provider: "fake", route: chatRoute }), model: Model.make({ id: "fake-model", provider: "fake", route: chatRoute }),
system: "Initial operator prompt.", system: "Initial operator prompt.",
@@ -87,7 +87,11 @@ describe("Anthropic Messages route", () => {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>( const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({ LLM.request({
model, model,
messages: [Message.user("Before."), Message.system("Treat </system-update> literally."), Message.assistant("After.")], messages: [
Message.user("Before."),
Message.system("Treat </system-update> literally."),
Message.assistant("After."),
],
cache: "none", cache: "none",
}), }),
) )
@@ -127,19 +131,19 @@ describe("Anthropic Messages route", () => {
LLMClient.prepare(LLM.request({ model: opus48, messages, cache: "none" })).pipe(Effect.flip) 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.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( expect(
( (yield* placementError([Message.user("Before."), Message.system("One."), Message.system("Two.")])).message,
yield* placementError([ ).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.user("Use the tool."),
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]), Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
Message.system("Too early."), Message.system("Too early."),
Message.tool({ id: "call_1", name: "lookup", result: "Done." }), Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
]) ])).message,
).message,
).toContain("cannot appear between a local tool call and its tool result") ).toContain("cannot appear between a local tool call and its tool result")
}), }),
) )
@@ -327,7 +327,10 @@ describe("Bedrock Converse route", () => {
}), }),
) )
expect(prepared.body.messages).toEqual([ 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" } } }],
},
]) ])
}), }),
) )
+15 -3
View File
@@ -55,12 +55,19 @@ describe("OpenAI Chat route", () => {
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>( const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ LLM.request({
model, model,
messages: [Message.user("Before."), Message.system("Treat <admin> & data literally."), Message.assistant("After.")], messages: [
Message.user("Before."),
Message.system("Treat <admin> & data literally."),
Message.assistant("After."),
],
}), }),
) )
expect(prepared.body.messages).toEqual([ expect(prepared.body.messages).toEqual([
{ role: "user", content: "Before.\n<system-update>\nTreat &lt;admin&gt; &amp; data literally.\n</system-update>" }, {
role: "user",
content: "Before.\n<system-update>\nTreat &lt;admin&gt; &amp; data literally.\n</system-update>",
},
{ role: "assistant", content: "After." }, { role: "assistant", content: "After." },
]) ])
}), }),
@@ -71,7 +78,12 @@ describe("OpenAI Chat route", () => {
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>( const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ LLM.request({
model, model,
messages: [Message.assistant([{ type: "reasoning", text: "thinking" }, { type: "text", text: "Hello" }])], messages: [
Message.assistant([
{ type: "reasoning", text: "thinking" },
{ type: "text", text: "Hello" },
]),
],
}), }),
) )
@@ -62,7 +62,11 @@ describe("OpenAI Responses route", () => {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>( const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ LLM.request({
model, model,
messages: [Message.user("Before."), Message.system("Treat </system-update> literally."), Message.assistant("After.")], messages: [
Message.user("Before."),
Message.system("Treat </system-update> literally."),
Message.assistant("After."),
],
}), }),
) )
+42 -12
View File
@@ -1,6 +1,17 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect" 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 { Auth, LLMClient } from "../src/route"
import * as AnthropicMessages from "../src/protocols/anthropic-messages" import * as AnthropicMessages from "../src/protocols/anthropic-messages"
import * as OpenAIChat from "../src/protocols/openai-chat" import * as OpenAIChat from "../src/protocols/openai-chat"
@@ -188,10 +199,12 @@ describe("LLMClient tools", () => {
execute: () => Effect.succeed({ ok: true }), execute: () => Effect.succeed({ ok: true }),
}) })
expect((yield* ToolRuntime.dispatch({ text }, LLMEvent.toolCall({ id: "call_text", name: "text", input: {} }))).output) expect(
.toEqual({ structured: "hello", content: [{ type: "text", text: "hello" }] }) (yield* ToolRuntime.dispatch({ text }, LLMEvent.toolCall({ id: "call_text", name: "text", input: {} }))).output,
expect((yield* ToolRuntime.dispatch({ json }, LLMEvent.toolCall({ id: "call_json", name: "json", input: {} }))).output) ).toEqual({ structured: "hello", content: [{ type: "text", text: "hello" }] })
.toEqual({ structured: { ok: true }, content: [] }) 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" }, source: { type: "data", data: "AAAA" },
mime: "image/png", 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", type: "file",
source: { type: "url", url: "https://example.test/image.png" }, source: { type: "url", url: "https://example.test/image.png" },
mime: "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", type: "file",
source: { type: "file", uri: "file:///tmp/image.png" }, source: { type: "file", uri: "file:///tmp/image.png" },
mime: "image/png", mime: "image/png",
@@ -226,16 +243,29 @@ describe("LLMClient tools", () => {
).toEqual({ type: "content", value: [{ type: "media", mediaType: "image/png", data: "AAAA" }] }) ).toEqual({ type: "content", value: [{ type: "media", mediaType: "image/png", data: "AAAA" }] })
expect( expect(
ToolOutput.toResultValue( 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( expect(
ToolOutput.toResultValue( 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("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("file:///tmp/image.png")).toEqual({ type: "file", uri: "file:///tmp/image.png" })
expect(() => toolFileSourceFromUri("opaque-value")).toThrow("Unsupported tool file URI") expect(() => toolFileSourceFromUri("opaque-value")).toThrow("Unsupported tool file URI")
expect(() => expect(() =>
+3 -1
View File
@@ -27,7 +27,9 @@ Tool.make({
parameters: Schema.Struct({ city: Schema.String }), parameters: Schema.Struct({ city: Schema.String }),
success: Schema.Struct({ forecast: Schema.NumberFromString }), success: Schema.Struct({ forecast: Schema.NumberFromString }),
execute: () => Effect.succeed({ forecast: 1 }), 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) LLM.stream(request)
@@ -43,7 +43,9 @@ function latestTool(assistant: SessionMessageAssistant | undefined, callID?: str
} }
function latestText(assistant: SessionMessageAssistant | undefined, textID: string) { 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) { function latestReasoning(assistant: SessionMessageAssistant | undefined, reasoningID: string) {
@@ -208,19 +210,28 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break break
case "session.next.tool.input.delta": case "session.next.tool.input.delta":
update(event.properties.sessionID, (draft) => { 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 if (match?.state.status === "pending") match.state.input += event.properties.delta
}) })
break break
case "session.next.tool.input.ended": case "session.next.tool.input.ended":
update(event.properties.sessionID, (draft) => { 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 if (match?.state.status === "pending") match.state.input = event.properties.text
}) })
break break
case "session.next.tool.called": case "session.next.tool.called":
update(event.properties.sessionID, (draft) => { 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 if (!match) return
match.time.ran = event.properties.timestamp match.time.ran = event.properties.timestamp
match.provider = event.properties.provider match.provider = event.properties.provider
@@ -229,7 +240,10 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break break
case "session.next.tool.progress": case "session.next.tool.progress":
update(event.properties.sessionID, (draft) => { 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 if (match?.state.status !== "running") return
match.state.structured = event.properties.structured match.state.structured = event.properties.structured
match.state.content = [...event.properties.content] match.state.content = [...event.properties.content]
@@ -237,7 +251,10 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break break
case "session.next.tool.success": case "session.next.tool.success":
update(event.properties.sessionID, (draft) => { 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 if (match?.state.status !== "running") return
match.state = { match.state = {
status: "completed", status: "completed",
@@ -252,7 +269,10 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break break
case "session.next.tool.failed": case "session.next.tool.failed":
update(event.properties.sessionID, (draft) => { 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 if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return
match.state = { match.state = {
status: "error", status: "error",
@@ -287,7 +307,8 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID) const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID)
if (match) { if (match) {
match.text = event.properties.text 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 break
@@ -1087,7 +1087,8 @@ function toolOutput(content?: Array<ToolTextContent | ToolFileContent>) {
return (content ?? []) return (content ?? [])
.map((item) => { .map((item) => {
if (item.type === "text") return item.text.trim() 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}]` return `[file ${item.name ?? source}]`
}) })
.filter(Boolean) .filter(Boolean)
@@ -7,7 +7,15 @@ import { asSchema, type ModelMessage, type Tool } from "ai"
import { Cause, Effect, FiberSet, Queue } from "effect" import { Cause, Effect, FiberSet, Queue } from "effect"
import * as Stream from "effect/Stream" import * as Stream from "effect/Stream"
import { FetchHttpClient } from "effect/unstable/http" 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 type { LLMClientShape } from "@opencode-ai/llm/route"
import { LLMNative } from "./native-request" import { LLMNative } from "./native-request"
+14 -4
View File
@@ -426,7 +426,9 @@ export const layer = Layer.effect(
case "tool-input-delta": case "tool-input-delta":
{ {
const toolCall = yield* ensureToolCall(value) 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) { if (assistantMessageID) {
yield* events.publish(SessionEvent.Tool.Input.Delta, { yield* events.publish(SessionEvent.Tool.Input.Delta, {
sessionID: ctx.sessionID, sessionID: ctx.sessionID,
@@ -589,12 +591,19 @@ export const layer = Layer.effect(
const content = [ const content = [
ToolOutput.text({ type: "text", text: output.output }), ToolOutput.text({ type: "text", text: output.output }),
...(output.attachments?.map((item: SessionV1.FilePart) => ...(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") const unsupported = content.find((item) => item.type === "file" && item.source.type !== "data")
if (unsupported?.type === "file") { 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, { yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: ctx.sessionID, sessionID: ctx.sessionID,
assistantMessageID, assistantMessageID,
@@ -611,7 +620,8 @@ export const layer = Layer.effect(
}) })
yield* failToolCall(value.id, error) yield* failToolCall(value.id, error)
return return
} else yield* events.publish(SessionEvent.Tool.Success, { } else
yield* events.publish(SessionEvent.Tool.Success, {
sessionID: ctx.sessionID, sessionID: ctx.sessionID,
assistantMessageID, assistantMessageID,
callID: value.id, callID: value.id,
@@ -107,7 +107,11 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
await wait(() => { await wait(() => {
const assistant = sync.session.message.fromSession("session-1")[0] 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] const assistant = sync.session.message.fromSession("session-1")[0]
@@ -47,7 +47,7 @@ const appCache: Partial<Record<string, CachedApp>> = {}
export async function disposeApps() { export async function disposeApps() {
const apps = Object.values(appCache) const apps = Object.values(appCache)
for (const key of Object.keys(appCache)) delete appCache[key] 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) { function app(modules: Runtime, options: CallOptions) {
@@ -493,10 +493,12 @@ describe("session HttpApi", () => {
direction: "next", direction: "next",
}) })
const nextMessagePage = yield* request(`/api/session/${session.id}/message?cursor=${messageCursor}`, { headers }) const nextMessagePage = yield* request(`/api/session/${session.id}/message?cursor=${messageCursor}`, {
expect((yield* json<{ items: SessionMessage.Message[] }>(nextMessagePage)).items.map((message) => message.id)).toEqual([ headers,
firstMessage.id, })
]) expect(
(yield* json<{ items: SessionMessage.Message[] }>(nextMessagePage)).items.map((message) => message.id),
).toEqual([firstMessage.id])
const legacyMessageCursor = Buffer.from( const legacyMessageCursor = Buffer.from(
JSON.stringify({ id: secondMessage.id, time: 1, order: "desc", direction: "next" }), 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}`, { const legacyMessagePage = yield* request(`/api/session/${session.id}/message?cursor=${legacyMessageCursor}`, {
headers, headers,
}) })
expect((yield* json<{ items: SessionMessage.Message[] }>(legacyMessagePage)).items.map((message) => message.id)).toEqual([ expect(
firstMessage.id, (yield* json<{ items: SessionMessage.Message[] }>(legacyMessagePage)).items.map((message) => message.id),
]) ).toEqual([firstMessage.id])
const messageCursorWithOrder = yield* request( const messageCursorWithOrder = yield* request(
`/api/session/${session.id}/message?cursor=${messageCursor}&order=asc`, `/api/session/${session.id}/message?cursor=${messageCursor}&order=asc`,
@@ -291,9 +291,7 @@ function recordedNativeLLMLayer(scenario: RecordedScenario) {
redactor: recordingRedactor, redactor: recordingRedactor,
}) })
const recordedClient = LLMClient.layer.pipe( const recordedClient = LLMClient.layer.pipe(
Layer.provide( Layer.provide(Layer.mergeAll(RequestExecutor.layer.pipe(Layer.provide(recordedHttp)), WebSocketExecutor.layer)),
Layer.mergeAll(RequestExecutor.layer.pipe(Layer.provide(recordedHttp)), WebSocketExecutor.layer),
),
) )
return Layer.mergeAll( return Layer.mergeAll(
@@ -1004,7 +1004,8 @@ itProviderError.live("session.processor effect tests fail provider-executed erro
const mdl = yield* provider.getModel(ref.providerID, ref.modelID) const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
const settlements: Array<typeof SessionEvent.Tool.Failed.Type> = [] const settlements: Array<typeof SessionEvent.Tool.Failed.Type> = []
const off = yield* events.listen((event) => { 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 return Effect.void
}) })
const handle = yield* processors.create({ assistantMessage: msg, sessionID: chat.id, model: mdl }) 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 let reasoning: string | undefined
const off = yield* events.listen((event) => { const off = yield* events.listen((event) => {
seen.push(event.type) 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) if (event.type === SessionEvent.Reasoning.Ended.type)
reasoning = (event.data as typeof SessionEvent.Reasoning.Ended.data.Type).text reasoning = (event.data as typeof SessionEvent.Reasoning.Ended.data.Type).text
return Effect.void return Effect.void
+60 -60
View File
@@ -46,12 +46,12 @@ export type Event =
| EventSessionError | EventSessionError
| EventInstallationUpdated | EventInstallationUpdated
| EventInstallationUpdateAvailable | EventInstallationUpdateAvailable
| EventPermissionV2Asked
| EventPermissionV2Replied
| EventFileEdited | EventFileEdited
| EventAccountAdded | EventAccountAdded
| EventAccountRemoved | EventAccountRemoved
| EventAccountSwitched | EventAccountSwitched
| EventPermissionV2Asked
| EventPermissionV2Replied
| EventFileWatcherUpdated | EventFileWatcherUpdated
| EventPtyCreated | EventPtyCreated
| EventPtyUpdated | EventPtyUpdated
@@ -1163,30 +1163,6 @@ export type GlobalEvent = {
version: string version: string
} }
} }
| {
id: string
type: "permission.v2.asked"
properties: {
id: string
sessionID: string
action: string
resources: Array<string>
save?: Array<string>
metadata?: {
[key: string]: unknown
}
source?: PermissionV2Source
}
}
| {
id: string
type: "permission.v2.replied"
properties: {
sessionID: string
requestID: string
reply: PermissionV2Reply
}
}
| { | {
id: string id: string
type: "file.edited" type: "file.edited"
@@ -1217,6 +1193,30 @@ export type GlobalEvent = {
to?: string to?: string
} }
} }
| {
id: string
type: "permission.v2.asked"
properties: {
id: string
sessionID: string
action: string
resources: Array<string>
save?: Array<string>
metadata?: {
[key: string]: unknown
}
source?: PermissionV2Source
}
}
| {
id: string
type: "permission.v2.replied"
properties: {
sessionID: string
requestID: string
reply: PermissionV2Reply
}
}
| { | {
id: string id: string
type: "file.watcher.updated" 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 = { export type AuthOAuthCredential = {
type: "oauth" type: "oauth"
refresh: string refresh: string
@@ -2925,6 +2917,14 @@ export type AuthInfo = {
credential: AuthCredential credential: AuthCredential
} }
export type PermissionV2Source = {
type: "tool"
messageID: string
callID: string
}
export type PermissionV2Reply = "once" | "always" | "reject"
export type QuestionV2Option = { export type QuestionV2Option = {
/** /**
* Display text (1-5 words, concise) * Display text (1-5 words, concise)
@@ -4404,32 +4404,6 @@ export type EventInstallationUpdateAvailable = {
} }
} }
export type EventPermissionV2Asked = {
id: string
type: "permission.v2.asked"
properties: {
id: string
sessionID: string
action: string
resources: Array<string>
save?: Array<string>
metadata?: {
[key: string]: unknown
}
source?: PermissionV2Source
}
}
export type EventPermissionV2Replied = {
id: string
type: "permission.v2.replied"
properties: {
sessionID: string
requestID: string
reply: PermissionV2Reply
}
}
export type EventFileEdited = { export type EventFileEdited = {
id: string id: string
type: "file.edited" type: "file.edited"
@@ -4464,6 +4438,32 @@ export type EventAccountSwitched = {
} }
} }
export type EventPermissionV2Asked = {
id: string
type: "permission.v2.asked"
properties: {
id: string
sessionID: string
action: string
resources: Array<string>
save?: Array<string>
metadata?: {
[key: string]: unknown
}
source?: PermissionV2Source
}
}
export type EventPermissionV2Replied = {
id: string
type: "permission.v2.replied"
properties: {
sessionID: string
requestID: string
reply: PermissionV2Reply
}
}
export type EventFileWatcherUpdated = { export type EventFileWatcherUpdated = {
id: string id: string
type: "file.watcher.updated" type: "file.watcher.updated"
+191 -191
View File
@@ -11655,12 +11655,6 @@
{ {
"$ref": "#/components/schemas/EventInstallationUpdate-available" "$ref": "#/components/schemas/EventInstallationUpdate-available"
}, },
{
"$ref": "#/components/schemas/EventPermissionV2Asked"
},
{
"$ref": "#/components/schemas/EventPermissionV2Replied"
},
{ {
"$ref": "#/components/schemas/EventFileEdited" "$ref": "#/components/schemas/EventFileEdited"
}, },
@@ -11673,6 +11667,12 @@
{ {
"$ref": "#/components/schemas/EventAccountSwitched" "$ref": "#/components/schemas/EventAccountSwitched"
}, },
{
"$ref": "#/components/schemas/EventPermissionV2Asked"
},
{
"$ref": "#/components/schemas/EventPermissionV2Replied"
},
{ {
"$ref": "#/components/schemas/EventFileWatcherUpdated" "$ref": "#/components/schemas/EventFileWatcherUpdated"
}, },
@@ -15112,88 +15112,6 @@
"required": ["id", "type", "properties"], "required": ["id", "type", "properties"],
"additionalProperties": false "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", "type": "object",
"properties": { "properties": {
@@ -15296,6 +15214,88 @@
"required": ["id", "type", "properties"], "required": ["id", "type", "properties"],
"additionalProperties": false "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", "type": "object",
"properties": { "properties": {
@@ -20175,27 +20175,6 @@
"required": ["message", "isRetryable"], "required": ["message", "isRetryable"],
"additionalProperties": false "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": { "AuthOAuthCredential": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -20266,6 +20245,27 @@
"required": ["id", "serviceID", "description", "credential"], "required": ["id", "serviceID", "description", "credential"],
"additionalProperties": false "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": { "QuestionV2Option": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -24737,88 +24737,6 @@
"required": ["id", "type", "properties"], "required": ["id", "type", "properties"],
"additionalProperties": false "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": { "EventFileEdited": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -24921,6 +24839,88 @@
"required": ["id", "type", "properties"], "required": ["id", "type", "properties"],
"additionalProperties": false "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": { "EventFileWatcherUpdated": {
"type": "object", "type": "object",
"properties": { "properties": {
+4 -1
View File
@@ -232,7 +232,10 @@ export interface Interface {
} }
readonly model: { readonly model: {
readonly get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => Effect.Effect<ModelV2.Info, ProviderNotFoundError | ModelNotFoundError> readonly get: (
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
) => Effect.Effect<ModelV2.Info, ProviderNotFoundError | ModelNotFoundError>
readonly all: () => Effect.Effect<ModelV2.Info[]> readonly all: () => Effect.Effect<ModelV2.Info[]>
readonly available: () => Effect.Effect<ModelV2.Info[]> readonly available: () => Effect.Effect<ModelV2.Info[]>
readonly default: () => Effect.Effect<Option.Option<ModelV2.Info>> readonly default: () => Effect.Effect<Option.Option<ModelV2.Info>>