fix(core): finalize v2 session context epochs
This commit is contained in:
parent
b28546a6a5
commit
cd812e2045
33 changed files with 1245 additions and 791 deletions
|
|
@ -1,10 +1,8 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "65b52d0f-2bbe-483f-a7ec-9d2a7fa29f57",
|
||||
"prevIds": [
|
||||
"fc92fa34-8074-44c3-88f0-a5417f7fd92d"
|
||||
],
|
||||
"id": "40f7b9b8-83b4-4ea0-a59f-76a489679d88",
|
||||
"prevIds": ["84c6ad6c-6116-48e1-b973-6fee4593496b"],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "workspace",
|
||||
|
|
@ -838,19 +836,9 @@
|
|||
"entityType": "columns",
|
||||
"table": "session_context_epoch"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": true,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "seq",
|
||||
"entityType": "columns",
|
||||
"table": "session_input"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
|
|
@ -888,6 +876,16 @@
|
|||
"entityType": "columns",
|
||||
"table": "session_input"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "admitted_seq",
|
||||
"entityType": "columns",
|
||||
"table": "session_input"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
|
|
@ -1399,13 +1397,9 @@
|
|||
"table": "session_share"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"project_id"
|
||||
],
|
||||
"columns": ["project_id"],
|
||||
"tableTo": "project",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
|
|
@ -1414,13 +1408,9 @@
|
|||
"table": "workspace"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"active_account_id"
|
||||
],
|
||||
"columns": ["active_account_id"],
|
||||
"tableTo": "account",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "SET NULL",
|
||||
"nameExplicit": false,
|
||||
|
|
@ -1429,13 +1419,9 @@
|
|||
"table": "account_state"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"aggregate_id"
|
||||
],
|
||||
"columns": ["aggregate_id"],
|
||||
"tableTo": "event_sequence",
|
||||
"columnsTo": [
|
||||
"aggregate_id"
|
||||
],
|
||||
"columnsTo": ["aggregate_id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
|
|
@ -1444,13 +1430,9 @@
|
|||
"table": "event"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"project_id"
|
||||
],
|
||||
"columns": ["project_id"],
|
||||
"tableTo": "project",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
|
|
@ -1459,13 +1441,9 @@
|
|||
"table": "permission"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"project_id"
|
||||
],
|
||||
"columns": ["project_id"],
|
||||
"tableTo": "project",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
|
|
@ -1474,13 +1452,9 @@
|
|||
"table": "project_directory"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"columns": ["session_id"],
|
||||
"tableTo": "session",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
|
|
@ -1489,13 +1463,9 @@
|
|||
"table": "message"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"message_id"
|
||||
],
|
||||
"columns": ["message_id"],
|
||||
"tableTo": "message",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
|
|
@ -1504,13 +1474,9 @@
|
|||
"table": "part"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"columns": ["session_id"],
|
||||
"tableTo": "session",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
|
|
@ -1519,13 +1485,9 @@
|
|||
"table": "session_context_epoch"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"columns": ["session_id"],
|
||||
"tableTo": "session",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
|
|
@ -1534,13 +1496,9 @@
|
|||
"table": "session_input"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"columns": ["session_id"],
|
||||
"tableTo": "session",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
|
|
@ -1549,13 +1507,9 @@
|
|||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"project_id"
|
||||
],
|
||||
"columns": ["project_id"],
|
||||
"tableTo": "project",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
|
|
@ -1564,13 +1518,9 @@
|
|||
"table": "session"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"columns": ["session_id"],
|
||||
"tableTo": "session",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
|
|
@ -1579,13 +1529,9 @@
|
|||
"table": "todo"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"columns": ["session_id"],
|
||||
"tableTo": "session",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
|
|
@ -1594,165 +1540,126 @@
|
|||
"table": "session_share"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"email",
|
||||
"url"
|
||||
],
|
||||
"columns": ["email", "url"],
|
||||
"nameExplicit": false,
|
||||
"name": "control_account_pk",
|
||||
"entityType": "pks",
|
||||
"table": "control_account"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"project_id",
|
||||
"directory"
|
||||
],
|
||||
"columns": ["project_id", "directory"],
|
||||
"nameExplicit": false,
|
||||
"name": "project_directory_pk",
|
||||
"entityType": "pks",
|
||||
"table": "project_directory"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id",
|
||||
"position"
|
||||
],
|
||||
"columns": ["session_id", "position"],
|
||||
"nameExplicit": false,
|
||||
"name": "todo_pk",
|
||||
"entityType": "pks",
|
||||
"table": "todo"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "workspace_pk",
|
||||
"table": "workspace",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"columns": ["name"],
|
||||
"nameExplicit": false,
|
||||
"name": "data_migration_pk",
|
||||
"table": "data_migration",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "account_state_pk",
|
||||
"table": "account_state",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "account_pk",
|
||||
"table": "account",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"aggregate_id"
|
||||
],
|
||||
"columns": ["aggregate_id"],
|
||||
"nameExplicit": false,
|
||||
"name": "event_sequence_pk",
|
||||
"table": "event_sequence",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "event_pk",
|
||||
"table": "event",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "permission_pk",
|
||||
"table": "permission",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "project_pk",
|
||||
"table": "project",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "message_pk",
|
||||
"table": "message",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "part_pk",
|
||||
"table": "part",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"columns": ["session_id"],
|
||||
"nameExplicit": false,
|
||||
"name": "session_context_epoch_pk",
|
||||
"table": "session_context_epoch",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"seq"
|
||||
],
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "session_input_pk",
|
||||
"table": "session_input",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "session_message_pk",
|
||||
"table": "session_message",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "session_pk",
|
||||
"table": "session",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"columns": ["session_id"],
|
||||
"nameExplicit": false,
|
||||
"name": "session_share_pk",
|
||||
"table": "session_share",
|
||||
|
|
@ -1769,7 +1676,7 @@
|
|||
"isExpression": false
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"isUnique": true,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "event_aggregate_seq_idx",
|
||||
|
|
@ -1889,7 +1796,7 @@
|
|||
"isExpression": false
|
||||
},
|
||||
{
|
||||
"value": "seq",
|
||||
"value": "admitted_seq",
|
||||
"isExpression": false
|
||||
}
|
||||
],
|
||||
|
|
@ -1900,6 +1807,42 @@
|
|||
"entityType": "indexes",
|
||||
"table": "session_input"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
{
|
||||
"value": "session_id",
|
||||
"isExpression": false
|
||||
},
|
||||
{
|
||||
"value": "admitted_seq",
|
||||
"isExpression": false
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "session_input_session_admitted_seq_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session_input"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
{
|
||||
"value": "session_id",
|
||||
"isExpression": false
|
||||
},
|
||||
{
|
||||
"value": "promoted_seq",
|
||||
"isExpression": false
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "session_input_session_promoted_seq_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session_input"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
{
|
||||
|
|
@ -1911,7 +1854,7 @@
|
|||
"isExpression": false
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"isUnique": true,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "session_message_session_seq_idx",
|
||||
|
|
@ -2031,16 +1974,7 @@
|
|||
"name": "todo_session_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "todo"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "session_input_id_unique",
|
||||
"entityType": "uniques",
|
||||
"table": "session_input"
|
||||
}
|
||||
],
|
||||
"renames": []
|
||||
}
|
||||
}
|
||||
2
packages/core/src/database/migration.gen.ts
generated
2
packages/core/src/database/migration.gen.ts
generated
|
|
@ -32,6 +32,6 @@ export const migrations = (
|
|||
import("./migration/20260603141458_session_input_inbox"),
|
||||
import("./migration/20260603160727_jittery_ezekiel_stane"),
|
||||
import("./migration/20260604172448_event_sourced_session_input"),
|
||||
import("./migration/20260604234609_add_session_context_snapshot"),
|
||||
import("./migration/20260605003541_add_session_context_snapshot"),
|
||||
])
|
||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Effect } from "effect"
|
|||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260604234609_add_session_context_snapshot",
|
||||
id: "20260605003541_add_session_context_snapshot",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`
|
||||
|
|
@ -155,7 +155,6 @@ export interface Interface {
|
|||
readonly aggregateID: string
|
||||
readonly after?: Cursor
|
||||
}) => Stream.Stream<CursorEvent>
|
||||
readonly sequence: (aggregateID: string) => Effect.Effect<number>
|
||||
readonly sync: (handler: Sync) => Effect.Effect<Unsubscribe>
|
||||
readonly listen: (listener: Listener) => Effect.Effect<Unsubscribe>
|
||||
readonly beforeCommit: (guard: CommitGuard) => Effect.Effect<void>
|
||||
|
|
@ -337,9 +336,6 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
yield* projector({ ...event, seq } as Payload)
|
||||
}
|
||||
if (commit) yield* commit(seq)
|
||||
const encoded = syncRegistry
|
||||
.get(versionedType(definition.type, sync.version))!
|
||||
.encode(event.data)
|
||||
yield* db
|
||||
.insert(EventSequenceTable)
|
||||
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
|
||||
|
|
@ -390,7 +386,10 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
const durable = registry.get(event.type)?.sync !== undefined
|
||||
if (!durable && options?.commit)
|
||||
return yield* Effect.die(
|
||||
new InvalidSyncEventError({ type: event.type, message: "Local commit hooks require a synchronized event" }),
|
||||
new InvalidSyncEventError({
|
||||
type: event.type,
|
||||
message: "Local commit hooks require a synchronized event",
|
||||
}),
|
||||
)
|
||||
if (durable) {
|
||||
const committed = yield* commitSyncEvent(event as Payload, undefined, options?.commit)
|
||||
|
|
@ -438,14 +437,17 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
(serviceLocation
|
||||
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
|
||||
: undefined)
|
||||
return yield* publishEvent({
|
||||
id: options?.id ?? ID.create(),
|
||||
...(options?.metadata ? { metadata: options.metadata } : {}),
|
||||
type: definition.type,
|
||||
...(definition.sync === undefined ? {} : { version: definition.sync.version }),
|
||||
...(location ? { location } : {}),
|
||||
data,
|
||||
} as Payload<D>, options)
|
||||
return yield* publishEvent(
|
||||
{
|
||||
id: options?.id ?? ID.create(),
|
||||
...(options?.metadata ? { metadata: options.metadata } : {}),
|
||||
type: definition.type,
|
||||
...(definition.sync === undefined ? {} : { version: definition.sync.version }),
|
||||
...(location ? { location } : {}),
|
||||
data,
|
||||
} as Payload<D>,
|
||||
options,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -534,14 +536,6 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
.pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
const sequence = (aggregateID: string) =>
|
||||
db
|
||||
.select({ seq: EventSequenceTable.seq })
|
||||
.from(EventSequenceTable)
|
||||
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
|
||||
.get()
|
||||
.pipe(Effect.orDie, Effect.map((row) => row?.seq ?? -1))
|
||||
|
||||
const subscribe = <D extends Definition>(definition: D): Stream.Stream<Payload<D>> =>
|
||||
Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe(
|
||||
Stream.map((event) => event as Payload<D>),
|
||||
|
|
@ -669,7 +663,6 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
subscribe,
|
||||
all: streamAll,
|
||||
aggregateEvents: streamEvents,
|
||||
sequence,
|
||||
sync,
|
||||
listen,
|
||||
beforeCommit,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ class File extends Schema.Class<File>("InstructionContext.File")({
|
|||
}) {}
|
||||
|
||||
const Files = Schema.Array(File)
|
||||
const key = SystemContext.Key.make("core/instructions")
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -25,7 +26,7 @@ export const layer = Layer.effectDiscard(
|
|||
|
||||
const source = (value: ReadonlyArray<File> | SystemContext.Unavailable) =>
|
||||
SystemContext.make({
|
||||
key: SystemContext.Key.make("core/instructions"),
|
||||
key,
|
||||
codec: Schema.toCodecJson(Files),
|
||||
load: Effect.succeed(value),
|
||||
baseline: render,
|
||||
|
|
@ -43,29 +44,37 @@ export const layer = Layer.effectDiscard(
|
|||
const files = yield* Effect.forEach(
|
||||
paths,
|
||||
(path) =>
|
||||
fs.readFileStringSafe(path).pipe(
|
||||
Effect.map((content) => (content === undefined ? undefined : new File({ path: AbsolutePath.make(path), content }))),
|
||||
),
|
||||
fs
|
||||
.readFileStringSafe(path)
|
||||
.pipe(
|
||||
Effect.map((content) =>
|
||||
content === undefined ? undefined : new File({ path: AbsolutePath.make(path), content }),
|
||||
),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
if (files.some((file, index) => file === undefined && discovered.has(paths[index]))) return SystemContext.unavailable
|
||||
if (files.some((file, index) => file === undefined && discovered.has(paths[index])))
|
||||
return SystemContext.unavailable
|
||||
return files.filter((file): file is File => file !== undefined)
|
||||
})
|
||||
|
||||
yield* registry.contribute({
|
||||
key: "core/instructions",
|
||||
key,
|
||||
load: observe().pipe(
|
||||
Effect.map((files) =>
|
||||
files === SystemContext.unavailable ? source(files) : files.length === 0 ? SystemContext.empty : source(files),
|
||||
files === SystemContext.unavailable
|
||||
? source(files)
|
||||
: files.length === 0
|
||||
? SystemContext.empty
|
||||
: source(files),
|
||||
),
|
||||
Effect.catch(() => Effect.succeed(source(SystemContext.unavailable))),
|
||||
Effect.catchDefect(() => Effect.succeed(source(SystemContext.unavailable))),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
function render(files: ReadonlyArray<File>) {
|
||||
return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,13 +7,51 @@ import { EventV2 } from "../event"
|
|||
import { SystemContext } from "../system-context"
|
||||
import { SystemContextRegistry } from "../system-context-registry"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionInput } from "./input"
|
||||
import { SessionMessageID } from "./message-id"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionContextEpochTable } from "./sql"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
export const prepare = Effect.fn("SessionContextEpoch.prepare")(function* (
|
||||
class RevisionMismatch extends Error {}
|
||||
|
||||
const retryRevisionMismatch = <A, E>(attempt: () => Effect.Effect<A, E>): Effect.Effect<A, E> =>
|
||||
attempt().pipe(
|
||||
Effect.catchDefect((defect) =>
|
||||
defect instanceof RevisionMismatch
|
||||
? Effect.yieldNow.pipe(Effect.andThen(retryRevisionMismatch(attempt)))
|
||||
: Effect.die(defect),
|
||||
),
|
||||
)
|
||||
|
||||
interface Prepared {
|
||||
readonly baseline: string
|
||||
readonly baselineSeq: number
|
||||
}
|
||||
|
||||
export function initialize(
|
||||
db: DatabaseService,
|
||||
context: SystemContextRegistry.Interface,
|
||||
sessionID: SessionSchema.ID,
|
||||
): Effect.Effect<Prepared | undefined, SystemContext.InitializationBlocked> {
|
||||
return retryRevisionMismatch(() => initializeOnce(db, context, sessionID)).pipe(
|
||||
Effect.withSpan("SessionContextEpoch.initialize"),
|
||||
)
|
||||
}
|
||||
|
||||
export function prepare(
|
||||
db: DatabaseService,
|
||||
events: EventV2.Interface,
|
||||
context: SystemContextRegistry.Interface,
|
||||
sessionID: SessionSchema.ID,
|
||||
): Effect.Effect<Prepared, SystemContext.InitializationBlocked> {
|
||||
return retryRevisionMismatch(() => prepareOnce(db, events, context, sessionID)).pipe(
|
||||
Effect.withSpan("SessionContextEpoch.prepare"),
|
||||
)
|
||||
}
|
||||
|
||||
const prepareOnce = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
events: EventV2.Interface,
|
||||
context: SystemContextRegistry.Interface,
|
||||
|
|
@ -22,17 +60,19 @@ export const prepare = Effect.fn("SessionContextEpoch.prepare")(function* (
|
|||
const [value, stored] = yield* Effect.all([context.load(), find(db, sessionID)], { concurrency: "unbounded" })
|
||||
if (!stored) {
|
||||
const generation = yield* SystemContext.initialize(value)
|
||||
const baselineSeq = yield* initialize(db, events, sessionID, generation)
|
||||
const baselineSeq = yield* insert(db, sessionID, generation)
|
||||
return { baseline: generation.baseline, baselineSeq }
|
||||
}
|
||||
|
||||
const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe(Effect.orDie)
|
||||
const result =
|
||||
stored.replacement_seq === null ? yield* SystemContext.reconcile(value, snapshot) : yield* SystemContext.replace(value, snapshot)
|
||||
stored.replacement_seq === null
|
||||
? yield* SystemContext.reconcile(value, snapshot)
|
||||
: yield* SystemContext.replace(value, snapshot)
|
||||
if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked")
|
||||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
||||
if (result._tag === "Replaced") {
|
||||
const replacementSeq = stored.replacement_seq ?? (yield* events.sequence(sessionID))
|
||||
if (result._tag === "ReplacementReady") {
|
||||
const replacementSeq = stored.replacement_seq ?? (yield* SessionInput.latestSeq(db, sessionID))
|
||||
yield* replace(db, sessionID, stored.revision, replacementSeq, result.generation)
|
||||
return { baseline: result.generation.baseline, baselineSeq: replacementSeq }
|
||||
}
|
||||
|
|
@ -45,6 +85,28 @@ export const prepare = Effect.fn("SessionContextEpoch.prepare")(function* (
|
|||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
||||
})
|
||||
|
||||
const initializeOnce = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
context: SystemContextRegistry.Interface,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
if (yield* exists(db, sessionID)) return
|
||||
const generation = yield* context.load().pipe(Effect.flatMap(SystemContext.initialize))
|
||||
const baselineSeq = yield* insert(db, sessionID, generation)
|
||||
return { baseline: generation.baseline, baselineSeq }
|
||||
})
|
||||
|
||||
const exists = Effect.fn("SessionContextEpoch.exists")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return (
|
||||
(yield* db
|
||||
.select({ sessionID: SessionContextEpochTable.session_id })
|
||||
.from(SessionContextEpochTable)
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)) !== undefined
|
||||
)
|
||||
})
|
||||
|
||||
const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return yield* db
|
||||
.select()
|
||||
|
|
@ -73,9 +135,8 @@ export const requestReplacement = Effect.fn("SessionContextEpoch.requestReplacem
|
|||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const initialize = Effect.fnUntraced(function* (
|
||||
const insert = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
events: EventV2.Interface,
|
||||
sessionID: SessionSchema.ID,
|
||||
generation: SystemContext.Generation,
|
||||
) {
|
||||
|
|
@ -83,7 +144,7 @@ const initialize = Effect.fnUntraced(function* (
|
|||
.transaction(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const baselineSeq = yield* events.sequence(sessionID)
|
||||
const baselineSeq = yield* SessionInput.latestSeq(db, sessionID)
|
||||
yield* db
|
||||
.insert(SessionContextEpochTable)
|
||||
.values({
|
||||
|
|
@ -93,8 +154,13 @@ const initialize = Effect.fnUntraced(function* (
|
|||
baseline_seq: baselineSeq,
|
||||
revision: 0,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
.onConflictDoNothing()
|
||||
.returning({ sessionID: SessionContextEpochTable.session_id })
|
||||
.get()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.flatMap((inserted) => (inserted ? Effect.void : Effect.die(new RevisionMismatch()))),
|
||||
)
|
||||
return baselineSeq
|
||||
}),
|
||||
{ behavior: "immediate" },
|
||||
|
|
@ -109,33 +175,22 @@ const replace = Effect.fnUntraced(function* (
|
|||
baselineSeq: number,
|
||||
generation: SystemContext.Generation,
|
||||
) {
|
||||
yield* db
|
||||
.transaction(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const updated = yield* db
|
||||
.update(SessionContextEpochTable)
|
||||
.set({
|
||||
baseline: generation.baseline,
|
||||
snapshot: generation.snapshot,
|
||||
baseline_seq: baselineSeq,
|
||||
replacement_seq: null,
|
||||
revision: expectedRevision + 1,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(SessionContextEpochTable.session_id, sessionID),
|
||||
eq(SessionContextEpochTable.revision, expectedRevision),
|
||||
),
|
||||
)
|
||||
.returning({ revision: SessionContextEpochTable.revision })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!updated) return yield* Effect.die("Session context epoch revision mismatch")
|
||||
}),
|
||||
{ behavior: "immediate" },
|
||||
const updated = yield* db
|
||||
.update(SessionContextEpochTable)
|
||||
.set({
|
||||
baseline: generation.baseline,
|
||||
snapshot: generation.snapshot,
|
||||
baseline_seq: baselineSeq,
|
||||
replacement_seq: null,
|
||||
revision: expectedRevision + 1,
|
||||
})
|
||||
.where(
|
||||
and(eq(SessionContextEpochTable.session_id, sessionID), eq(SessionContextEpochTable.revision, expectedRevision)),
|
||||
)
|
||||
.returning({ revision: SessionContextEpochTable.revision })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!updated) return yield* Effect.die(new RevisionMismatch())
|
||||
})
|
||||
|
||||
const advance = Effect.fnUntraced(function* (
|
||||
|
|
@ -157,5 +212,5 @@ const advance = Effect.fnUntraced(function* (
|
|||
.returning({ revision: SessionContextEpochTable.revision })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!updated) return yield* Effect.die("Session context epoch revision mismatch")
|
||||
if (!updated) return yield* Effect.die(new RevisionMismatch())
|
||||
})
|
||||
|
|
|
|||
|
|
@ -75,10 +75,7 @@ export const load = Effect.fn("SessionContext.load")(function* (db: DatabaseServ
|
|||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return yield* Effect.forEach(
|
||||
yield* messageRows(db, sessionID, compaction, epoch?.baselineSeq),
|
||||
decodeMessageRow,
|
||||
)
|
||||
return yield* Effect.forEach(yield* messageRows(db, sessionID, compaction, epoch?.baselineSeq), decodeMessageRow)
|
||||
})
|
||||
|
||||
export const loadForRunner = Effect.fn("SessionContext.loadForRunner")(function* (
|
||||
|
|
@ -86,7 +83,10 @@ export const loadForRunner = Effect.fn("SessionContext.loadForRunner")(function*
|
|||
sessionID: SessionSchema.ID,
|
||||
baselineSeq: number,
|
||||
) {
|
||||
return yield* Effect.forEach(yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID), baselineSeq), decodeMessageRow)
|
||||
return yield* Effect.forEach(
|
||||
yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID), baselineSeq),
|
||||
decodeMessageRow,
|
||||
)
|
||||
})
|
||||
|
||||
export * as SessionContext from "./context"
|
||||
|
|
|
|||
|
|
@ -176,7 +176,16 @@ export class Compaction extends Schema.Class<Compaction>("Session.Message.Compac
|
|||
...Base,
|
||||
}) {}
|
||||
|
||||
export const Message = Schema.Union([AgentSwitched, ModelSwitched, User, Synthetic, System, Shell, Assistant, Compaction])
|
||||
export const Message = Schema.Union([
|
||||
AgentSwitched,
|
||||
ModelSwitched,
|
||||
User,
|
||||
Synthetic,
|
||||
System,
|
||||
Shell,
|
||||
Assistant,
|
||||
Compaction,
|
||||
])
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
.annotate({ identifier: "Session.Message" })
|
||||
|
||||
|
|
|
|||
|
|
@ -422,7 +422,9 @@ export const layer = Layer.effectDiscard(
|
|||
)
|
||||
yield* events.project(SessionEvent.ContextUpdated, (event) => {
|
||||
if (!event.replay || event.seq === undefined) return run(db, event)
|
||||
return run(db, event).pipe(Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)))
|
||||
return run(db, event).pipe(
|
||||
Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)),
|
||||
)
|
||||
})
|
||||
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Context, Effect, Schema } from "effect"
|
|||
import { SessionSchema } from "../schema"
|
||||
import type { MessageDecodeError } from "../error"
|
||||
import { SessionRunnerModel } from "./model"
|
||||
import type { SystemContext } from "../../system-context"
|
||||
|
||||
export class StepLimitExceededError extends Schema.TaggedErrorClass<StepLimitExceededError>()(
|
||||
"SessionRunner.StepLimitExceededError",
|
||||
|
|
@ -14,7 +15,12 @@ export class StepLimitExceededError extends Schema.TaggedErrorClass<StepLimitExc
|
|||
},
|
||||
) {}
|
||||
|
||||
export type RunError = LLMError | SessionRunnerModel.Error | MessageDecodeError | StepLimitExceededError
|
||||
export type RunError =
|
||||
| LLMError
|
||||
| SessionRunnerModel.Error
|
||||
| MessageDecodeError
|
||||
| StepLimitExceededError
|
||||
| SystemContext.InitializationBlocked
|
||||
|
||||
/** Runs one local continuation from already-recorded Session history. */
|
||||
export interface Interface {
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ export const layer = Layer.effect(
|
|||
promotion: "steer" | "queue" | undefined,
|
||||
) {
|
||||
const session = yield* getSession(sessionID)
|
||||
const initialized = yield* SessionContextEpoch.initialize(db, systemContext, session.id)
|
||||
const model = yield* models.resolve(session)
|
||||
const toolFibers = yield* FiberSet.make<void, never>()
|
||||
let needsContinuation = false
|
||||
|
|
@ -150,7 +151,7 @@ export const layer = Layer.effect(
|
|||
yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
|
||||
}
|
||||
}
|
||||
const system = yield* SessionContextEpoch.prepare(db, events, systemContext, session.id)
|
||||
const system = initialized ?? (yield* SessionContextEpoch.prepare(db, events, systemContext, session.id))
|
||||
const context = yield* getRunnerContext(session.id, system.baselineSeq)
|
||||
const request = LLM.request({
|
||||
model,
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ const builtIns = Layer.effectDiscard(
|
|||
}),
|
||||
])
|
||||
|
||||
yield* registry.contribute({ key: "core/builtins", load: Effect.succeed(context) })
|
||||
yield* registry.contribute({ key: SystemContext.Key.make("core/builtins"), load: Effect.succeed(context) })
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Context, Effect, Layer, Ref, Scope } from "effect"
|
|||
import { SystemContext } from "./system-context"
|
||||
|
||||
export interface Contribution {
|
||||
readonly key: string
|
||||
readonly key: SystemContext.Key
|
||||
readonly load: Effect.Effect<SystemContext.SystemContext>
|
||||
}
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ export const layer = Layer.effect(
|
|||
)
|
||||
}),
|
||||
load: Effect.fn("SystemContextRegistry.load")(function* () {
|
||||
const current = (yield* Ref.get(contributions)).toSorted((a, b) => a.key.localeCompare(b.key))
|
||||
const current = (yield* Ref.get(contributions)).toSorted((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
|
||||
return SystemContext.combine(
|
||||
yield* Effect.forEach(current, (contribution) => contribution.load, { concurrency: "unbounded" }),
|
||||
)
|
||||
|
|
@ -44,5 +44,3 @@ export const layer = Layer.effect(
|
|||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
|
|
|||
|
|
@ -67,8 +67,8 @@ export interface Updated {
|
|||
readonly snapshot: Snapshot
|
||||
}
|
||||
|
||||
export interface Replaced {
|
||||
readonly _tag: "Replaced"
|
||||
export interface ReplacementReady {
|
||||
readonly _tag: "ReplacementReady"
|
||||
readonly generation: Generation
|
||||
}
|
||||
|
||||
|
|
@ -76,9 +76,14 @@ export interface ReplacementBlocked {
|
|||
readonly _tag: "ReplacementBlocked"
|
||||
}
|
||||
|
||||
export type ReplacementResult = Replaced | ReplacementBlocked
|
||||
export type ReplacementResult = ReplacementReady | ReplacementBlocked
|
||||
export type ReconcileResult = { readonly _tag: "Unchanged" } | Updated | ReplacementResult
|
||||
|
||||
export class InitializationBlocked extends Schema.TaggedErrorClass<InitializationBlocked>()(
|
||||
"SystemContext.InitializationBlocked",
|
||||
{ keys: Schema.Array(Key) },
|
||||
) {}
|
||||
|
||||
export class DuplicateKeyError extends Schema.TaggedErrorClass<DuplicateKeyError>()("SystemContext.DuplicateKeyError", {
|
||||
key: Key,
|
||||
}) {
|
||||
|
|
@ -186,8 +191,14 @@ const observe = (value: SystemContext) =>
|
|||
)
|
||||
|
||||
/** Creates the immutable baseline and durable snapshot for a new generation. */
|
||||
export function initialize(value: SystemContext): Effect.Effect<Generation> {
|
||||
return observe(value).pipe(Effect.map(initializeObservation))
|
||||
export function initialize(value: SystemContext): Effect.Effect<Generation, InitializationBlocked> {
|
||||
return observe(value).pipe(
|
||||
Effect.flatMap((entries) => {
|
||||
const unavailable = entries.flatMap((entry) => (entry._tag === "Unavailable" ? [entry.key] : []))
|
||||
if (unavailable.length > 0) return new InitializationBlocked({ keys: unavailable })
|
||||
return Effect.succeed(initializeObservation(entries))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function initializeObservation(entries: ReadonlyArray<Entry>): Generation {
|
||||
|
|
@ -272,7 +283,7 @@ export function replace(value: SystemContext, previous: Snapshot): Effect.Effect
|
|||
function replaceObservation(entries: ReadonlyArray<Entry>, previous: Snapshot): ReplacementResult {
|
||||
if (entries.some((entry) => entry._tag === "Unavailable" && getSnapshot(previous, entry.key) !== undefined))
|
||||
return { _tag: "ReplacementBlocked" }
|
||||
return { _tag: "Replaced", generation: initializeObservation(entries) }
|
||||
return { _tag: "ReplacementReady", generation: initializeObservation(entries) }
|
||||
}
|
||||
|
||||
function context(sources: ReadonlyArray<PackedSource>): SystemContext {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import { fileURLToPath } from "url"
|
||||
import path from "path"
|
||||
|
|
@ -7,6 +7,7 @@ import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
|||
import { Effect, Layer } from "effect"
|
||||
import { eq, inArray, sql } from "drizzle-orm"
|
||||
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
|
||||
import { migrations } from "@opencode-ai/core/database/migration.gen"
|
||||
import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage"
|
||||
import normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths"
|
||||
import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/migration/20260603040000_session_message_projection_order"
|
||||
|
|
@ -17,43 +18,45 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
|||
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata"
|
||||
import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
|
||||
const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
|
||||
Effect.runPromise(
|
||||
effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Effect.scoped),
|
||||
)
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
|
||||
const it = testEffect(SqliteClient.layer({ filename: ":memory:", disableWAL: true }))
|
||||
|
||||
describe("DatabaseMigration", () => {
|
||||
test("serializes concurrent embedded initialization for one database path", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filename = path.join(tmp.path, "embedded.sqlite")
|
||||
const layers = [Database.layerFromPath(filename), Database.layerFromPath(filename)]
|
||||
it.effect("serializes concurrent embedded initialization for one database path", () =>
|
||||
Effect.promise(async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filename = path.join(tmp.path, "embedded.sqlite")
|
||||
const layers = [Database.layerFromPath(filename), Database.layerFromPath(filename)]
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.all(
|
||||
layers.map((layer) => Effect.scoped(Layer.build(layer))),
|
||||
{ concurrency: "unbounded" },
|
||||
),
|
||||
)
|
||||
})
|
||||
await Effect.runPromise(
|
||||
Effect.all(
|
||||
layers.map((layer) => Effect.scoped(Layer.build(layer))),
|
||||
{ concurrency: "unbounded" },
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
if (process.platform === "linux") {
|
||||
test("declared schema has no ungenerated migrations", async () => {
|
||||
const result = await $`bun ${fileURLToPath(new URL("../script/migration.ts", import.meta.url))} --check`
|
||||
.quiet()
|
||||
.nothrow()
|
||||
expect(result.exitCode, result.stderr.toString()).toBe(0)
|
||||
expect(result.stdout.toString()).toContain("No schema changes, nothing to migrate")
|
||||
}, 30_000)
|
||||
it.effect(
|
||||
"declared schema has no ungenerated migrations",
|
||||
() =>
|
||||
Effect.promise(async () => {
|
||||
const result = await $`bun ${fileURLToPath(new URL("../script/migration.ts", import.meta.url))} --check`
|
||||
.quiet()
|
||||
.nothrow()
|
||||
expect(result.exitCode, result.stderr.toString()).toBe(0)
|
||||
expect(result.stdout.toString()).toContain("No schema changes, nothing to migrate")
|
||||
}),
|
||||
30_000,
|
||||
)
|
||||
}
|
||||
|
||||
test("applies tracked migrations to an empty database", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
it.effect("applies tracked migrations to an empty database", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* DatabaseMigration.apply(db)
|
||||
|
||||
|
|
@ -66,10 +69,7 @@ describe("DatabaseMigration", () => {
|
|||
expect(
|
||||
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_context_epoch'`),
|
||||
).toEqual({ name: "session_context_epoch" })
|
||||
expect(
|
||||
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_context_message'`),
|
||||
).toEqual({ name: "session_context_message" })
|
||||
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 31 })
|
||||
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length })
|
||||
expect(
|
||||
yield* db.all(
|
||||
sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_input_session_admitted_seq_idx', 'session_input_session_promoted_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`,
|
||||
|
|
@ -84,13 +84,11 @@ describe("DatabaseMigration", () => {
|
|||
{ name: "session_message_session_time_created_id_idx" },
|
||||
{ name: "session_message_session_type_seq_idx" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
test("resets beta history and rebuilds event-sourced Session input storage", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
it.effect("resets beta history and rebuilds event-sourced Session input storage", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, workspace_id text)`)
|
||||
yield* db.run(sql`CREATE TABLE workspace (id text PRIMARY KEY)`)
|
||||
|
|
@ -160,13 +158,11 @@ describe("DatabaseMigration", () => {
|
|||
expect.objectContaining({ name: "session_input_session_promoted_seq_idx", unique: 1 }),
|
||||
expect.objectContaining({ name: "session_input_session_admitted_seq_idx", unique: 1 }),
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
test("resets incompatible projected Session messages before adding sequence order", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
it.effect("resets incompatible projected Session messages before adding sequence order", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
|
||||
yield* db.run(
|
||||
|
|
@ -215,13 +211,11 @@ describe("DatabaseMigration", () => {
|
|||
sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('fresh_projection', 'session', 'user', 7, 2, 2, '{}')`,
|
||||
)
|
||||
expect(yield* db.get(sql`SELECT id, seq FROM session_message`)).toEqual({ id: "fresh_projection", seq: 7 })
|
||||
}),
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
test("runs session usage backfill in order with schema changes", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
it.effect("runs session usage backfill in order with schema changes", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, time_updated integer NOT NULL)`)
|
||||
yield* db.run(sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, data text NOT NULL)`)
|
||||
|
|
@ -244,13 +238,11 @@ describe("DatabaseMigration", () => {
|
|||
tokens_cache_read: 5,
|
||||
tokens_cache_write: 6,
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
test("normalizes Windows storage paths and leaves POSIX paths untouched", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
it.effect("normalizes Windows storage paths and leaves POSIX paths untouched", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE project (id text PRIMARY KEY, worktree text NOT NULL, sandboxes text NOT NULL)`)
|
||||
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, directory text NOT NULL, path text)`)
|
||||
|
|
@ -295,14 +287,12 @@ describe("DatabaseMigration", () => {
|
|||
directory: "/home/me/we\\ird",
|
||||
path: "src\\weird",
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
test("maps native Windows paths through database columns", async () => {
|
||||
if (process.platform !== "win32") return
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
it.effect("maps native Windows paths through database columns", () => {
|
||||
if (process.platform !== "win32") return Effect.void
|
||||
return Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* DatabaseMigration.apply(db)
|
||||
const projectID = ProjectV2.ID.make("codec_project")
|
||||
|
|
@ -405,13 +395,11 @@ describe("DatabaseMigration", () => {
|
|||
expect(() =>
|
||||
Effect.runSync(db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get()),
|
||||
).toThrow()
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test("imports existing drizzle migration state", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
it.effect("imports existing drizzle migration state", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(
|
||||
sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`,
|
||||
|
|
@ -424,13 +412,11 @@ describe("DatabaseMigration", () => {
|
|||
yield* DatabaseMigration.applyOnly(db, [])
|
||||
|
||||
expect(yield* db.get(sql`SELECT id FROM migration`)).toEqual({ id: "20260127222353_familiar_lady_ursula" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
test("does not replay a migrated session metadata column", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
it.effect("does not replay a migrated session metadata column", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, metadata text)`)
|
||||
yield* db.run(
|
||||
|
|
@ -444,13 +430,11 @@ describe("DatabaseMigration", () => {
|
|||
yield* DatabaseMigration.applyOnly(db, [sessionMetadataMigration])
|
||||
|
||||
expect(yield* db.all(sql`SELECT id FROM migration`)).toEqual([{ id: "20260511173437_session-metadata" }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
test("accepts the temporary replacement session metadata migration id", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
it.effect("accepts the temporary replacement session metadata migration id", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, metadata text)`)
|
||||
yield* db.run(sql`CREATE TABLE migration (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`)
|
||||
|
|
@ -462,13 +446,11 @@ describe("DatabaseMigration", () => {
|
|||
{ id: "20260511173437_session-metadata" },
|
||||
{ id: "20260530232709_lovely_romulus" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
test("skips drizzle import when migration table already has state", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
it.effect("skips drizzle import when migration table already has state", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE migration (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`)
|
||||
yield* db.run(sql`INSERT INTO migration (id, time_completed) VALUES ('existing', 1)`)
|
||||
|
|
@ -483,7 +465,6 @@ describe("DatabaseMigration", () => {
|
|||
yield* DatabaseMigration.applyOnly(db, [])
|
||||
|
||||
expect(yield* db.all(sql`SELECT id FROM migration ORDER BY id`)).toEqual([{ id: "existing" }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -194,11 +194,12 @@ describe("EventV2", () => {
|
|||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const received = new Array<string>()
|
||||
const aggregateID = EventV2.ID.create()
|
||||
yield* events.project(SyncMessage, () => Effect.sync(() => received.push("projector")))
|
||||
|
||||
yield* events.publish(
|
||||
SyncMessage,
|
||||
{ id: "one", text: "hello" },
|
||||
{ id: aggregateID, text: "hello" },
|
||||
{ commit: (seq) => Effect.sync(() => received.push(`commit:${seq}`)) },
|
||||
)
|
||||
|
||||
|
|
@ -224,7 +225,9 @@ describe("EventV2", () => {
|
|||
expect(String(exit)).toContain("commit failed")
|
||||
expect(yield* db.all("SELECT value FROM event_commit_probe")).toEqual([])
|
||||
expect(yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).all()).toEqual([])
|
||||
expect(yield* db.select().from(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).all()).toEqual([])
|
||||
expect(
|
||||
yield* db.select().from(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).all(),
|
||||
).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -117,7 +117,9 @@ describe("InstructionContext", () => {
|
|||
const failingFS = Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.pipe(
|
||||
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) })),
|
||||
Effect.map((fs) =>
|
||||
FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) }),
|
||||
),
|
||||
),
|
||||
).pipe(Layer.provide(FSUtil.defaultLayer))
|
||||
const context = yield* SystemContextRegistry.Service.pipe(
|
||||
|
|
@ -126,10 +128,7 @@ describe("InstructionContext", () => {
|
|||
Effect.provide(failingFS),
|
||||
Effect.provide(Global.layerWith({ config: "/global" })),
|
||||
Effect.provide(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
|
||||
),
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -165,10 +164,7 @@ describe("InstructionContext", () => {
|
|||
Effect.provide(racingFS),
|
||||
Effect.provide(Global.layerWith({ config: "/global" })),
|
||||
Effect.provide(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
|
||||
),
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -16,375 +16,388 @@ const id = (value: string) => SessionMessage.ID.make(`msg_${value}`)
|
|||
const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route })
|
||||
|
||||
describe("toLLMMessages", () => {
|
||||
it.effect("maps every top-level V2 Session message type", () => Effect.sync(() => {
|
||||
const file = new FileAttachment({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" })
|
||||
const reference = new ReferenceAttachment({ name: "docs", kind: "local", uri: "file:///docs" })
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
new SessionMessage.AgentSwitched({
|
||||
id: id("agent"),
|
||||
type: "agent-switched",
|
||||
agent: "build",
|
||||
time: { created },
|
||||
}),
|
||||
new SessionMessage.ModelSwitched({
|
||||
id: id("model"),
|
||||
type: "model-switched",
|
||||
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||
time: { created },
|
||||
}),
|
||||
new SessionMessage.User({
|
||||
id: id("user"),
|
||||
type: "user",
|
||||
text: "Inspect this image",
|
||||
files: [file],
|
||||
agents: [new AgentAttachment({ name: "build" })],
|
||||
references: [reference],
|
||||
time: { created },
|
||||
}),
|
||||
new SessionMessage.Synthetic({
|
||||
id: id("synthetic"),
|
||||
type: "synthetic",
|
||||
sessionID: SessionV2.ID.make("ses_translate"),
|
||||
text: "Synthetic context",
|
||||
time: { created },
|
||||
}),
|
||||
new SessionMessage.Shell({
|
||||
id: id("shell"),
|
||||
type: "shell",
|
||||
callID: "shell-1",
|
||||
command: "pwd",
|
||||
output: "/project",
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
new SessionMessage.Compaction({
|
||||
id: id("compaction"),
|
||||
type: "compaction",
|
||||
reason: "auto",
|
||||
summary: "Earlier work",
|
||||
time: { created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
|
||||
expect(messages.map((message) => message.role)).toEqual(["user", "user", "user", "user"])
|
||||
expect(messages[0]).toEqual(
|
||||
Message.make({
|
||||
id: id("user"),
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Inspect this image" },
|
||||
{ type: "media", mediaType: "image/png", data: "data:image/png;base64,aGVsbG8=", filename: "hello.png" },
|
||||
],
|
||||
metadata: { agents: [{ name: "build" }], references: [reference] },
|
||||
}),
|
||||
)
|
||||
expect(messages.slice(1).map((message) => message.content)).toEqual([
|
||||
[{ type: "text", text: "Synthetic context" }],
|
||||
[{ type: "text", text: "Shell command: pwd\n\n/project" }],
|
||||
[{ type: "text", text: "Summary of earlier conversation:\nEarlier work" }],
|
||||
])
|
||||
}))
|
||||
|
||||
it.effect("maps durable Session system messages into chronological system messages", () => Effect.sync(() => {
|
||||
expect(
|
||||
toLLMMessages(
|
||||
it.effect("maps every top-level V2 Session message type", () =>
|
||||
Effect.sync(() => {
|
||||
const file = new FileAttachment({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" })
|
||||
const reference = new ReferenceAttachment({ name: "docs", kind: "local", uri: "file:///docs" })
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
new SessionMessage.System({ id: id("system"), type: "system", text: "Updated context\n\nOther context", time: { created } }),
|
||||
new SessionMessage.AgentSwitched({
|
||||
id: id("agent"),
|
||||
type: "agent-switched",
|
||||
agent: "build",
|
||||
time: { created },
|
||||
}),
|
||||
new SessionMessage.ModelSwitched({
|
||||
id: id("model"),
|
||||
type: "model-switched",
|
||||
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||
time: { created },
|
||||
}),
|
||||
new SessionMessage.User({
|
||||
id: id("user"),
|
||||
type: "user",
|
||||
text: "Inspect this image",
|
||||
files: [file],
|
||||
agents: [new AgentAttachment({ name: "build" })],
|
||||
references: [reference],
|
||||
time: { created },
|
||||
}),
|
||||
new SessionMessage.Synthetic({
|
||||
id: id("synthetic"),
|
||||
type: "synthetic",
|
||||
sessionID: SessionV2.ID.make("ses_translate"),
|
||||
text: "Synthetic context",
|
||||
time: { created },
|
||||
}),
|
||||
new SessionMessage.Shell({
|
||||
id: id("shell"),
|
||||
type: "shell",
|
||||
callID: "shell-1",
|
||||
command: "pwd",
|
||||
output: "/project",
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
new SessionMessage.Compaction({
|
||||
id: id("compaction"),
|
||||
type: "compaction",
|
||||
reason: "auto",
|
||||
summary: "Earlier work",
|
||||
time: { created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
),
|
||||
).toEqual([
|
||||
Message.system("Updated context\n\nOther context"),
|
||||
])
|
||||
}))
|
||||
)
|
||||
|
||||
it.effect("expands assistant tool calls and settled outcomes into canonical tool messages", () => Effect.sync(() => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
new SessionMessage.Assistant({
|
||||
id: id("assistant"),
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||
expect(messages.map((message) => message.role)).toEqual(["user", "user", "user", "user"])
|
||||
expect(messages[0]).toEqual(
|
||||
Message.make({
|
||||
id: id("user"),
|
||||
role: "user",
|
||||
content: [
|
||||
new SessionMessage.AssistantText({ type: "text", id: "text-1", text: "Checking" }),
|
||||
new SessionMessage.AssistantReasoning({
|
||||
type: "reasoning",
|
||||
id: "reasoning-1",
|
||||
text: "Think",
|
||||
providerMetadata: { anthropic: { signature: "sig_1" } },
|
||||
}),
|
||||
new SessionMessage.AssistantTool({
|
||||
type: "tool",
|
||||
id: "pending",
|
||||
name: "read",
|
||||
state: new SessionMessage.ToolStatePending({ status: "pending", input: '{"path":"README.md"}' }),
|
||||
{ type: "text", text: "Inspect this image" },
|
||||
{ type: "media", mediaType: "image/png", data: "data:image/png;base64,aGVsbG8=", filename: "hello.png" },
|
||||
],
|
||||
metadata: { agents: [{ name: "build" }], references: [reference] },
|
||||
}),
|
||||
)
|
||||
expect(messages.slice(1).map((message) => message.content)).toEqual([
|
||||
[{ type: "text", text: "Synthetic context" }],
|
||||
[{ type: "text", text: "Shell command: pwd\n\n/project" }],
|
||||
[{ type: "text", text: "Summary of earlier conversation:\nEarlier work" }],
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps durable Session system messages into chronological system messages", () =>
|
||||
Effect.sync(() => {
|
||||
expect(
|
||||
toLLMMessages(
|
||||
[
|
||||
new SessionMessage.System({
|
||||
id: id("system"),
|
||||
type: "system",
|
||||
text: "Updated context\n\nOther context",
|
||||
time: { created },
|
||||
}),
|
||||
new SessionMessage.AssistantTool({
|
||||
type: "tool",
|
||||
id: "running",
|
||||
name: "read",
|
||||
state: new SessionMessage.ToolStateRunning({
|
||||
status: "running",
|
||||
input: { path: "README.md" },
|
||||
content: [],
|
||||
structured: {},
|
||||
}),
|
||||
time: { created },
|
||||
}),
|
||||
new SessionMessage.AssistantTool({
|
||||
type: "tool",
|
||||
id: "completed",
|
||||
name: "read",
|
||||
state: new SessionMessage.ToolStateCompleted({
|
||||
status: "completed",
|
||||
input: { path: "README.md" },
|
||||
content: [
|
||||
new ToolOutput.TextContent({ type: "text", text: "Hello" }),
|
||||
new ToolOutput.FileContent({
|
||||
type: "file",
|
||||
source: { type: "data", data: "aGVsbG8=" },
|
||||
mime: "image/png",
|
||||
name: "hello.png",
|
||||
}),
|
||||
],
|
||||
structured: {},
|
||||
}),
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
new SessionMessage.AssistantTool({
|
||||
type: "tool",
|
||||
id: "hosted",
|
||||
name: "web_search",
|
||||
provider: {
|
||||
executed: true,
|
||||
metadata: { fake: { continuation: "hosted-call" } },
|
||||
resultMetadata: { fake: { continuation: "hosted-result" } },
|
||||
},
|
||||
state: new SessionMessage.ToolStateCompleted({
|
||||
status: "completed",
|
||||
input: { query: "Effect" },
|
||||
content: [new ToolOutput.TextContent({ type: "text", text: "Found it" })],
|
||||
structured: {},
|
||||
}),
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
new SessionMessage.AssistantTool({
|
||||
type: "tool",
|
||||
id: "hosted-failed",
|
||||
name: "write",
|
||||
provider: { executed: true, metadata: { fake: { continuation: "failed" } } },
|
||||
state: new SessionMessage.ToolStateError({
|
||||
status: "error",
|
||||
input: { path: "README.md" },
|
||||
content: [],
|
||||
structured: {},
|
||||
error: { type: "unknown", message: "Denied" },
|
||||
}),
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
],
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
model,
|
||||
),
|
||||
).toEqual([Message.system("Updated context\n\nOther context")])
|
||||
}),
|
||||
)
|
||||
|
||||
expect(messages.map((message) => message.role)).toEqual(["assistant", "tool"])
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "Checking" },
|
||||
{ type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "sig_1" } } },
|
||||
{ type: "tool-call", id: "pending", name: "read", input: { path: "README.md" } },
|
||||
{ type: "tool-call", id: "running", name: "read", input: { path: "README.md" } },
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "completed",
|
||||
name: "read",
|
||||
input: { path: "README.md" },
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "hosted",
|
||||
name: "web_search",
|
||||
input: { query: "Effect" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { fake: { continuation: "hosted-call" } },
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "hosted",
|
||||
name: "web_search",
|
||||
providerExecuted: true,
|
||||
providerMetadata: { fake: { continuation: "hosted-result" } },
|
||||
result: { type: "text", value: "Found it" },
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "hosted-failed",
|
||||
name: "write",
|
||||
input: { path: "README.md" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { fake: { continuation: "failed" } },
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "hosted-failed",
|
||||
name: "write",
|
||||
providerExecuted: true,
|
||||
providerMetadata: { fake: { continuation: "failed" } },
|
||||
result: {
|
||||
type: "error",
|
||||
value: { error: { type: "unknown", message: "Denied" }, content: [], structured: {} },
|
||||
it.effect("expands assistant tool calls and settled outcomes into canonical tool messages", () =>
|
||||
Effect.sync(() => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
new SessionMessage.Assistant({
|
||||
id: id("assistant"),
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||
content: [
|
||||
new SessionMessage.AssistantText({ type: "text", id: "text-1", text: "Checking" }),
|
||||
new SessionMessage.AssistantReasoning({
|
||||
type: "reasoning",
|
||||
id: "reasoning-1",
|
||||
text: "Think",
|
||||
providerMetadata: { anthropic: { signature: "sig_1" } },
|
||||
}),
|
||||
new SessionMessage.AssistantTool({
|
||||
type: "tool",
|
||||
id: "pending",
|
||||
name: "read",
|
||||
state: new SessionMessage.ToolStatePending({ status: "pending", input: '{"path":"README.md"}' }),
|
||||
time: { created },
|
||||
}),
|
||||
new SessionMessage.AssistantTool({
|
||||
type: "tool",
|
||||
id: "running",
|
||||
name: "read",
|
||||
state: new SessionMessage.ToolStateRunning({
|
||||
status: "running",
|
||||
input: { path: "README.md" },
|
||||
content: [],
|
||||
structured: {},
|
||||
}),
|
||||
time: { created },
|
||||
}),
|
||||
new SessionMessage.AssistantTool({
|
||||
type: "tool",
|
||||
id: "completed",
|
||||
name: "read",
|
||||
state: new SessionMessage.ToolStateCompleted({
|
||||
status: "completed",
|
||||
input: { path: "README.md" },
|
||||
content: [
|
||||
new ToolOutput.TextContent({ type: "text", text: "Hello" }),
|
||||
new ToolOutput.FileContent({
|
||||
type: "file",
|
||||
source: { type: "data", data: "aGVsbG8=" },
|
||||
mime: "image/png",
|
||||
name: "hello.png",
|
||||
}),
|
||||
],
|
||||
structured: {},
|
||||
}),
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
new SessionMessage.AssistantTool({
|
||||
type: "tool",
|
||||
id: "hosted",
|
||||
name: "web_search",
|
||||
provider: {
|
||||
executed: true,
|
||||
metadata: { fake: { continuation: "hosted-call" } },
|
||||
resultMetadata: { fake: { continuation: "hosted-result" } },
|
||||
},
|
||||
state: new SessionMessage.ToolStateCompleted({
|
||||
status: "completed",
|
||||
input: { query: "Effect" },
|
||||
content: [new ToolOutput.TextContent({ type: "text", text: "Found it" })],
|
||||
structured: {},
|
||||
}),
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
new SessionMessage.AssistantTool({
|
||||
type: "tool",
|
||||
id: "hosted-failed",
|
||||
name: "write",
|
||||
provider: { executed: true, metadata: { fake: { continuation: "failed" } } },
|
||||
state: new SessionMessage.ToolStateError({
|
||||
status: "error",
|
||||
input: { path: "README.md" },
|
||||
content: [],
|
||||
structured: {},
|
||||
error: { type: "unknown", message: "Denied" },
|
||||
}),
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
],
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
|
||||
expect(messages.map((message) => message.role)).toEqual(["assistant", "tool"])
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "Checking" },
|
||||
{ type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "sig_1" } } },
|
||||
{ type: "tool-call", id: "pending", name: "read", input: { path: "README.md" } },
|
||||
{ type: "tool-call", id: "running", name: "read", input: { path: "README.md" } },
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "completed",
|
||||
name: "read",
|
||||
input: { path: "README.md" },
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(messages[1]?.content).toEqual([
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "completed",
|
||||
name: "read",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "Hello" },
|
||||
{ type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "hello.png" },
|
||||
],
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "hosted",
|
||||
name: "web_search",
|
||||
input: { query: "Effect" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { fake: { continuation: "hosted-call" } },
|
||||
},
|
||||
},
|
||||
])
|
||||
}))
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "hosted",
|
||||
name: "web_search",
|
||||
providerExecuted: true,
|
||||
providerMetadata: { fake: { continuation: "hosted-result" } },
|
||||
result: { type: "text", value: "Found it" },
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "hosted-failed",
|
||||
name: "write",
|
||||
input: { path: "README.md" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { fake: { continuation: "failed" } },
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "hosted-failed",
|
||||
name: "write",
|
||||
providerExecuted: true,
|
||||
providerMetadata: { fake: { continuation: "failed" } },
|
||||
result: {
|
||||
type: "error",
|
||||
value: { error: { type: "unknown", message: "Denied" }, content: [], structured: {} },
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(messages[1]?.content).toEqual([
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "completed",
|
||||
name: "read",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "Hello" },
|
||||
{ type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "hello.png" },
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("restores OpenAI encrypted reasoning metadata", () => Effect.sync(() => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
new SessionMessage.Assistant({
|
||||
id: id("assistant-openai-reasoning"),
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||
content: [
|
||||
new SessionMessage.AssistantReasoning({
|
||||
type: "reasoning",
|
||||
id: "reasoning-openai",
|
||||
text: "Think",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
}),
|
||||
],
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Think",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
])
|
||||
}))
|
||||
|
||||
it.effect("drops provider-native continuation metadata after a model switch", () => Effect.sync(() => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
new SessionMessage.Assistant({
|
||||
id: id("assistant-old-model"),
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: ModelV2.ID.make("old-model"), providerID: ProviderV2.ID.make("provider") },
|
||||
content: [
|
||||
new SessionMessage.AssistantReasoning({
|
||||
type: "reasoning",
|
||||
id: "reasoning-old-model",
|
||||
text: "Visible thought",
|
||||
providerMetadata: { anthropic: { signature: "sig_old" } },
|
||||
}),
|
||||
new SessionMessage.AssistantTool({
|
||||
type: "tool",
|
||||
id: "hosted-old-model",
|
||||
name: "web_search",
|
||||
provider: {
|
||||
executed: true,
|
||||
metadata: { openai: { itemId: "hosted-old-model" } },
|
||||
resultMetadata: { openai: { itemId: "hosted-old-model" } },
|
||||
},
|
||||
state: new SessionMessage.ToolStateCompleted({
|
||||
status: "completed",
|
||||
input: { query: "Effect" },
|
||||
content: [],
|
||||
structured: {},
|
||||
result: { type: "json", value: { status: "completed" } },
|
||||
it.effect("restores OpenAI encrypted reasoning metadata", () =>
|
||||
Effect.sync(() => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
new SessionMessage.Assistant({
|
||||
id: id("assistant-openai-reasoning"),
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||
content: [
|
||||
new SessionMessage.AssistantReasoning({
|
||||
type: "reasoning",
|
||||
id: "reasoning-openai",
|
||||
text: "Think",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
}),
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
new SessionMessage.AssistantTool({
|
||||
type: "tool",
|
||||
id: "local-old-model",
|
||||
name: "read",
|
||||
provider: {
|
||||
executed: false,
|
||||
metadata: { fake: { call: "old" } },
|
||||
resultMetadata: { fake: { result: "old" } },
|
||||
},
|
||||
state: new SessionMessage.ToolStateCompleted({
|
||||
status: "completed",
|
||||
input: { path: "README.md" },
|
||||
content: [],
|
||||
structured: { text: "Hello" },
|
||||
}),
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
],
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
],
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "Visible thought" },
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "hosted-old-model",
|
||||
name: "web_search",
|
||||
input: { query: "Effect" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "hosted-old-model",
|
||||
name: "web_search",
|
||||
result: { type: "json", value: { status: "completed" } },
|
||||
providerExecuted: true,
|
||||
cache: undefined,
|
||||
metadata: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "local-old-model",
|
||||
name: "read",
|
||||
input: { path: "README.md" },
|
||||
providerExecuted: false,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
])
|
||||
expect(messages[1]?.content).toEqual([
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "local-old-model",
|
||||
name: "read",
|
||||
result: { type: "json", value: { text: "Hello" } },
|
||||
providerExecuted: false,
|
||||
cache: undefined,
|
||||
metadata: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
])
|
||||
}))
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Think",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("drops provider-native continuation metadata after a model switch", () =>
|
||||
Effect.sync(() => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
new SessionMessage.Assistant({
|
||||
id: id("assistant-old-model"),
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: ModelV2.ID.make("old-model"), providerID: ProviderV2.ID.make("provider") },
|
||||
content: [
|
||||
new SessionMessage.AssistantReasoning({
|
||||
type: "reasoning",
|
||||
id: "reasoning-old-model",
|
||||
text: "Visible thought",
|
||||
providerMetadata: { anthropic: { signature: "sig_old" } },
|
||||
}),
|
||||
new SessionMessage.AssistantTool({
|
||||
type: "tool",
|
||||
id: "hosted-old-model",
|
||||
name: "web_search",
|
||||
provider: {
|
||||
executed: true,
|
||||
metadata: { openai: { itemId: "hosted-old-model" } },
|
||||
resultMetadata: { openai: { itemId: "hosted-old-model" } },
|
||||
},
|
||||
state: new SessionMessage.ToolStateCompleted({
|
||||
status: "completed",
|
||||
input: { query: "Effect" },
|
||||
content: [],
|
||||
structured: {},
|
||||
result: { type: "json", value: { status: "completed" } },
|
||||
}),
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
new SessionMessage.AssistantTool({
|
||||
type: "tool",
|
||||
id: "local-old-model",
|
||||
name: "read",
|
||||
provider: {
|
||||
executed: false,
|
||||
metadata: { fake: { call: "old" } },
|
||||
resultMetadata: { fake: { result: "old" } },
|
||||
},
|
||||
state: new SessionMessage.ToolStateCompleted({
|
||||
status: "completed",
|
||||
input: { path: "README.md" },
|
||||
content: [],
|
||||
structured: { text: "Hello" },
|
||||
}),
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
],
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "Visible thought" },
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "hosted-old-model",
|
||||
name: "web_search",
|
||||
input: { query: "Effect" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "hosted-old-model",
|
||||
name: "web_search",
|
||||
result: { type: "json", value: { status: "completed" } },
|
||||
providerExecuted: true,
|
||||
cache: undefined,
|
||||
metadata: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "local-old-model",
|
||||
name: "read",
|
||||
input: { path: "README.md" },
|
||||
providerExecuted: false,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
])
|
||||
expect(messages[1]?.content).toEqual([
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "local-old-model",
|
||||
name: "read",
|
||||
result: { type: "json", value: { text: "Hello" } },
|
||||
providerExecuted: false,
|
||||
cache: undefined,
|
||||
metadata: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -57,14 +57,15 @@ const model = OpenAIChat.route
|
|||
})
|
||||
.model({ id: "gpt-4o-mini" })
|
||||
const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
|
||||
const systemContextKey = SystemContext.Key.make("test/context")
|
||||
const systemContext = Layer.effectDiscard(
|
||||
SystemContextRegistry.Service.pipe(
|
||||
Effect.flatMap((registry) =>
|
||||
registry.contribute({
|
||||
key: "test/context",
|
||||
key: systemContextKey,
|
||||
load: Effect.succeed(
|
||||
SystemContext.make({
|
||||
key: SystemContext.Key.make("test/context"),
|
||||
key: systemContextKey,
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
load: Effect.succeed("Recorded context"),
|
||||
baseline: String,
|
||||
|
|
|
|||
|
|
@ -32,13 +32,18 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
|||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
|
||||
import { NativeTool } from "@opencode-ai/core/tool/native"
|
||||
import { SessionContextEpochTable, SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import {
|
||||
SessionContextEpochTable,
|
||||
SessionInputTable,
|
||||
SessionMessageTable,
|
||||
SessionTable,
|
||||
} from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||
import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { Cause, DateTime, Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
|
|
@ -145,11 +150,12 @@ const systemContextKey = SystemContext.Key.make("test/context")
|
|||
let systemBaseline = "Initial context"
|
||||
let systemRemoved = false
|
||||
let systemUnavailable = false
|
||||
let systemLoadHook = Effect.void
|
||||
const systemContext = Layer.effectDiscard(
|
||||
SystemContextRegistry.Service.pipe(
|
||||
Effect.flatMap((registry) =>
|
||||
registry.contribute({
|
||||
key: "test/context",
|
||||
key: systemContextKey,
|
||||
load: Effect.sync(() =>
|
||||
SystemContext.combine(
|
||||
systemRemoved
|
||||
|
|
@ -158,7 +164,11 @@ const systemContext = Layer.effectDiscard(
|
|||
SystemContext.make({
|
||||
key: systemContextKey,
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
load: Effect.sync(() => (systemUnavailable ? SystemContext.unavailable : systemBaseline)),
|
||||
load: systemLoadHook.pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => (systemUnavailable ? SystemContext.unavailable : systemBaseline)),
|
||||
),
|
||||
),
|
||||
baseline: String,
|
||||
update: (_previous, current) => current,
|
||||
removed: () => "System context source removed: test/context",
|
||||
|
|
@ -240,6 +250,7 @@ const setup = Effect.gen(function* () {
|
|||
systemBaseline = "Initial context"
|
||||
systemRemoved = false
|
||||
systemUnavailable = false
|
||||
systemLoadHook = Effect.void
|
||||
responses = undefined
|
||||
streamFailure = undefined
|
||||
responseStream = undefined
|
||||
|
|
@ -552,6 +563,39 @@ describe("SessionRunnerLLM", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("retries the first provider turn after system context becomes available", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
const messageID = SessionMessage.ID.create()
|
||||
systemUnavailable = true
|
||||
yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "First" }), resume: false })
|
||||
requests.length = 0
|
||||
|
||||
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(SystemContext.InitializationBlocked)
|
||||
expect(requests).toHaveLength(0)
|
||||
expect(yield* SessionInput.hasPending(db, sessionID, "steer")).toBe(true)
|
||||
expect(
|
||||
yield* db
|
||||
.select()
|
||||
.from(SessionContextEpochTable)
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.get(),
|
||||
).toBeUndefined()
|
||||
|
||||
systemUnavailable = false
|
||||
yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "First" }) })
|
||||
yield* (yield* SessionRunCoordinator.Service).awaitIdle(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reuses one durable baseline after the context producer changes", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
|
|
@ -622,6 +666,7 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* session.resume(sessionID)
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
|
||||
})
|
||||
|
|
@ -661,6 +706,7 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* session.resume(sessionID)
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
|
||||
})
|
||||
|
|
@ -692,15 +738,17 @@ describe("SessionRunnerLLM", () => {
|
|||
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
model: { id: ModelV2.ID.make("replacement-1"), providerID: ProviderV2.ID.make("fake") },
|
||||
})
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(2),
|
||||
model: { id: ModelV2.ID.make("replacement-2"), providerID: ProviderV2.ID.make("fake") },
|
||||
})
|
||||
const latest = yield* events.sequence(sessionID)
|
||||
const latest = yield* SessionInput.latestSeq(db, sessionID)
|
||||
|
||||
expect(
|
||||
yield* db
|
||||
|
|
@ -713,6 +761,40 @@ describe("SessionRunnerLLM", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("retries epoch preparation until observation-time invalidations settle", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
requests.length = 0
|
||||
systemBaseline = "Changed context"
|
||||
let invalidations = 0
|
||||
systemLoadHook = Effect.suspend(() => {
|
||||
if (invalidations === 4) return Effect.void
|
||||
invalidations++
|
||||
return events
|
||||
.publish(SessionEvent.ModelSwitched, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(invalidations),
|
||||
model: { id: ModelV2.ID.make(`replacement-${invalidations}`), providerID: ProviderV2.ID.make("fake") },
|
||||
})
|
||||
.pipe(Effect.asVoid)
|
||||
})
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(invalidations).toBe(4)
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.system.map((part) => part.text)).toEqual(["Changed context"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays retained context projections while replacement is pending", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
|
|
@ -728,6 +810,7 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* session.resume(sessionID)
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
|
||||
})
|
||||
|
|
@ -752,6 +835,7 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* session.resume(sessionID)
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
model: { id: ModelV2.ID.make("replacement-1"), providerID: ProviderV2.ID.make("fake") },
|
||||
})
|
||||
|
|
@ -760,6 +844,7 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* session.resume(sessionID)
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(2),
|
||||
model: { id: ModelV2.ID.make("replacement-2"), providerID: ProviderV2.ID.make("fake") },
|
||||
})
|
||||
|
|
@ -784,6 +869,7 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* session.resume(sessionID)
|
||||
yield* events.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
reason: "manual",
|
||||
})
|
||||
|
|
@ -821,6 +907,7 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* session.resume(sessionID)
|
||||
yield* events.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
reason: "manual",
|
||||
})
|
||||
|
|
@ -834,7 +921,16 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Initial context"])
|
||||
expect(requests.at(-1)?.messages.some((message) => message.role === "system" && message.content[0]?.type === "text" && message.content[0].text === "Changed context")).toBe(true)
|
||||
expect(
|
||||
requests
|
||||
.at(-1)
|
||||
?.messages.some(
|
||||
(message) =>
|
||||
message.role === "system" &&
|
||||
message.content[0]?.type === "text" &&
|
||||
message.content[0].text === "Changed context",
|
||||
),
|
||||
).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -1022,6 +1118,7 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* Deferred.await(toolExecutionsStarted)
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry
|
|||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const contribution = (key: string, text: string, sourceKey = key) => ({
|
||||
key,
|
||||
key: SystemContext.Key.make(key),
|
||||
load: Effect.succeed(
|
||||
SystemContext.make({
|
||||
key: SystemContext.Key.make(sourceKey),
|
||||
|
|
@ -43,7 +43,7 @@ describe("SystemContextRegistry", () => {
|
|||
const registry = yield* SystemContextRegistry.Service
|
||||
let loads = 0
|
||||
yield* registry.contribute({
|
||||
key: "test/dynamic",
|
||||
key: SystemContext.Key.make("test/dynamic"),
|
||||
load: Effect.sync(() => {
|
||||
loads++
|
||||
return SystemContext.empty
|
||||
|
|
@ -61,7 +61,7 @@ describe("SystemContextRegistry", () => {
|
|||
Effect.gen(function* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
const failure = new Error("contribution failed")
|
||||
yield* registry.contribute({ key: "test/failure", load: Effect.die(failure) })
|
||||
yield* registry.contribute({ key: SystemContext.Key.make("test/failure"), load: Effect.die(failure) })
|
||||
|
||||
const exit = yield* registry.load().pipe(Effect.exit)
|
||||
|
||||
|
|
|
|||
|
|
@ -125,16 +125,21 @@ describe("SystemContext", () => {
|
|||
|
||||
expect(yield* SystemContext.reconcile(context, previous)).toEqual({ _tag: "Unchanged" })
|
||||
expect(yield* SystemContext.replace(context, previous)).toEqual({ _tag: "ReplacementBlocked" })
|
||||
expect(yield* SystemContext.replace(context, {})).toMatchObject({ _tag: "Replaced" })
|
||||
expect(yield* SystemContext.replace(context, {})).toMatchObject({ _tag: "ReplacementReady" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits unavailable sources from an initial baseline", () =>
|
||||
it.effect("blocks initialization while a source is unavailable", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* SystemContext.initialize(stringContext({ key: "core/remote", value: SystemContext.unavailable }))).toEqual({
|
||||
baseline: "",
|
||||
snapshot: {},
|
||||
})
|
||||
const exit = yield* SystemContext.initialize(
|
||||
stringContext({ key: "core/remote", value: SystemContext.unavailable }),
|
||||
).pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit))
|
||||
expect(Cause.squash(exit.cause)).toEqual(
|
||||
new SystemContext.InitializationBlocked({ keys: [key("core/remote")] }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -154,8 +159,10 @@ describe("SystemContext", () => {
|
|||
|
||||
it.effect("requests replacement when a source without removal text disappears", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* SystemContext.reconcile(SystemContext.empty, { "core/date": { value: "2026-06-04" } })).toMatchObject({
|
||||
_tag: "Replaced",
|
||||
expect(
|
||||
yield* SystemContext.reconcile(SystemContext.empty, { "core/date": { value: "2026-06-04" } }),
|
||||
).toMatchObject({
|
||||
_tag: "ReplacementReady",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
@ -188,7 +195,7 @@ describe("SystemContext", () => {
|
|||
yield* SystemContext.reconcile(stringContext({ key: "core/date", value: "2026-06-04" }), {
|
||||
"core/date": { value: 42, removed: "Date removed" },
|
||||
}),
|
||||
).toMatchObject({ _tag: "Replaced" })
|
||||
).toMatchObject({ _tag: "ReplacementReady" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -207,7 +214,7 @@ describe("SystemContext", () => {
|
|||
})
|
||||
|
||||
expect(yield* SystemContext.reconcile(context, { "core/date": { value: 42 } })).toMatchObject({
|
||||
_tag: "Replaced",
|
||||
_tag: "ReplacementReady",
|
||||
generation: { baseline: "2026-06-04" },
|
||||
})
|
||||
expect(loads).toBe(1)
|
||||
|
|
@ -234,7 +241,7 @@ describe("SystemContext", () => {
|
|||
"core/date": { value: "2026-06-03" },
|
||||
"core/location": { value: 42 },
|
||||
}),
|
||||
).toMatchObject({ _tag: "Replaced" })
|
||||
).toMatchObject({ _tag: "ReplacementReady" })
|
||||
expect(updates).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue