feat(core): add embedded v2 session runtime and tool foundation (#30632)

This commit is contained in:
Kit Langton 2026-06-03 23:02:17 -04:00 committed by GitHub
commit 76ee87ead8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
215 changed files with 31398 additions and 3332 deletions

View file

@ -0,0 +1,47 @@
import { and, asc, desc, eq, gt, gte, or } from "drizzle-orm"
import { Effect, Schema } from "effect"
import { Database } from "../database/database"
import { MessageDecodeError } from "./error"
import { SessionMessage } from "./message"
import { SessionSchema } from "./schema"
import { SessionMessageTable } from "./sql"
type DatabaseService = Database.Interface["db"]
const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
export const load = Effect.fn("SessionContext.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const compaction = yield* db
.select()
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
.orderBy(desc(SessionMessageTable.seq))
.limit(1)
.get()
.pipe(Effect.orDie)
const rows = yield* db
.select()
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
compaction ? or(gte(SessionMessageTable.seq, compaction.seq)) : undefined,
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
return yield* Effect.forEach(rows, (row) =>
decode({ ...row.data, id: row.id, type: row.type }).pipe(
Effect.mapError(
() =>
new MessageDecodeError({
sessionID: SessionSchema.ID.make(row.session_id),
messageID: SessionMessage.ID.make(row.id),
}),
),
),
)
})
export * as SessionContext from "./context"

View file

@ -0,0 +1,8 @@
import { Schema } from "effect"
import { SessionMessage } from "./message"
import { SessionSchema } from "./schema"
export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", {
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
}) {}

View file

@ -1,4 +1,5 @@
import { Schema } from "effect"
import { ProviderMetadata } from "@opencode-ai/llm"
import { EventV2 } from "../event"
import { ModelV2 } from "../model"
import { NonNegativeInt } from "../schema"
@ -29,6 +30,12 @@ const options = {
version: 1,
},
} as const
const stepSettlementOptions = {
sync: {
aggregate: "sessionID",
version: 2,
},
} as const
export const UnknownError = Schema.Struct({
type: Schema.Literal("unknown"),
@ -64,6 +71,7 @@ export const Prompted = EventV2.define({
schema: {
...Base,
prompt: Prompt,
delivery: Schema.Literals(["steer", "queue"]),
},
})
export type Prompted = typeof Prompted.Type
@ -117,9 +125,10 @@ export namespace Step {
export const Ended = EventV2.define({
type: "session.next.step.ended",
...options,
...stepSettlementOptions,
schema: {
...Base,
assistantMessageID: EventV2.ID,
finish: Schema.String,
cost: Schema.Finite,
tokens: Schema.Struct({
@ -138,9 +147,10 @@ export namespace Step {
export const Failed = EventV2.define({
type: "session.next.step.failed",
...options,
...stepSettlementOptions,
schema: {
...Base,
assistantMessageID: EventV2.ID,
error: UnknownError,
},
})
@ -153,15 +163,17 @@ export namespace Text {
...options,
schema: {
...Base,
textID: Schema.String,
},
})
export type Started = typeof Started.Type
// Stream fragments are live-only; Text.Ended is the replayable full-value boundary.
export const Delta = EventV2.define({
type: "session.next.text.delta",
...options,
schema: {
...Base,
textID: Schema.String,
delta: Schema.String,
},
})
@ -172,6 +184,7 @@ export namespace Text {
...options,
schema: {
...Base,
textID: Schema.String,
text: Schema.String,
},
})
@ -185,13 +198,14 @@ export namespace Reasoning {
schema: {
...Base,
reasoningID: Schema.String,
providerMetadata: ProviderMetadata.pipe(Schema.optional),
},
})
export type Started = typeof Started.Type
// Stream fragments are live-only; Reasoning.Ended is the replayable full-value boundary.
export const Delta = EventV2.define({
type: "session.next.reasoning.delta",
...options,
schema: {
...Base,
reasoningID: Schema.String,
@ -207,30 +221,35 @@ export namespace Reasoning {
...Base,
reasoningID: Schema.String,
text: Schema.String,
providerMetadata: ProviderMetadata.pipe(Schema.optional),
},
})
export type Ended = typeof Ended.Type
}
export namespace Tool {
const ToolBase = {
...Base,
assistantMessageID: EventV2.ID,
callID: Schema.String,
}
export namespace Input {
export const Started = EventV2.define({
type: "session.next.tool.input.started",
...options,
schema: {
...Base,
callID: Schema.String,
...ToolBase,
name: Schema.String,
},
})
export type Started = typeof Started.Type
// Stream fragments are live-only; Input.Ended is the replayable raw-input boundary.
export const Delta = EventV2.define({
type: "session.next.tool.input.delta",
...options,
schema: {
...Base,
callID: Schema.String,
...ToolBase,
delta: Schema.String,
},
})
@ -240,8 +259,7 @@ export namespace Tool {
type: "session.next.tool.input.ended",
...options,
schema: {
...Base,
callID: Schema.String,
...ToolBase,
text: Schema.String,
},
})
@ -252,24 +270,26 @@ export namespace Tool {
type: "session.next.tool.called",
...options,
schema: {
...Base,
callID: Schema.String,
...ToolBase,
tool: Schema.String,
input: Schema.Record(Schema.String, Schema.Unknown),
provider: Schema.Struct({
executed: Schema.Boolean,
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
metadata: ProviderMetadata.pipe(Schema.optional),
}),
},
})
export type Called = typeof Called.Type
/**
* Replayable bounded running-tool state. Tools should checkpoint semantic
* transitions or at a bounded cadence, not persist every stdout/stderr chunk.
*/
export const Progress = EventV2.define({
type: "session.next.tool.progress",
...options,
schema: {
...Base,
callID: Schema.String,
...ToolBase,
structured: ToolOutput.Structured,
content: Schema.Array(ToolOutput.Content),
},
@ -280,13 +300,13 @@ export namespace Tool {
type: "session.next.tool.success",
...options,
schema: {
...Base,
callID: Schema.String,
...ToolBase,
structured: ToolOutput.Structured,
content: Schema.Array(ToolOutput.Content),
result: Schema.Unknown.pipe(Schema.optional),
provider: Schema.Struct({
executed: Schema.Boolean,
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
metadata: ProviderMetadata.pipe(Schema.optional),
}),
},
})
@ -296,12 +316,12 @@ export namespace Tool {
type: "session.next.tool.failed",
...options,
schema: {
...Base,
callID: Schema.String,
...ToolBase,
error: UnknownError,
result: Schema.Unknown.pipe(Schema.optional),
provider: Schema.Struct({
executed: Schema.Boolean,
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
metadata: ProviderMetadata.pipe(Schema.optional),
}),
},
})
@ -364,39 +384,39 @@ export namespace Compaction {
export type Ended = typeof Ended.Type
}
export const All = Schema.Union(
[
AgentSwitched,
ModelSwitched,
Prompted,
Synthetic,
Shell.Started,
Shell.Ended,
Step.Started,
Step.Ended,
Step.Failed,
Text.Started,
Text.Delta,
Text.Ended,
Tool.Input.Started,
Tool.Input.Delta,
Tool.Input.Ended,
Tool.Called,
Tool.Progress,
Tool.Success,
Tool.Failed,
Reasoning.Started,
Reasoning.Delta,
Reasoning.Ended,
Retried,
Compaction.Started,
Compaction.Delta,
Compaction.Ended,
],
{
mode: "oneOf",
},
).pipe(Schema.toTaggedUnion("type"))
const DurableDefinitions = [
AgentSwitched,
ModelSwitched,
Prompted,
Synthetic,
Shell.Started,
Shell.Ended,
Step.Started,
Step.Ended,
Step.Failed,
Text.Started,
Text.Ended,
Tool.Input.Started,
Tool.Input.Ended,
Tool.Called,
Tool.Progress,
Tool.Success,
Tool.Failed,
Reasoning.Started,
Reasoning.Ended,
Retried,
Compaction.Started,
Compaction.Delta,
Compaction.Ended,
] as const
const EphemeralDefinitions = [Text.Delta, Tool.Input.Delta, Reasoning.Delta] as const
export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type"))
export type DurableEvent = typeof Durable.Type
export const All = Schema.Union([...DurableDefinitions, ...EphemeralDefinitions], { mode: "oneOf" }).pipe(
Schema.toTaggedUnion("type"),
)
export type Event = typeof All.Type
export type Type = Event["type"]

View file

@ -0,0 +1,18 @@
export * as SessionExecution from "./execution"
import { Context, Effect, Layer } from "effect"
import { SessionRunner } from "./runner/index"
import { SessionSchema } from "./schema"
export interface Interface {
/** Explicitly drain one Session, making at least one provider attempt. */
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
/** Schedule a drain after durable work is recorded. Repeated wakeups may coalesce. */
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
}
/** Routes execution from a Session ID to the runner owned by that Session's Location. */
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionExecution") {}
/** Low-level compatibility layer for callers that only need durable Session recording. */
export const noopLayer = Layer.succeed(Service, Service.of({ resume: () => Effect.void, wake: () => Effect.void }))

View file

@ -0,0 +1,35 @@
import { Effect, Layer } from "effect"
import { LocationServiceMap } from "../../location-layer"
import { SessionRunCoordinator } from "../run-coordinator"
import { SessionSchema } from "../schema"
import { SessionStore } from "../store"
import { SessionExecution } from "../execution"
/** Current-process routing for implicit-local Locations. Future remote placement belongs here. */
export const layer = Layer.effect(
SessionExecution.Service,
Effect.gen(function* () {
const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap
const scope = yield* Effect.scope
const withCoordinator = Effect.fnUntraced(function* <A, E>(
sessionID: SessionSchema.ID,
use: (coordinator: SessionRunCoordinator.Interface) => Effect.Effect<A, E>,
) {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(`Session not found: ${sessionID}`)
return yield* SessionRunCoordinator.Service.use(use).pipe(Effect.provide(locations.get(session.location)))
})
return SessionExecution.Service.of({
resume: Effect.fn("SessionExecution.resume")(function* (sessionID) {
return yield* withCoordinator(sessionID, (coordinator) => coordinator.run(sessionID))
}),
wake: Effect.fn("SessionExecution.wake")(function* (sessionID) {
yield* withCoordinator(sessionID, (coordinator) =>
coordinator.wake(sessionID).pipe(Effect.andThen(coordinator.awaitIdle(sessionID))),
).pipe(Effect.forkIn(scope), Effect.asVoid)
}),
})
}),
)

View file

@ -0,0 +1,47 @@
import { DateTime } from "effect"
import { AgentV2 } from "../agent"
import { Location } from "../location"
import { ModelV2 } from "../model"
import { ProjectV2 } from "../project"
import { ProviderV2 } from "../provider"
import { AbsolutePath, RelativePath } from "../schema"
import { WorkspaceV2 } from "../workspace"
import { SessionSchema } from "./schema"
import { SessionTable } from "./sql"
export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.Info {
return SessionSchema.Info.make({
id: SessionSchema.ID.make(row.id),
projectID: ProjectV2.ID.make(row.project_id),
title: row.title,
parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined,
agent: row.agent ? AgentV2.ID.make(row.agent) : undefined,
model: row.model
? {
id: ModelV2.ID.make(row.model.id),
providerID: ProviderV2.ID.make(row.model.providerID),
variant: ModelV2.VariantID.make(row.model.variant ?? "default"),
}
: undefined,
cost: row.cost,
tokens: {
input: row.tokens_input,
output: row.tokens_output,
reasoning: row.tokens_reasoning,
cache: {
read: row.tokens_cache_read,
write: row.tokens_cache_write,
},
},
location: Location.Ref.make({
directory: AbsolutePath.make(row.directory),
workspaceID: row.workspace_id ? WorkspaceV2.ID.make(row.workspace_id) : undefined,
}),
subpath: row.path ? RelativePath.make(row.path) : undefined,
time: {
created: DateTime.makeUnsafe(row.time_created),
updated: DateTime.makeUnsafe(row.time_updated),
archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined,
},
})
}

View file

@ -0,0 +1,285 @@
export * as SessionInput from "./input"
import { and, asc, eq, inArray, isNull } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
import type { Database } from "../database/database"
import type { EventV2 } from "../event"
import { EventTable } from "../event/sql"
import { NonNegativeInt, PositiveInt } from "../schema"
import { V2Schema } from "../v2-schema"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
import { Prompt } from "./prompt"
import { SessionSchema } from "./schema"
import { SessionInputTable, SessionMessageTable } from "./sql"
type DatabaseService = Database.Interface["db"]
export const Delivery = Schema.Literals(["steer", "queue"])
export type Delivery = typeof Delivery.Type
export class Admitted extends Schema.Class<Admitted>("SessionInput.Admitted")({
seq: PositiveInt,
id: SessionMessage.ID,
sessionID: SessionSchema.ID,
prompt: Prompt,
delivery: Delivery,
timeCreated: V2Schema.DateTimeUtcFromMillis,
promotedSeq: NonNegativeInt.pipe(Schema.optional),
}) {}
const decodePrompt = Schema.decodeUnknownSync(Prompt)
const encodePrompt = Schema.encodeSync(Prompt)
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
const fromRow = (row: typeof SessionInputTable.$inferSelect): Admitted =>
new Admitted({
seq: row.seq,
id: SessionMessage.ID.make(row.id),
sessionID: SessionSchema.ID.make(row.session_id),
prompt: decodePrompt(row.prompt),
delivery: row.delivery,
timeCreated: DateTime.makeUnsafe(row.time_created),
...(row.promoted_seq === null ? {} : { promotedSeq: row.promoted_seq }),
})
export const find = Effect.fn("SessionInput.find")(function* (db: DatabaseService, id: SessionMessage.ID) {
const row = yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie)
return row === undefined ? undefined : fromRow(row)
})
export const admit = Effect.fn("SessionInput.admit")(function* (
db: DatabaseService,
input: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly prompt: Prompt
readonly delivery: Delivery
},
) {
return yield* db
.transaction(
() =>
Effect.gen(function* () {
const existing = yield* find(db, input.id)
if (existing !== undefined) return existing
const event = yield* db
.select({ id: EventTable.id })
.from(EventTable)
.where(eq(EventTable.id, input.id))
.get()
.pipe(Effect.orDie)
const message = yield* db
.select({ id: SessionMessageTable.id })
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, input.id))
.get()
.pipe(Effect.orDie)
if (event !== undefined || message !== undefined) return undefined
const row = yield* db
.insert(SessionInputTable)
.values({
id: input.id,
session_id: input.sessionID,
prompt: encodePrompt(input.prompt),
delivery: input.delivery,
})
.returning()
.get()
.pipe(Effect.orDie)
return fromRow(row)
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
})
export const hasPending = Effect.fn("SessionInput.hasPending")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
deliveries: ReadonlyArray<Delivery> = ["steer", "queue"],
) {
if (deliveries.length === 0) return false
const row = yield* db
.select({ id: SessionInputTable.id })
.from(SessionInputTable)
.where(
and(
eq(SessionInputTable.session_id, sessionID),
isNull(SessionInputTable.promoted_seq),
inArray(SessionInputTable.delivery, deliveries),
),
)
.limit(1)
.get()
.pipe(Effect.orDie)
return row !== undefined
})
export const equivalent = (
input: Admitted,
expected: {
readonly sessionID: SessionSchema.ID
readonly prompt: Prompt
readonly delivery: Delivery
},
) => input.delivery === expected.delivery && matchesPrompt(input, expected)
const matchesPrompt = (input: Admitted, expected: { readonly sessionID: SessionSchema.ID; readonly prompt: Prompt }) =>
input.sessionID === expected.sessionID && JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt))
export const guardReservedID = Effect.fn("SessionInput.guardReservedID")(function* (
db: DatabaseService,
event: EventV2.Payload,
) {
const admitted = yield* find(db, event.id)
if (admitted === undefined) return
if (!Schema.is(SessionEvent.Prompted)(event))
return yield* Effect.die("Durable event conflicts with admitted prompt input")
if (!equivalent(admitted, event.data)) return yield* Effect.die("Prompt projection conflicts with admitted input")
})
export const project = Effect.fn("SessionInput.project")(function* (
db: DatabaseService,
input: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly prompt: Prompt
readonly delivery: Delivery
readonly timeCreated: DateTime.Utc
readonly promotedSeq: number
},
) {
yield* db
.insert(SessionInputTable)
.values({
id: input.id,
session_id: input.sessionID,
prompt: encodePrompt(input.prompt),
delivery: input.delivery,
promoted_seq: input.promotedSeq,
time_created: DateTime.toEpochMillis(input.timeCreated),
})
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
const admitted = yield* find(db, input.id)
if (admitted === undefined || admitted.delivery !== input.delivery || !matchesPrompt(admitted, input))
return yield* Effect.die("Prompt projection conflicts with admitted input")
yield* db
.update(SessionInputTable)
.set({ promoted_seq: input.promotedSeq })
.where(
and(
eq(SessionInputTable.id, input.id),
eq(SessionInputTable.session_id, input.sessionID),
isNull(SessionInputTable.promoted_seq),
),
)
.run()
.pipe(Effect.orDie)
return yield* find(db, input.id)
})
export const reconcileProjected = Effect.fn("SessionInput.reconcileProjected")(function* (
db: DatabaseService,
expected: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly prompt: Prompt
readonly delivery: Delivery
},
) {
if (expected.delivery !== "steer") return undefined
const row = yield* db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, expected.id))
.get()
.pipe(Effect.orDie)
if (row === undefined || row.session_id !== expected.sessionID || row.type !== "user") return undefined
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
if (message.type !== "user" || !Prompt.equivalence(Prompt.fromUserMessage(message), expected.prompt)) return undefined
return yield* project(db, {
id: expected.id,
sessionID: expected.sessionID,
prompt: expected.prompt,
delivery: expected.delivery,
timeCreated: message.time.created,
promotedSeq: row.seq,
})
})
const publish = Effect.fn("SessionInput.publish")(function* (
events: EventV2.Interface,
sessionID: SessionSchema.ID,
rows: ReadonlyArray<typeof SessionInputTable.$inferSelect>,
) {
for (const row of rows) {
yield* events.publish(
SessionEvent.Prompted,
{
sessionID,
timestamp: DateTime.makeUnsafe(row.time_created),
prompt: decodePrompt(row.prompt),
delivery: row.delivery,
},
{ id: SessionMessage.ID.make(row.id) },
)
}
return rows.length
})
export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* (
db: DatabaseService,
events: EventV2.Interface,
sessionID: SessionSchema.ID,
) {
const rows = yield* db
.select()
.from(SessionInputTable)
.where(
and(
eq(SessionInputTable.session_id, sessionID),
isNull(SessionInputTable.promoted_seq),
eq(SessionInputTable.delivery, "steer"),
),
)
.orderBy(asc(SessionInputTable.seq))
.all()
.pipe(Effect.orDie)
return yield* publish(events, sessionID, rows)
})
export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(function* (
db: DatabaseService,
events: EventV2.Interface,
sessionID: SessionSchema.ID,
) {
const row = yield* db
.select()
.from(SessionInputTable)
.where(
and(
eq(SessionInputTable.session_id, sessionID),
isNull(SessionInputTable.promoted_seq),
eq(SessionInputTable.delivery, "queue"),
),
)
.orderBy(asc(SessionInputTable.seq))
.limit(1)
.get()
.pipe(Effect.orDie)
return row === undefined ? false : yield* publish(events, sessionID, [row]).pipe(Effect.as(true))
})
export const toMessage = (input: Admitted) =>
new SessionMessage.User({
id: input.id,
type: "user",
text: input.prompt.text,
files: input.prompt.files,
agents: input.prompt.agents,
references: input.prompt.references,
time: { created: input.timeCreated },
})

View file

@ -1,4 +1,4 @@
import { produce, type WritableDraft } from "immer"
import { castDraft, produce, type WritableDraft } from "immer"
import { Effect } from "effect"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
@ -9,6 +9,7 @@ export type MemoryState = {
export interface Adapter {
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined>
readonly getAssistant: (messageID: SessionMessage.ID) => Effect.Effect<SessionMessage.Assistant | undefined>
readonly getCurrentCompaction: () => Effect.Effect<SessionMessage.Compaction | undefined>
readonly getCurrentShell: (callID: string) => Effect.Effect<SessionMessage.Shell | undefined>
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void>
@ -18,8 +19,10 @@ export interface Adapter {
}
export function memory(state: MemoryState): Adapter {
const activeAssistantIndex = () =>
state.messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed)
const assistantIndex = (messageID: SessionMessage.ID) =>
state.messages.findLastIndex((message) => message.id === messageID)
// A newer turn supersedes stale incomplete rows; never resume an older assistant projection.
const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant")
const activeCompactionIndex = () => state.messages.findLastIndex((message) => message.type === "compaction")
const activeShellIndex = (callID: string) =>
state.messages.findLastIndex((message) => message.type === "shell" && message.callID === callID)
@ -27,7 +30,15 @@ export function memory(state: MemoryState): Adapter {
return {
getCurrentAssistant() {
return Effect.sync(() => {
const index = activeAssistantIndex()
const index = latestAssistantIndex()
if (index < 0) return
const assistant = state.messages[index]
return assistant?.type === "assistant" && !assistant.time.completed ? assistant : undefined
})
},
getAssistant(messageID) {
return Effect.sync(() => {
const index = assistantIndex(messageID)
if (index < 0) return
const assistant = state.messages[index]
return assistant?.type === "assistant" ? assistant : undefined
@ -51,7 +62,7 @@ export function memory(state: MemoryState): Adapter {
},
updateAssistant(assistant) {
return Effect.sync(() => {
const index = activeAssistantIndex()
const index = assistantIndex(assistant.id)
if (index < 0) return
const current = state.messages[index]
if (current?.type !== "assistant") return
@ -95,12 +106,18 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
(item): item is DraftTool => item.type === "tool" && (callID === undefined || item.id === callID),
)
const latestText = (assistant: DraftAssistant | undefined) =>
assistant?.content.findLast((item): item is DraftText => item.type === "text")
const latestText = (assistant: DraftAssistant | undefined, textID: string) =>
assistant?.content.findLast((item): item is DraftText => item.type === "text" && item.id === textID)
const latestReasoning = (assistant: DraftAssistant | undefined, reasoningID: string) =>
assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && item.id === reasoningID)
const updateOwnedAssistant = (messageID: SessionMessage.ID, recipe: (draft: DraftAssistant) => void) =>
Effect.gen(function* () {
const assistant = yield* adapter.getAssistant(messageID)
if (assistant) yield* adapter.updateAssistant(produce(assistant, recipe))
})
return Effect.gen(function* () {
yield* SessionEvent.All.match(event, {
"session.next.agent.switched": (event) => {
@ -200,42 +217,30 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
})
},
"session.next.step.ended": (event) => {
return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
draft.time.completed = event.data.timestamp
draft.finish = event.data.finish
draft.cost = event.data.cost
draft.tokens = event.data.tokens
if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, end: event.data.snapshot }
}),
)
}
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.completed = event.data.timestamp
draft.finish = event.data.finish
draft.cost = event.data.cost
draft.tokens = event.data.tokens
if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, end: event.data.snapshot }
})
},
"session.next.step.failed": (event) => {
return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
draft.time.completed = event.data.timestamp
draft.finish = "error"
draft.error = event.data.error
}),
)
}
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.completed = event.data.timestamp
draft.finish = "error"
draft.error = event.data.error
})
},
"session.next.text.started": () => {
"session.next.text.started": (event) => {
return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
draft.content.push(new SessionMessage.AssistantText({ type: "text", text: "" }) as DraftText)
draft.content.push(
castDraft(new SessionMessage.AssistantText({ type: "text", id: event.data.textID, text: "" })),
)
}),
)
}
@ -247,7 +252,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
if (currentAssistant) {
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
const match = latestText(draft)
const match = latestText(draft, event.data.textID)
if (match) match.text += event.data.delta
}),
)
@ -260,7 +265,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
if (currentAssistant) {
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
const match = latestText(draft)
const match = latestText(draft, event.data.textID)
if (match) match.text = event.data.text
}),
)
@ -268,118 +273,93 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
})
},
"session.next.tool.input.started": (event) => {
return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
draft.content.push(
new SessionMessage.AssistantTool({
type: "tool",
id: event.data.callID,
name: event.data.name,
time: { created: event.data.timestamp },
state: new SessionMessage.ToolStatePending({ status: "pending", input: "" }),
}) as DraftTool,
)
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.content.push(
castDraft(
new SessionMessage.AssistantTool({
type: "tool",
id: event.data.callID,
name: event.data.name,
time: { created: event.data.timestamp },
state: new SessionMessage.ToolStatePending({ status: "pending", input: "" }),
}),
)
}
),
)
})
},
"session.next.tool.input.delta": (event) => {
return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
const match = latestTool(draft, event.data.callID)
// oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string)
if (match && match.state.status === "pending") match.state.input += event.data.delta
}),
)
}
"session.next.tool.input.delta": () => Effect.void,
"session.next.tool.input.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "pending") match.state.input = event.data.text
})
},
"session.next.tool.input.ended": () => Effect.void,
"session.next.tool.called": (event) => {
return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match) {
match.provider = event.data.provider
match.time.ran = event.data.timestamp
match.state = {
status: "running",
input: event.data.input,
structured: {},
content: [],
}
}
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match) {
match.provider = event.data.provider
match.time.ran = event.data.timestamp
match.state = castDraft(
new SessionMessage.ToolStateRunning({
status: "running",
input: event.data.input,
structured: {},
content: [],
}),
)
}
})
},
"session.next.tool.progress": (event) => {
return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "running") {
match.state.structured = event.data.structured
match.state.content = [...event.data.content]
}
}),
)
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "running") {
match.state.structured = event.data.structured
match.state.content = [...event.data.content]
}
})
},
"session.next.tool.success": (event) => {
return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "running") {
match.provider = event.data.provider
match.time.completed = event.data.timestamp
match.state = {
status: "completed",
input: match.state.input,
structured: event.data.structured,
content: [...event.data.content],
}
}
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "running") {
match.provider = {
executed: event.data.provider.executed || match.provider?.executed === true,
metadata: match.provider?.metadata,
resultMetadata: event.data.provider.metadata,
}
match.time.completed = event.data.timestamp
match.state = castDraft(
new SessionMessage.ToolStateCompleted({
status: "completed",
input: match.state.input,
structured: event.data.structured,
content: [...event.data.content],
result: event.data.result,
}),
)
}
})
},
"session.next.tool.failed": (event) => {
return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "running") {
match.provider = event.data.provider
match.time.completed = event.data.timestamp
match.state = {
status: "error",
error: event.data.error,
input: match.state.input,
structured: match.state.structured,
content: match.state.content,
}
}
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match && (match.state.status === "pending" || match.state.status === "running")) {
match.provider = {
executed: event.data.provider.executed || match.provider?.executed === true,
metadata: match.provider?.metadata,
resultMetadata: event.data.provider.metadata,
}
match.time.completed = event.data.timestamp
match.state = castDraft(
new SessionMessage.ToolStateError({
status: "error",
error: event.data.error,
input: typeof match.state.input === "string" ? {} : match.state.input,
structured: match.state.status === "running" ? match.state.structured : {},
content: match.state.status === "running" ? match.state.content : [],
result: event.data.result,
}),
)
}
@ -392,11 +372,14 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
draft.content.push(
new SessionMessage.AssistantReasoning({
type: "reasoning",
id: event.data.reasoningID,
text: "",
}) as DraftReasoning,
castDraft(
new SessionMessage.AssistantReasoning({
type: "reasoning",
id: event.data.reasoningID,
text: "",
providerMetadata: event.data.providerMetadata,
}),
),
)
}),
)
@ -423,7 +406,10 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
const match = latestReasoning(draft, event.data.reasoningID)
if (match) match.text = event.data.text
if (match) {
match.text = event.data.text
if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata
}
}),
)
}

View file

@ -1,6 +1,7 @@
export * as SessionMessage from "./message"
import { Schema } from "effect"
import { ProviderMetadata } from "@opencode-ai/llm"
import { EventV2 } from "../event"
import { ModelV2 } from "../model"
import { ToolOutput } from "../tool-output"
@ -80,6 +81,7 @@ export class ToolStateCompleted extends Schema.Class<ToolStateCompleted>("Sessio
attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional),
content: ToolOutput.Content.pipe(Schema.Array),
structured: ToolOutput.Structured,
result: SessionEvent.Tool.Success.data.fields.result,
}) {}
export class ToolStateError extends Schema.Class<ToolStateError>("Session.Message.ToolState.Error")({
@ -88,6 +90,7 @@ export class ToolStateError extends Schema.Class<ToolStateError>("Session.Messag
content: ToolOutput.Content.pipe(Schema.Array),
structured: ToolOutput.Structured,
error: SessionEvent.UnknownError,
result: SessionEvent.Tool.Failed.data.fields.result,
}) {}
export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe(
@ -101,7 +104,8 @@ export class AssistantTool extends Schema.Class<AssistantTool>("Session.Message.
name: Schema.String,
provider: Schema.Struct({
executed: Schema.Boolean,
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
metadata: ProviderMetadata.pipe(Schema.optional),
resultMetadata: ProviderMetadata.pipe(Schema.optional),
}).pipe(Schema.optional),
state: ToolState,
time: Schema.Struct({
@ -114,6 +118,7 @@ export class AssistantTool extends Schema.Class<AssistantTool>("Session.Message.
export class AssistantText extends Schema.Class<AssistantText>("Session.Message.Assistant.Text")({
type: Schema.Literal("text"),
id: Schema.String,
text: Schema.String,
}) {}
@ -121,6 +126,7 @@ export class AssistantReasoning extends Schema.Class<AssistantReasoning>("Sessio
type: Schema.Literal("reasoning"),
id: Schema.String,
text: Schema.String,
providerMetadata: ProviderMetadata.pipe(Schema.optional),
}) {}
export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe(

View file

@ -1,6 +1,6 @@
export * as SessionProjector from "./projector"
import { and, eq, sql } from "drizzle-orm"
import { and, desc, eq, sql } from "drizzle-orm"
import { DateTime, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database"
import { EventV2 } from "../event"
@ -9,6 +9,7 @@ import { SessionV1 } from "../v1/session"
import { WorkspaceTable } from "../control-plane/workspace.sql"
import { SessionMessage } from "./message"
import { SessionMessageUpdater } from "./message-updater"
import { SessionInput } from "./input"
import { MessageTable, PartTable, SessionMessageTable, SessionTable } from "./sql"
import type { DeepMutable } from "../schema"
@ -17,6 +18,9 @@ type DatabaseService = Database.Interface["db"]
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
const encodeMessage = Schema.encodeSync(SessionMessage.Message)
export class PromptAlreadyProjected extends Error {}
export class SessionAlreadyProjected extends Error {}
type Usage = {
cost: number
tokens: {
@ -105,37 +109,84 @@ function applyUsage(
function run(db: DatabaseService, event: SessionEvent.Event) {
return Effect.gen(function* () {
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type })
const writeMessage = (message: SessionMessage.Message) => {
if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
const encoded = encodeMessage(message)
const { id, type, ...data } = encoded
return db
.insert(SessionMessageTable)
.values([
{
id: SessionMessage.ID.make(id),
session_id: event.data.sessionID,
type,
seq: event.seq,
time_created: DateTime.toEpochMillis(message.time.created),
data,
},
])
.onConflictDoUpdate({
target: SessionMessageTable.id,
set: { type, time_created: DateTime.toEpochMillis(message.time.created), data },
})
.run()
.pipe(Effect.orDie)
}
const adapter: SessionMessageUpdater.Adapter = {
getCurrentAssistant() {
return Effect.gen(function* () {
const rows = yield* db
// A newer turn supersedes stale incomplete rows; never resume an older assistant projection.
const row = yield* db
.select()
.from(SessionMessageTable)
.where(
and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "assistant")),
)
.all()
.orderBy(desc(SessionMessageTable.seq))
.limit(1)
.get()
.pipe(Effect.orDie)
return rows
.map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type }))
.find(
(message): message is SessionMessage.Assistant => message.type === "assistant" && !message.time.completed,
if (!row) return
const message = decodeRow(row)
return message.type === "assistant" && !message.time.completed ? message : undefined
})
},
getAssistant(messageID) {
return Effect.gen(function* () {
const row = yield* db
.select()
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.id, messageID),
eq(SessionMessageTable.session_id, event.data.sessionID),
eq(SessionMessageTable.type, "assistant"),
),
)
.get()
.pipe(Effect.orDie)
if (!row) return
const message = decodeRow(row)
return message.type === "assistant" ? message : undefined
})
},
getCurrentCompaction() {
return Effect.gen(function* () {
const rows = yield* db
const row = yield* db
.select()
.from(SessionMessageTable)
.where(
and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "compaction")),
)
.all()
.orderBy(desc(SessionMessageTable.seq))
.limit(1)
.get()
.pipe(Effect.orDie)
return rows
.map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type }))
.find((message): message is SessionMessage.Compaction => message.type === "compaction")
if (!row) return
const message = decodeRow(row)
return message.type === "compaction" ? message : undefined
})
},
getCurrentShell(callID) {
@ -144,121 +195,18 @@ function run(db: DatabaseService, event: SessionEvent.Event) {
.select()
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "shell")))
.orderBy(desc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
return rows
.map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type }))
.map(decodeRow)
.find((message): message is SessionMessage.Shell => message.type === "shell" && message.callID === callID)
})
},
updateAssistant(message) {
return Effect.gen(function* () {
const encoded = encodeMessage(message)
const { id, type, ...data } = encoded
yield* db
.insert(SessionMessageTable)
.values([
{
id: SessionMessage.ID.make(id),
session_id: event.data.sessionID,
type,
time_created: DateTime.toEpochMillis(message.time.created),
data,
},
])
.onConflictDoUpdate({
target: SessionMessageTable.id,
set: {
type,
time_created: DateTime.toEpochMillis(message.time.created),
data,
},
})
.run()
.pipe(Effect.orDie)
})
},
updateCompaction(message) {
return Effect.gen(function* () {
const encoded = encodeMessage(message)
const { id, type, ...data } = encoded
yield* db
.insert(SessionMessageTable)
.values([
{
id: SessionMessage.ID.make(id),
session_id: event.data.sessionID,
type,
time_created: DateTime.toEpochMillis(message.time.created),
data,
},
])
.onConflictDoUpdate({
target: SessionMessageTable.id,
set: {
type,
time_created: DateTime.toEpochMillis(message.time.created),
data,
},
})
.run()
.pipe(Effect.orDie)
})
},
updateShell(message) {
return Effect.gen(function* () {
const encoded = encodeMessage(message)
const { id, type, ...data } = encoded
yield* db
.insert(SessionMessageTable)
.values([
{
id: SessionMessage.ID.make(id),
session_id: event.data.sessionID,
type,
time_created: DateTime.toEpochMillis(message.time.created),
data,
},
])
.onConflictDoUpdate({
target: SessionMessageTable.id,
set: {
type,
time_created: DateTime.toEpochMillis(message.time.created),
data,
},
})
.run()
.pipe(Effect.orDie)
})
},
appendMessage(message) {
return Effect.gen(function* () {
const encoded = encodeMessage(message)
const { id, type, ...data } = encoded
yield* db
.insert(SessionMessageTable)
.values([
{
id: SessionMessage.ID.make(id),
session_id: event.data.sessionID,
type,
time_created: DateTime.toEpochMillis(message.time.created),
data,
},
])
.onConflictDoUpdate({
target: SessionMessageTable.id,
set: {
type,
time_created: DateTime.toEpochMillis(message.time.created),
data,
},
})
.run()
.pipe(Effect.orDie)
})
},
updateAssistant: writeMessage,
updateCompaction: writeMessage,
updateShell: writeMessage,
appendMessage: writeMessage,
}
yield* SessionMessageUpdater.update(adapter, event)
})
@ -268,9 +216,17 @@ export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const events = yield* EventV2.Service
const { db } = yield* Database.Service
yield* events.beforeCommit((event) => SessionInput.guardReservedID(db, event))
yield* events.project(SessionV1.Event.Created, (event) =>
Effect.gen(function* () {
yield* db.insert(SessionTable).values(sessionRow(event.data.info)).run().pipe(Effect.orDie)
const stored = yield* db
.insert(SessionTable)
.values(sessionRow(event.data.info))
.onConflictDoNothing()
.returning({ sessionID: SessionTable.id })
.get()
.pipe(Effect.orDie)
if (!stored) return yield* Effect.die(new SessionAlreadyProjected())
if (event.data.info.workspaceID) {
yield* db
.update(WorkspaceTable)
@ -361,93 +317,71 @@ export const layer = Layer.effectDiscard(
if (next) yield* applyUsage(db, sessionID, next)
}),
)
// session.next.* projectors are disabled while the v2 message projection is stabilized.
// The events still publish through EventV2 and fan out through the opencode bridge.
// yield* events.project(SessionEvent.AgentSwitched, (event) =>
// Effect.gen(function* () {
// const message = Schema.encodeSync(SessionMessage.AgentSwitched)(
// new SessionMessage.AgentSwitched({
// id: event.id,
// type: "agent-switched",
// metadata: event.metadata,
// agent: event.data.agent,
// time: { created: event.data.timestamp },
// }),
// )
// const data = { metadata: message.metadata, agent: message.agent, time: message.time }
// yield* db
// .update(SessionTable)
// .set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
// .where(eq(SessionTable.id, event.data.sessionID))
// .run()
// .pipe(Effect.orDie)
// yield* db
// .insert(SessionMessageTable)
// .values([
// {
// id: SessionMessage.ID.make(event.id),
// session_id: event.data.sessionID,
// type: "agent-switched",
// time_created: DateTime.toEpochMillis(event.data.timestamp),
// data,
// },
// ])
// .run()
// .pipe(Effect.orDie)
// }),
// )
// yield* events.project(SessionEvent.ModelSwitched, (event) =>
// Effect.gen(function* () {
// const message = Schema.encodeSync(SessionMessage.ModelSwitched)(
// new SessionMessage.ModelSwitched({
// id: event.id,
// type: "model-switched",
// metadata: event.metadata,
// model: event.data.model,
// time: { created: event.data.timestamp },
// }),
// )
// const data = { metadata: message.metadata, model: message.model, time: message.time }
// yield* db
// .update(SessionTable)
// .set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
// .where(eq(SessionTable.id, event.data.sessionID))
// .run()
// .pipe(Effect.orDie)
// yield* db
// .insert(SessionMessageTable)
// .values([
// {
// id: SessionMessage.ID.make(event.id),
// session_id: event.data.sessionID,
// type: "model-switched",
// time_created: DateTime.toEpochMillis(event.data.timestamp),
// data,
// },
// ])
// .run()
// .pipe(Effect.orDie)
// }),
// )
// yield* events.project(SessionEvent.Prompted, (event) => run(db, event))
// yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
// yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
// yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
// yield* events.project(SessionEvent.Step.Started, (event) => run(db, event))
// yield* events.project(SessionEvent.Step.Ended, (event) => run(db, event))
// yield* events.project(SessionEvent.Step.Failed, (event) => run(db, event))
// yield* events.project(SessionEvent.Text.Started, (event) => run(db, event))
// yield* events.project(SessionEvent.Text.Ended, (event) => run(db, event))
// yield* events.project(SessionEvent.Tool.Input.Started, (event) => run(db, event))
// yield* events.project(SessionEvent.Tool.Input.Ended, (event) => run(db, event))
// yield* events.project(SessionEvent.Tool.Called, (event) => run(db, event))
// yield* events.project(SessionEvent.Tool.Success, (event) => run(db, event))
// yield* events.project(SessionEvent.Tool.Failed, (event) => run(db, event))
// yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event))
// yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.AgentSwitched, (event) =>
db.update(SessionTable)
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
)
yield* events.project(SessionEvent.ModelSwitched, (event) =>
db.update(SessionTable)
.set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
)
yield* events.project(SessionEvent.Prompted, (event) =>
Effect.gen(function* () {
const existing = yield* db
.select({ id: SessionMessageTable.id })
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, event.id))
.get()
.pipe(Effect.orDie)
if (existing) return yield* Effect.die(new PromptAlreadyProjected())
yield* run(db, event)
const row = yield* db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, event.id))
.get()
.pipe(Effect.orDie)
if (!row) return yield* Effect.die("Prompt projection was not stored")
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
if (message.type !== "user") return yield* Effect.die("Prompt projection did not produce a user message")
if (event.seq === undefined)
return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
yield* SessionInput.project(db, {
id: SessionMessage.ID.make(event.id),
sessionID: event.data.sessionID,
prompt: event.data.prompt,
delivery: event.data.delivery,
timeCreated: event.data.timestamp,
promotedSeq: event.seq,
})
}),
)
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.Step.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Step.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.Step.Failed, (event) => run(db, event))
yield* events.project(SessionEvent.Text.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Text.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Input.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Input.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Called, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Progress, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Success, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Failed, (event) => run(db, event))
yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event))
// yield* events.project(SessionEvent.Retried, (event) => run(db, event))
// yield* events.project(SessionEvent.Compaction.Started, (event) => run(db, event))
// yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Delta, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event))
}),
)

View file

@ -46,4 +46,15 @@ export class Prompt extends Schema.Class<Prompt>("Prompt")({
files: Schema.Array(FileAttachment).pipe(Schema.optional),
agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
references: Schema.Array(ReferenceAttachment).pipe(Schema.optional),
}) {}
}) {
static readonly equivalence = Schema.toEquivalence(Prompt)
static fromUserMessage(input: Pick<Prompt, "text" | "files" | "agents" | "references">) {
return new Prompt({
text: input.text,
...(input.files === undefined ? {} : { files: input.files }),
...(input.agents === undefined ? {} : { agents: input.agents }),
...(input.references === undefined ? {} : { references: input.references }),
})
}
}

View file

@ -0,0 +1,183 @@
export * as SessionRunCoordinator from "./run-coordinator"
import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Scope } from "effect"
import { SessionRunner } from "./runner"
import { SessionSchema } from "./schema"
export type Mode = "run" | "wake"
/**
* Runs at most one drain chain per key while allowing different keys to drain concurrently.
*
* For each key:
*
* idle --run/wake--> draining --run/wake--> draining + one coalesced rerun --> idle
*
* `run` is an explicit drain request. It starts a chain or joins the current chain and
* upgrades a pending follow-up so the caller receives explicit-run semantics.
*
* `wake` reports that durable work may now be available. It starts a chain while idle or
* requests one coalesced follow-up while draining. Repeated wakes collapse together.
*/
export interface Coordinator<Key, A, E> {
/** Starts or joins one explicit drain generation. */
readonly run: (key: Key) => Effect.Effect<A, E>
/** Coalesces one wake-up after durable work is recorded. */
readonly wake: (key: Key) => Effect.Effect<void>
/** Waits until the current ownership chain settles. */
readonly awaitIdle: (key: Key) => Effect.Effect<void, E>
}
type Entry<A, E> = {
readonly done: Deferred.Deferred<A, E>
mode: Mode
rerun?: Mode
explicit?: Deferred.Deferred<A, E>
}
const strongest = (left: Mode | undefined, right: Mode): Mode => (left === "run" || right === "run" ? "run" : "wake")
/** Constructs a scoped coordinator. Every in-memory transition is synchronous. */
export const make = <Key, A, E>(options: {
readonly drain: (key: Key, mode: Mode) => Effect.Effect<A, E>
readonly onFailure?: (key: Key, cause: Cause.Cause<E>) => Effect.Effect<void>
}): Effect.Effect<Coordinator<Key, A, E>, never, Scope.Scope> =>
Effect.gen(function* () {
const active = new Map<Key, Entry<A, E>>()
const scope = yield* Effect.scope
const fork = yield* FiberSet.makeRuntime<never, void, never>()
const shutdown = Deferred.makeUnsafe<void>()
let closed = false
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
closed = true
Deferred.doneUnsafe(shutdown, Effect.void)
active.clear()
}),
)
const makeEntry = (mode: Mode, explicit?: Deferred.Deferred<A, E>): Entry<A, E> => ({
done: Deferred.makeUnsafe<A, E>(),
mode,
explicit,
})
const start = (key: Key, entry: Entry<A, E>, mode: Mode) => {
fork(own(key, entry, mode))
}
const own = (key: Key, entry: Entry<A, E>, mode: Mode): Effect.Effect<void> =>
Effect.suspend(() => options.drain(key, mode)).pipe(
Effect.exit,
Effect.flatMap((exit) => {
if (closed) return Deferred.done(entry.done, exit).pipe(Effect.asVoid)
if (mode === "run" && entry.explicit !== undefined) {
Deferred.doneUnsafe(entry.explicit, exit)
entry.explicit = undefined
}
if (exit._tag === "Success") {
if (active.get(key) !== entry) return Deferred.done(entry.done, exit).pipe(Effect.asVoid)
if (entry.rerun !== undefined) {
const mode = entry.rerun
entry.rerun = undefined
entry.mode = mode
return own(key, entry, mode)
}
active.delete(key)
return Deferred.done(entry.done, exit).pipe(Effect.asVoid)
}
const successor =
active.get(key) === entry && entry.rerun !== undefined ? makeEntry(entry.rerun, entry.explicit) : undefined
if (successor === undefined) active.delete(key)
else {
active.set(key, successor)
}
if (successor !== undefined) start(key, successor, successor.mode)
const report =
mode === "wake" && options.onFailure !== undefined
? options.onFailure(key, exit.cause).pipe(Effect.forkIn(scope), Effect.asVoid)
: Effect.void
return Deferred.done(entry.done, exit).pipe(Effect.andThen(report), Effect.asVoid)
}),
)
const wake = (key: Key) =>
Effect.sync(() => {
if (closed) return
const entry = active.get(key)
if (entry !== undefined) {
entry.rerun = strongest(entry.rerun, "wake")
return
}
const next = makeEntry("wake")
active.set(key, next)
start(key, next, "wake")
})
const awaitIdle = (key: Key): Effect.Effect<void, E> =>
Effect.gen(function* () {
let firstFailure: Cause.Cause<E> | undefined
while (!closed) {
const entry = active.get(key)
if (entry === undefined) break
const exit = yield* Effect.raceFirst(
Deferred.await(entry.done).pipe(Effect.exit),
Deferred.await(shutdown).pipe(Effect.as(Exit.void)),
)
if (closed) break
if (exit._tag === "Failure" && firstFailure === undefined) firstFailure = exit.cause
}
if (firstFailure !== undefined) return yield* Effect.failCause(firstFailure)
})
return { run, wake, awaitIdle }
function run(key: Key): Effect.Effect<A, E> {
return Effect.uninterruptibleMask((restore) => {
if (closed) return Effect.interrupt
const entry = active.get(key)
if (entry !== undefined) {
if (entry.mode === "wake") {
entry.rerun = "run"
entry.explicit ??= Deferred.makeUnsafe<A, E>()
return restore(awaitRun(entry.explicit))
}
return restore(awaitRun(entry.done))
}
const next = makeEntry("run")
active.set(key, next)
start(key, next, "run")
return restore(awaitRun(next.done))
})
}
function awaitRun(done: Deferred.Deferred<A, E>): Effect.Effect<A, E> {
return Effect.raceFirst(Deferred.await(done), Deferred.await(shutdown).pipe(Effect.andThen(Effect.interrupt)))
}
})
export interface Interface extends Coordinator<SessionSchema.ID, void, SessionRunner.RunError> {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionRunCoordinator") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const runner = yield* SessionRunner.Service
return Service.of(
yield* make<SessionSchema.ID, void, SessionRunner.RunError>({
drain: (sessionID, mode) => runner.run({ sessionID, force: mode === "run" }),
onFailure: (sessionID, cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.void
: Effect.logError("Failed to drain Session").pipe(
Effect.annotateLogs("sessionID", sessionID),
Effect.annotateLogs("cause", cause),
),
}),
)
}),
)

View file

@ -0,0 +1,28 @@
export * as SessionRunner from "./index"
import type { LLMError } from "@opencode-ai/llm"
import { Context, Effect, Schema } from "effect"
import { SessionSchema } from "../schema"
import type { MessageDecodeError } from "../error"
import { SessionRunnerModel } from "./model"
export class StepLimitExceededError extends Schema.TaggedErrorClass<StepLimitExceededError>()(
"SessionRunner.StepLimitExceededError",
{
sessionID: SessionSchema.ID,
limit: Schema.Int,
},
) {}
export type RunError = LLMError | SessionRunnerModel.Error | MessageDecodeError | StepLimitExceededError
/** Runs one local continuation from already-recorded Session history. */
export interface Interface {
/** Drains eligible durable work. Explicit runs perform one provider attempt even when no work is eligible. */
readonly run: (input: {
readonly sessionID: SessionSchema.ID
readonly force?: boolean
}) => Effect.Effect<void, RunError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionRunner") {}

View file

@ -0,0 +1,262 @@
import { LLM, LLMClient, LLMError, LLMEvent } from "@opencode-ai/llm"
import { Cause, DateTime, Effect, FiberSet, Layer, Semaphore, Stream } from "effect"
import { EventV2 } from "../../event"
import { ModelV2 } from "../../model"
import { ProviderV2 } from "../../provider"
import { SessionSchema } from "../schema"
import { SessionEvent } from "../event"
import { SessionStore } from "../store"
import { Service, StepLimitExceededError } from "./index"
import { createLLMEventPublisher } from "./publish-llm-event"
import { toLLMMessages } from "./to-llm-message"
import { ToolRegistry } from "../../tool-registry"
import { SessionRunnerModel } from "./model"
import { Database } from "../../database/database"
import { SessionInput } from "../input"
import { QuestionV2 } from "../../question"
/**
* Runs one durable coding-agent Session until it settles.
*
* Keep this as orchestration over smaller collaborators rather than rebuilding the legacy
* `SessionPrompt` monolith. Implement the unchecked items in small reviewed slices:
*
* - Session ownership and controls
* - [x] Coordinate one local active drain per Session; explicit resumes join and prompt wakeups coalesce.
* - [ ] Replace local ownership with durable multi-node ownership when clustered.
* - [ ] Mark busy, retrying, idle, interrupted, or terminal-failure status durably.
* - [ ] Honor interruption and reject stale work after runtime attachment replacement.
* - [x] Bound model steps.
* - [ ] Bound provider retries and repeated identical tool calls.
*
* - Runtime context assembly
* - [x] Load Session placement and chronological projected V2 history.
* - [x] Resolve the selected model through the location-scoped runner environment.
* - [ ] Load the selected agent and effective permissions.
* - [ ] Build provider/model-specific base instructions and environment facts.
* - [ ] Load configured project instructions such as `AGENTS.md`, remote instructions, and
* nearby nested instructions discovered while files are read.
* - [ ] List available skills in the system prompt and expose a tool for loading skill bodies.
* - [ ] Resolve referenced files, directories, agents, repositories, MCP resources, and media.
* - [ ] Apply steering reminders, plugin transforms, and structured-output policy.
* - [ ] Compact or summarize history when context pressure requires it.
*
* - One provider turn
* - [x] Translate every projected V2 Session message variant into canonical
* `@opencode-ai/llm` messages.
* - [ ] Resolve policy-filtered built-in, MCP, plugin, and structured-output tool definitions.
* - [x] Stream exactly one `llm.stream(request)` provider turn.
* - [x] Persist assistant text and usage events incrementally as they arrive.
* - [ ] Persist snapshots, patches, and retry notices incrementally as they arrive.
* - [x] Persist reasoning, provider errors, and tool-call events incrementally as they arrive.
*
* - Tool settlement and continuation
* - [x] Durably record each tool call before side effects begin.
* - [x] Authorize and execute recorded local calls through a core-owned registry hook.
* - [x] Persist typed success, failure, and provider-executed tool outcomes.
* - [x] Start each recorded local call eagerly and await all settlements before continuation.
* - [ ] Add scoped runtime context, progress updates, output truncation, attachment normalization,
* plugins, and cancellation settlement.
* - [x] Reload projected history and start the next explicit provider turn after local tool results.
* - [x] Continue for durable user steering accepted during an active provider turn.
* - [ ] Continue for compaction or another continuation condition when required.
*
* - Post-run maintenance
* - [ ] Settle final status and expose durable output events to replayable consumers.
* - [ ] Coalesce streamed deltas and add covering projected-history indexes.
* - [ ] Update title, summaries, compaction state, and cleanup in bounded background work.
*
* Use `llm.stream(request)` for each provider turn. Keep tool execution and continuation here.
* Durable activity recovery remains a separate future slice with an explicit retry policy.
*
* The current slice loads V2 history, translates it, resolves a model through a core service, and persists one
* provider turn. Registry definitions are advertised, local tool calls are settled durably, and a
* bounded explicit loop starts the next provider turn after local settlement.
*/
// QUESTION: Did this exist previously, or did we add this limit? Does it make sense?
const MAX_STEPS = 25
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
const llm = yield* LLMClient.Service
const tools = yield* ToolRegistry.Service
const models = yield* SessionRunnerModel.Service
const store = yield* SessionStore.Service
const db = (yield* Database.Service).db
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(`Session not found: ${sessionID}`)
return session
})
const getContext = Effect.fn("SessionRunner.getContext")(function* (sessionID: SessionSchema.ID) {
return yield* store.context(sessionID)
})
const failInterruptedTools = Effect.fn("SessionRunner.failInterruptedTools")(function* (
sessionID: SessionSchema.ID,
) {
for (const message of yield* getContext(sessionID)) {
if (message.type !== "assistant") continue
for (const tool of message.content) {
if (tool.type !== "tool" || (tool.state.status !== "pending" && tool.state.status !== "running")) continue
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID,
timestamp: yield* DateTime.now,
assistantMessageID: message.id,
callID: tool.id,
error: { type: "unknown", message: "Tool execution interrupted" },
provider: {
executed: tool.provider?.executed === true,
...(tool.provider?.metadata === undefined ? {} : { metadata: tool.provider.metadata }),
},
})
}
}
})
const awaitToolFibers = (fibers: FiberSet.FiberSet<void, never>) =>
Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers))
// Match V1: dismissing a question halts the loop instead of becoming model-facing tool output.
const isQuestionRejected = (cause: Cause.Cause<unknown>) =>
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError)
const runTurn = Effect.fn("SessionRunner.runTurn")(function* (
session: SessionSchema.Info,
promotion: "steer" | "queue" | undefined,
) {
const model = yield* models.resolve(session)
const toolFibers = yield* FiberSet.make<void, never>()
let needsContinuation = false
if (promotion === "steer") yield* SessionInput.promoteSteers(db, events, session.id)
if (promotion === "queue") {
yield* SessionInput.promoteNextQueued(db, events, session.id)
yield* SessionInput.promoteSteers(db, events, session.id)
}
yield* failInterruptedTools(session.id)
const context = yield* getContext(session.id)
const request = LLM.request({ model, messages: toLLMMessages(context, model), tools: yield* tools.definitions() })
const publisher = createLLMEventPublisher(events, {
sessionID: session.id,
agent: session.agent ?? "build",
model: {
id: ModelV2.ID.make(model.id),
providerID: ProviderV2.ID.make(model.provider),
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
},
})
const withPublication = Semaphore.makeUnsafe(1).withPermit
const publish = (event: LLMEvent) => withPublication(publisher.publish(event))
const providerStream = llm.stream(request).pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
yield* publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return
needsContinuation = true
yield* tools.settle({ sessionID: session.id, call: event }).pipe(
Effect.catchCause((cause) => {
if (isQuestionRejected(cause)) return Effect.failCause(cause)
return Effect.succeed({
result: { type: "error" as const, value: String(Cause.squash(cause)) },
output: undefined,
})
}),
Effect.flatMap((settlement) =>
publish(
LLMEvent.toolResult({
id: event.id,
name: event.name,
result: settlement.result,
output: settlement.output,
}),
),
),
FiberSet.run(toolFibers),
)
}),
),
Effect.ensuring(withPublication(publisher.flush())),
)
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const stream = yield* restore(providerStream).pipe(Effect.exit)
let llmFailure: LLMError | undefined
if (stream._tag === "Failure") {
for (const reason of stream.cause.reasons) {
if (!Cause.isFailReason(reason)) continue
if (reason.error instanceof LLMError) llmFailure = reason.error
}
}
if (llmFailure && !publisher.hasProviderError()) {
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
yield* withPublication(
events.publish(SessionEvent.Step.Failed, {
sessionID: session.id,
timestamp: yield* DateTime.now,
assistantMessageID: yield* publisher.startAssistant(),
error: { type: "unknown", message: llmFailure.reason.message },
}),
)
}
if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers)
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
if (settled._tag === "Failure" && isQuestionRejected(settled.cause)) {
yield* FiberSet.clear(toolFibers)
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
return yield* Effect.interrupt
}
if (
(stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) ||
(settled._tag === "Failure" && Cause.hasInterrupts(settled.cause))
) {
yield* FiberSet.clear(toolFibers)
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
}
if (publisher.hasProviderError())
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
if (stream._tag === "Success" && !publisher.hasProviderError())
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
const attempt = stream._tag === "Failure" ? stream : settled
if (attempt._tag === "Failure") return yield* Effect.failCause(attempt.cause)
return !publisher.hasProviderError() && needsContinuation
}),
)
}, Effect.scoped)
const run = Effect.fn("SessionRunner.run")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly force?: boolean
}) {
const session = yield* getSession(input.sessionID)
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, ["steer"])
const hasQueue = yield* SessionInput.hasPending(db, input.sessionID, ["queue"])
if (input.force !== true && !hasSteer && !hasQueue) return
let promotion: "steer" | "queue" | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined
let openActivity = input.force === true || hasSteer || hasQueue
while (openActivity) {
let needsContinuation = true
for (let step = 0; step < MAX_STEPS; step++) {
needsContinuation = yield* runTurn(session, promotion)
promotion = "steer"
if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, ["steer"])
if (!needsContinuation) break
}
if (needsContinuation)
return yield* new StepLimitExceededError({ sessionID: input.sessionID, limit: MAX_STEPS })
openActivity = yield* SessionInput.hasPending(db, input.sessionID, ["queue"])
promotion = openActivity ? "queue" : undefined
}
})
return Service.of({
run,
})
}),
)
export const defaultLayer = layer

View file

@ -0,0 +1,141 @@
export * as SessionRunnerModel from "./model"
import { type Model } from "@opencode-ai/llm"
import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-messages"
import * as OpenAICompatibleChat from "@opencode-ai/llm/protocols/openai-compatible-chat"
import * as OpenAIResponses from "@opencode-ai/llm/protocols/openai-responses"
import { Auth, type AnyRoute } from "@opencode-ai/llm/route"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { produce } from "immer"
import { Catalog } from "../../catalog"
import { ModelV2 } from "../../model"
import { PluginBoot } from "../../plugin/boot"
import { ProviderV2 } from "../../provider"
import { SessionSchema } from "../schema"
export class ModelNotSelectedError extends Schema.TaggedErrorClass<ModelNotSelectedError>()(
"SessionRunnerModel.ModelNotSelectedError",
{
sessionID: SessionSchema.ID,
},
) {}
export class UnsupportedApiError extends Schema.TaggedErrorClass<UnsupportedApiError>()(
"SessionRunnerModel.UnsupportedApiError",
{
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
api: Schema.String,
},
) {}
export type Error =
| Catalog.ProviderNotFoundError
| Catalog.ModelNotFoundError
| ModelNotSelectedError
| UnsupportedApiError
export interface Interface {
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Model, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionRunnerModel") {}
/** Test or embedding seam for supplying a model resolver directly. */
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
const apiKey = (model: ModelV2.Info, provider?: ProviderV2.Info) => {
const value = model.request.body.apiKey ?? model.api.settings?.apiKey
if (typeof value === "string") return Auth.value(value)
return provider?.enabled !== false && provider?.enabled.via === "env" ? Auth.config(provider.enabled.name) : undefined
}
const withDefaults = (model: ModelV2.Info, route: AnyRoute) =>
route.with({
provider: model.providerID,
endpoint: model.api.url === undefined ? undefined : { baseURL: model.api.url },
headers: model.request.headers,
http: {
body: Object.fromEntries(Object.entries(model.request.body).filter(([key]) => key !== "apiKey")),
},
limits: { context: model.limit.context, output: model.limit.output },
})
const withVariant = (model: ModelV2.Info, variantID: ModelV2.VariantID | undefined) => {
const id = variantID === "default" || variantID === undefined ? model.request.variant : variantID
const variant = model.variants.find((item) => item.id === id)
if (!variant) return model
return produce(model, (draft) => {
Object.assign(draft.request.headers, variant.headers)
Object.assign(draft.request.body, variant.body)
})
}
const apiName = (model: ModelV2.Info) =>
model.api.type === "aisdk" ? `${model.api.type}:${model.api.package}` : model.api.type
export const fromCatalogModel = (
model: ModelV2.Info,
provider?: ProviderV2.Info,
): Effect.Effect<Model, UnsupportedApiError> => {
const key = apiKey(model, provider)
if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/openai") {
return Effect.succeed(
withDefaults(model, OpenAIResponses.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: model.api.id }),
)
}
if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/anthropic") {
return Effect.succeed(
withDefaults(model, AnthropicMessages.route)
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
.model({ id: model.api.id }),
)
}
if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/openai-compatible" && model.api.url) {
return Effect.succeed(
withDefaults(model, OpenAICompatibleChat.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: model.api.id }),
)
}
return Effect.fail(
new UnsupportedApiError({
providerID: model.providerID,
modelID: model.id,
api: apiName(model),
}),
)
}
export const resolve = (session: SessionSchema.Info, model: ModelV2.Info, provider?: ProviderV2.Info) =>
fromCatalogModel(withVariant(model, session.model?.variant), provider)
export const supported = (model: ModelV2.Info) =>
model.api.type === "aisdk" &&
(model.api.package === "@ai-sdk/openai" ||
model.api.package === "@ai-sdk/anthropic" ||
(model.api.package === "@ai-sdk/openai-compatible" && model.api.url !== undefined))
/** Resolves models from the catalog belonging to the current Location runtime. */
export const locationLayer = Layer.effect(
Service,
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const boot = yield* PluginBoot.Service
return Service.of({
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
// Location plugins populate and filter the catalog asynchronously during layer startup.
yield* boot.wait()
const preferred = yield* catalog.model.default()
const selected = session.model
? yield* catalog.model.get(session.model.providerID, session.model.id)
: (Option.getOrUndefined(preferred.pipe(Option.filter(supported))) ??
(yield* catalog.model.available()).find(supported))
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
return yield* resolve(session, selected, yield* catalog.provider.get(selected.providerID))
}),
})
}),
)

View file

@ -0,0 +1,355 @@
import { ToolOutput as LLMToolOutput, type LLMEvent, type ProviderMetadata, type ToolOutput as LLMToolOutputType, type ToolResultValue, type Usage } from "@opencode-ai/llm"
import { DateTime, Effect } from "effect"
import { EventV2 } from "../../event"
import { ModelV2 } from "../../model"
import { SessionEvent } from "../event"
import { SessionSchema } from "../schema"
type Input = {
readonly sessionID: SessionSchema.ID
readonly agent: string
readonly model: ModelV2.Ref
}
const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0)
const tokens = (usage: Usage | undefined) => {
const reasoning = safe(usage?.reasoningTokens)
const read = safe(usage?.cacheReadInputTokens)
const write = safe(usage?.cacheWriteInputTokens)
return {
input: safe(usage?.nonCachedInputTokens),
output: safe(usage?.visibleOutputTokens),
reasoning,
cache: { read, write },
}
}
const record = (value: unknown): Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : { value }
const message = (value: unknown) => {
if (typeof value === "string") return value
try {
return JSON.stringify(value) ?? String(value)
} catch {
return String(value)
}
}
type ToolOutput =
| { readonly structured: Record<string, unknown>; readonly content: LLMToolOutputType["content"] }
| { readonly error: { readonly type: "unknown"; readonly message: string } }
const settledOutput = (value: LLMToolOutputType | undefined, result: ToolResultValue): ToolOutput => {
if (result.type === "error") return { error: { type: "unknown", message: message(result.value) } }
const settled = value ?? LLMToolOutput.fromResultValue(result)
if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`)
return { structured: record(settled.structured), content: settled.content }
}
/** Persist one provider turn without executing tools or starting a continuation turn. */
export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) => {
const tools = new Map<
string,
{ readonly assistantMessageID: EventV2.ID; readonly name: string; inputEnded: boolean; called: boolean; settled: boolean; providerExecuted: boolean; providerMetadata?: ProviderMetadata }
>()
const timestamp = DateTime.now
let assistantMessageID: EventV2.ID | undefined
let providerFailed = false
const startAssistant = Effect.fnUntraced(function* () {
if (assistantMessageID !== undefined) return assistantMessageID
assistantMessageID = (yield* events.publish(SessionEvent.Step.Started, { ...input, timestamp: yield* timestamp })).id
return assistantMessageID
})
const currentAssistantMessageID = () => assistantMessageID === undefined
? Effect.die("Tool event before assistant step start")
: Effect.succeed(assistantMessageID)
const fragments = (name: string, ended: (id: string, value: string, providerMetadata?: ProviderMetadata) => Effect.Effect<void>) => {
const chunks = new Map<string, string[]>()
const start = (id: string) =>
Effect.suspend(() => {
if (chunks.has(id)) return Effect.die(`Duplicate ${name} start: ${id}`)
chunks.set(id, [])
return Effect.void
})
const append = (id: string, value: string) =>
Effect.suspend(() => {
const current = chunks.get(id)
if (!current) return Effect.die(`${name} delta before start: ${id}`)
current.push(value)
return Effect.void
})
const end = Effect.fnUntraced(function* (id: string, providerMetadata?: ProviderMetadata) {
const current = chunks.get(id)
if (!current) return yield* Effect.die(`${name} end before start: ${id}`)
yield* ended(id, current.join(""), providerMetadata)
chunks.delete(id)
})
const flush = Effect.fnUntraced(function* () {
for (const id of chunks.keys()) yield* end(id)
})
return { start, append, end, flush }
}
const text = fragments("text", (textID, value) =>
Effect.gen(function* () {
yield* events.publish(SessionEvent.Text.Ended, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
textID,
text: value,
})
}),
)
const reasoning = fragments("reasoning", (reasoningID, value, providerMetadata) =>
Effect.gen(function* () {
yield* events.publish(SessionEvent.Reasoning.Ended, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
reasoningID,
text: value,
providerMetadata,
})
}),
)
const toolInput = fragments("tool input", (callID, value) =>
Effect.gen(function* () {
const tool = tools.get(callID)
if (!tool) return yield* Effect.die(`Tool input end before start: ${callID}`)
yield* events.publish(SessionEvent.Tool.Input.Ended, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID: tool.assistantMessageID,
callID,
text: value,
})
tool.inputEnded = true
}),
)
const flushFragments = Effect.fnUntraced(function* () {
yield* text.flush()
yield* reasoning.flush()
yield* toolInput.flush()
})
const startToolInput = Effect.fnUntraced(function* (event: { readonly id: string; readonly name: string }) {
if (tools.has(event.id)) return yield* Effect.die(`Duplicate tool input start: ${event.id}`)
const assistantMessageID = yield* currentAssistantMessageID()
tools.set(event.id, { assistantMessageID, name: event.name, inputEnded: false, called: false, settled: false, providerExecuted: false })
yield* toolInput.start(event.id)
yield* events.publish(SessionEvent.Tool.Input.Started, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID,
callID: event.id,
name: event.name,
})
})
const endToolInput = Effect.fnUntraced(function* (event: { readonly id: string; readonly name: string }) {
const tool = tools.get(event.id)
if (!tool) return yield* Effect.die(`Tool input end before start: ${event.id}`)
if (tool.name !== event.name) return yield* Effect.die(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`)
if (tool.inputEnded) return yield* Effect.die(`Duplicate tool input end: ${event.id}`)
yield* toolInput.end(event.id)
})
const flush = Effect.fn("SessionRunner.flush")(function* () {
yield* flushFragments()
})
const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* (
message: string,
hostedOnly = false,
) {
for (const [callID, tool] of tools) {
if (tool.settled || (hostedOnly && !tool.providerExecuted)) continue
tool.settled = true
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID: tool.assistantMessageID,
callID,
error: { type: "unknown", message },
provider: {
executed: tool.providerExecuted,
...(tool.providerMetadata === undefined ? {} : { metadata: tool.providerMetadata }),
},
})
}
})
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent) {
switch (event.type) {
case "step-start":
yield* startAssistant()
return
case "text-start":
yield* text.start(event.id)
yield* events.publish(SessionEvent.Text.Started, { sessionID: input.sessionID, timestamp: yield* timestamp, textID: event.id })
return
case "text-delta":
yield* text.append(event.id, event.text)
yield* events.publish(SessionEvent.Text.Delta, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
textID: event.id,
delta: event.text,
})
return
case "text-end":
yield* text.end(event.id)
return
case "reasoning-start":
yield* reasoning.start(event.id)
yield* events.publish(SessionEvent.Reasoning.Started, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
reasoningID: event.id,
providerMetadata: event.providerMetadata,
})
return
case "reasoning-delta":
yield* reasoning.append(event.id, event.text)
yield* events.publish(SessionEvent.Reasoning.Delta, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
reasoningID: event.id,
delta: event.text,
})
return
case "reasoning-end":
yield* reasoning.end(event.id, event.providerMetadata)
return
case "tool-input-start":
yield* startToolInput(event)
return
case "tool-input-delta": {
const tool = tools.get(event.id)
if (!tool) return yield* Effect.die(`Tool input delta before start: ${event.id}`)
if (tool.name !== event.name) return yield* Effect.die(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`)
if (tool.inputEnded) return yield* Effect.die(`Tool input delta after end: ${event.id}`)
yield* toolInput.append(event.id, event.text)
yield* events.publish(SessionEvent.Tool.Input.Delta, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
delta: event.text,
})
return
}
case "tool-input-end":
yield* endToolInput(event)
return
case "tool-call": {
if (!tools.has(event.id)) yield* startToolInput(event)
const tool = tools.get(event.id)!
if (!tool.inputEnded) yield* endToolInput(event)
if (tool.name !== event.name) return yield* Effect.die(`Tool call name changed for ${event.id}: ${tool.name} -> ${event.name}`)
if (tool.called) return yield* Effect.die(`Duplicate tool call: ${event.id}`)
tool.called = true
tool.providerExecuted = event.providerExecuted === true
tool.providerMetadata = event.providerMetadata
yield* events.publish(SessionEvent.Tool.Called, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
tool: event.name,
input: record(event.input),
provider: {
executed: tool.providerExecuted,
...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }),
},
})
return
}
case "tool-result": {
const tool = tools.get(event.id)
if (!tool?.called) return yield* Effect.die(`Tool result before call: ${event.id}`)
if (tool.name !== event.name) return yield* Effect.die(`Tool result name changed for ${event.id}: ${tool.name} -> ${event.name}`)
if (tool.settled) {
if (event.result.type === "error") return
return yield* Effect.die(`Duplicate tool result: ${event.id}`)
}
tool.settled = true
const result = settledOutput(event.output, event.result)
const provider = {
executed: event.providerExecuted === true || tool.providerExecuted,
...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }),
}
if ("error" in result) {
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
error: result.error,
result: event.result,
provider,
})
return
}
yield* events.publish(SessionEvent.Tool.Success, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
...result,
result: event.result,
provider,
})
return
}
case "tool-error": {
const tool = tools.get(event.id)
if (!tool?.called) return yield* Effect.die(`Tool error before call: ${event.id}`)
if (tool.name !== event.name) return yield* Effect.die(`Tool error name changed for ${event.id}: ${tool.name} -> ${event.name}`)
if (tool.settled) return yield* Effect.die(`Duplicate tool error: ${event.id}`)
tool.settled = true
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
error: { type: "unknown", message: event.message },
provider: {
executed: tool.providerExecuted,
...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }),
},
})
return
}
case "step-finish":
yield* flush()
yield* events.publish(SessionEvent.Step.Ended, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID: yield* currentAssistantMessageID(),
finish: event.reason,
cost: 0,
tokens: tokens(event.usage),
})
return
case "finish":
return
case "provider-error":
providerFailed = true
yield* flush()
yield* events.publish(SessionEvent.Step.Failed, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID: yield* startAssistant(),
error: { type: "unknown", message: event.message },
})
return
}
})
return { publish, flush, failUnsettledTools, hasProviderError: () => providerFailed, startAssistant }
}

View file

@ -0,0 +1,130 @@
import { Message, ToolCallPart, ToolOutput, ToolResultPart, type ContentPart, type Model, type ProviderMetadata } from "@opencode-ai/llm"
import { SessionMessage } from "../message"
import type { FileAttachment } from "../prompt"
const media = (file: FileAttachment): ContentPart => ({
type: "media",
mediaType: file.mime,
data: file.uri,
filename: file.name,
metadata: file.description === undefined ? undefined : { description: file.description },
})
const toolInput = (tool: SessionMessage.AssistantTool) => {
if (tool.state.status !== "pending") return tool.state.input
try {
return JSON.parse(tool.state.input) as unknown
} catch {
return tool.state.input
}
}
const toolCall = (tool: SessionMessage.AssistantTool, providerMetadata: ProviderMetadata | undefined): ContentPart =>
ToolCallPart.make({
id: tool.id,
name: tool.name,
input: toolInput(tool),
providerExecuted: tool.provider?.executed,
providerMetadata,
})
const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: ProviderMetadata | undefined) => {
if (tool.state.status === "completed") {
// TODO: Materialize remote URL and managed file sources before provider-history lowering.
// ToolOutput.toResultValue intentionally rejects unmaterialized sources rather than
// guessing whether a provider can fetch them or leaking host-local resource paths.
const result =
tool.provider?.executed === true && tool.state.result !== undefined
? tool.state.result
: ToolOutput.toResultValue({ structured: tool.state.structured, content: tool.state.content })
return ToolResultPart.make({
id: tool.id,
name: tool.name,
result,
providerExecuted: tool.provider?.executed,
providerMetadata,
})
}
if (tool.state.status === "error") {
return ToolResultPart.make({
id: tool.id,
name: tool.name,
result:
tool.provider?.executed === true && tool.state.result !== undefined
? tool.state.result
: { error: tool.state.error, content: tool.state.content, structured: tool.state.structured },
resultType: "error",
providerExecuted: tool.provider?.executed,
providerMetadata,
})
}
}
const assistant = (message: SessionMessage.Assistant, model: Model) => {
const sameModel = String(message.model.providerID) === String(model.provider) && String(message.model.id) === String(model.id)
const content = message.content.flatMap((item): ContentPart[] => {
if (item.type === "text") return [{ type: "text", text: item.text }]
if (item.type === "reasoning")
return sameModel
? [{ type: "reasoning", text: item.text, providerMetadata: item.providerMetadata }]
: item.text.length > 0
? [{ type: "text", text: item.text }]
: []
const call = toolCall(item, sameModel ? item.provider?.metadata : undefined)
const result = toolResult(item, sameModel ? item.provider?.resultMetadata ?? item.provider?.metadata : undefined)
return item.provider?.executed === true && result ? [call, result] : [call]
})
const results = message.content
.filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true)
.map((item) => toolResult(item, sameModel ? item.provider?.resultMetadata ?? item.provider?.metadata : undefined))
.filter((message) => message !== undefined)
.map(Message.tool)
return [Message.make({ id: message.id, role: "assistant", content, metadata: message.metadata }), ...results]
}
function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] {
switch (message.type) {
case "agent-switched":
case "model-switched":
return []
case "user":
return [
Message.make({
id: message.id,
role: "user",
content: [{ type: "text", text: message.text }, ...(message.files ?? []).map(media)],
metadata: {
...message.metadata,
...(message.agents?.length ? { agents: message.agents } : {}),
...(message.references?.length ? { references: message.references } : {}),
},
}),
]
case "synthetic":
return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })]
case "shell":
return [
Message.make({
id: message.id,
role: "user",
content: `Shell command: ${message.command}\n\n${message.output}`,
metadata: message.metadata,
}),
]
case "assistant":
return assistant(message, model)
case "compaction":
return [
Message.make({
id: message.id,
role: "user",
content: `Summary of earlier conversation:\n${message.summary}`,
metadata: message.metadata,
}),
]
}
}
/** Translate projected V2 Session history into canonical @opencode-ai/llm context. */
export const toLLMMessages = (messages: readonly SessionMessage.Message[], model: Model) =>
messages.flatMap((message) => toLLMMessage(message, model))

View file

@ -4,23 +4,21 @@ import { Schema } from "effect"
import { Location } from "../location"
import { ModelV2 } from "../model"
import { ProjectV2 } from "../project"
import { RelativePath, optionalOmitUndefined, withStatics } from "../schema"
import { externalID, type ExternalID, RelativePath, optionalOmitUndefined, withStatics } from "../schema"
import { Identifier } from "../util/identifier"
import { V2Schema } from "../v2-schema"
import { AgentV2 } from "../agent"
export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({
identifier: "Session.Delivery",
})
export type Delivery = Schema.Schema.Type<typeof Delivery>
export const DefaultDelivery = "immediate" satisfies Delivery
export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
Schema.brand("SessionID"),
withStatics((schema) => ({
descending: (id?: string) => schema.make(id ?? "ses_" + Identifier.descending()),
})),
withStatics((schema) => {
const create = () => schema.make("ses_" + Identifier.descending())
return {
create,
descending: (id?: string) => id === undefined ? create() : schema.make(id),
fromExternal: (input: ExternalID) => schema.make(externalID("ses", input)),
}
}),
)
export type ID = typeof ID.Type

View file

@ -2,6 +2,8 @@ import { sqliteTable, text, integer, index, primaryKey, real } from "drizzle-orm
import * as DatabasePath from "../database/path"
import { ProjectTable } from "../project/sql"
import type { SessionMessage } from "./message"
import type { Prompt } from "./prompt"
import type { SessionInput } from "./input"
import type { Snapshot } from "../snapshot"
import { PermissionV1 } from "../v1/permission"
import { ProjectV2 } from "../project"
@ -120,12 +122,40 @@ export const SessionMessageTable = sqliteTable(
.notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }),
type: text().$type<SessionMessage.Type>().notNull(),
seq: integer().notNull(),
...Timestamps,
data: text({ mode: "json" }).notNull().$type<SessionMessageData>(),
},
(table) => [
index("session_message_session_idx").on(table.session_id),
index("session_message_session_type_idx").on(table.session_id, table.type),
index("session_message_session_seq_idx").on(table.session_id, table.seq),
index("session_message_session_type_seq_idx").on(table.session_id, table.type, table.seq),
index("session_message_session_time_created_id_idx").on(table.session_id, table.time_created, table.id),
index("session_message_time_created_idx").on(table.time_created),
],
)
export const SessionInputTable = sqliteTable(
"session_input",
{
seq: integer().primaryKey({ autoIncrement: true }),
id: text().$type<SessionMessage.ID>().notNull().unique(),
session_id: text()
.$type<SessionSchema.ID>()
.notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }),
prompt: text({ mode: "json" }).notNull().$type<Prompt>(),
delivery: text().$type<SessionInput.Delivery>().notNull(),
promoted_seq: integer(),
time_created: integer()
.notNull()
.$default(() => Date.now()),
},
(table) => [
index("session_input_session_pending_delivery_seq_idx").on(
table.session_id,
table.promoted_seq,
table.delivery,
table.seq,
),
],
)

View file

@ -0,0 +1,53 @@
export * as SessionStore from "./store"
import { eq } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database"
import { SessionContext } from "./context"
import { MessageDecodeError } from "./error"
import { SessionMessage } from "./message"
import { SessionSchema } from "./schema"
import { SessionMessageTable, SessionTable } from "./sql"
import { fromRow } from "./info"
export interface Interface {
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info | undefined>
readonly context: (sessionID: SessionSchema.ID) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
readonly message: (
messageID: SessionMessage.ID,
) => Effect.Effect<{ readonly sessionID: SessionSchema.ID; readonly message: SessionMessage.Message } | undefined>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionStore") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { db } = yield* Database.Service
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
return Service.of({
get: Effect.fn("SessionStore.get")(function* (sessionID) {
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)
return row ? fromRow(row) : undefined
}),
context: Effect.fn("SessionStore.context")(function* (sessionID) {
return yield* SessionContext.load(db, sessionID)
}),
message: Effect.fn("SessionStore.message")(function* (messageID) {
const row = yield* db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, messageID))
.get()
.pipe(Effect.orDie)
return row
? {
sessionID: SessionSchema.ID.make(row.session_id),
message: yield* decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie),
}
: undefined
}),
})
}),
)

View file

@ -0,0 +1,91 @@
export * as SessionTodo from "./todo"
import { asc, eq } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { SessionSchema } from "./schema"
import { TodoTable } from "./sql"
export const Info = Schema.Struct({
content: Schema.String.annotate({ description: "Brief description of the task" }),
status: Schema.String.annotate({
description: "Current status of the task: pending, in_progress, completed, cancelled",
}),
priority: Schema.String.annotate({ description: "Priority level of the task: high, medium, low" }),
}).annotate({ identifier: "SessionTodo.Info" })
export type Info = typeof Info.Type
export const Event = {
Updated: EventV2.define({
type: "todo.updated",
schema: {
sessionID: SessionSchema.ID,
todos: Schema.Array(Info),
},
}),
}
export interface Interface {
readonly update: (input: {
readonly sessionID: SessionSchema.ID
readonly todos: ReadonlyArray<Info>
}) => Effect.Effect<void>
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<ReadonlyArray<Info>>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionTodo") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { db } = yield* Database.Service
const events = yield* EventV2.Service
const update = Effect.fn("SessionTodo.update")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly todos: ReadonlyArray<Info>
}) {
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx.delete(TodoTable).where(eq(TodoTable.session_id, input.sessionID)).run()
if (input.todos.length === 0) return
yield* tx
.insert(TodoTable)
.values(
input.todos.map((todo, position) => ({
session_id: input.sessionID,
content: todo.content,
status: todo.status,
priority: todo.priority,
position,
})),
)
.run()
}),
)
.pipe(Effect.orDie)
yield* events.publish(Event.Updated, input)
})
const get = Effect.fn("SessionTodo.get")(function* (sessionID: SessionSchema.ID) {
const rows = yield* db
.select()
.from(TodoTable)
.where(eq(TodoTable.session_id, sessionID))
.orderBy(asc(TodoTable.position))
.all()
.pipe(Effect.orDie)
return rows.map((row) => ({
content: row.content,
status: row.status,
priority: row.priority,
}))
})
return Service.of({ update, get })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer))