Refactor v2 session events as schemas (#24512)
This commit is contained in:
@@ -14,10 +14,13 @@ import { Config } from "@/config/config"
|
||||
import { NotFoundError } from "@/storage/storage"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { isOverflow as overflow, usable } from "./overflow"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { fn } from "@/util/fn"
|
||||
import { EventV2 } from "@/v2/event"
|
||||
import { SessionEvent } from "@/v2/session-event"
|
||||
|
||||
const log = Log.create({ service: "session.compaction" })
|
||||
|
||||
@@ -556,7 +559,21 @@ export const layer: Layer.Layer<
|
||||
}
|
||||
|
||||
if (processor.message.error) return "stop"
|
||||
if (result === "continue") yield* bus.publish(Event.Compacted, { sessionID: input.sessionID })
|
||||
if (result === "continue") {
|
||||
const summary = summaryText(
|
||||
(yield* session.messages({ sessionID: input.sessionID })).find((item) => item.info.id === msg.id) ?? {
|
||||
info: msg,
|
||||
parts: [],
|
||||
},
|
||||
)
|
||||
EventV2.run(SessionEvent.Compaction.Ended.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
text: summary ?? "",
|
||||
include: selected.tail_start_id,
|
||||
})
|
||||
yield* bus.publish(Event.Compacted, { sessionID: input.sessionID })
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
@@ -583,6 +600,11 @@ export const layer: Layer.Layer<
|
||||
auto: input.auto,
|
||||
overflow: input.overflow,
|
||||
})
|
||||
EventV2.run(SessionEvent.Compaction.Started.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
reason: input.auto ? "auto" : "manual",
|
||||
})
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
|
||||
@@ -20,6 +20,9 @@ import { Question } from "@/question"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { EventV2 } from "@/v2/event"
|
||||
import { SessionEvent } from "@/v2/session-event"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
|
||||
const DOOM_LOOP_THRESHOLD = 3
|
||||
const log = Log.create({ service: "session.processor" })
|
||||
@@ -221,6 +224,12 @@ export const layer: Layer.Layer<
|
||||
|
||||
case "reasoning-start":
|
||||
if (value.id in ctx.reasoningMap) return
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Reasoning.Started.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
reasoningID: value.id,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
ctx.reasoningMap[value.id] = {
|
||||
id: PartID.ascending(),
|
||||
messageID: ctx.assistantMessage.id,
|
||||
@@ -248,6 +257,13 @@ export const layer: Layer.Layer<
|
||||
|
||||
case "reasoning-end":
|
||||
if (!(value.id in ctx.reasoningMap)) return
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Reasoning.Ended.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
reasoningID: value.id,
|
||||
text: ctx.reasoningMap[value.id].text,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
// oxlint-disable-next-line no-self-assign -- reactivity trigger
|
||||
ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text
|
||||
ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() }
|
||||
@@ -260,6 +276,13 @@ export const layer: Layer.Layer<
|
||||
if (ctx.assistantMessage.summary) {
|
||||
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
|
||||
}
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Tool.Input.Started.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.id,
|
||||
name: value.toolName,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
const part = yield* session.updatePart({
|
||||
id: ctx.toolcalls[value.id]?.partID ?? PartID.ascending(),
|
||||
messageID: ctx.assistantMessage.id,
|
||||
@@ -281,13 +304,34 @@ export const layer: Layer.Layer<
|
||||
case "tool-input-delta":
|
||||
return
|
||||
|
||||
case "tool-input-end":
|
||||
case "tool-input-end": {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Tool.Input.Ended.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.id,
|
||||
text: "",
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
case "tool-call": {
|
||||
if (ctx.assistantMessage.summary) {
|
||||
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
|
||||
}
|
||||
const toolCall = yield* readToolCall(value.toolCallId)
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Tool.Called.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.toolCallId,
|
||||
tool: value.toolName,
|
||||
input: value.input,
|
||||
provider: {
|
||||
executed: toolCall?.part.metadata?.providerExecuted === true,
|
||||
...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
yield* updateToolCall(value.toolCallId, (match) => ({
|
||||
...match,
|
||||
tool: value.toolName,
|
||||
@@ -331,11 +375,48 @@ export const layer: Layer.Layer<
|
||||
}
|
||||
|
||||
case "tool-result": {
|
||||
const toolCall = yield* readToolCall(value.toolCallId)
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Tool.Success.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.toolCallId,
|
||||
structured: value.output.metadata,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: value.output.output,
|
||||
},
|
||||
...(value.output.attachments?.map((item: MessageV2.FilePart) => ({
|
||||
type: "file",
|
||||
uri: item.url,
|
||||
mime: item.mime,
|
||||
name: item.filename,
|
||||
})) ?? []),
|
||||
],
|
||||
provider: {
|
||||
executed: toolCall?.part.metadata?.providerExecuted === true,
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
yield* completeToolCall(value.toolCallId, value.output)
|
||||
return
|
||||
}
|
||||
|
||||
case "tool-error": {
|
||||
const toolCall = yield* readToolCall(value.toolCallId)
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Tool.Error.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.toolCallId,
|
||||
error: {
|
||||
type: "unknown",
|
||||
message: errorMessage(value.error),
|
||||
},
|
||||
provider: {
|
||||
executed: toolCall?.part.metadata?.providerExecuted === true,
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
yield* failToolCall(value.toolCallId, value.error)
|
||||
return
|
||||
}
|
||||
@@ -345,6 +426,20 @@ export const layer: Layer.Layer<
|
||||
|
||||
case "start-step":
|
||||
if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Step.Started.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
agent: input.assistantMessage.agent,
|
||||
model: {
|
||||
id: ctx.model.id,
|
||||
providerID: ctx.model.providerID,
|
||||
variant: input.assistantMessage.variant,
|
||||
},
|
||||
snapshot: ctx.snapshot,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: ctx.assistantMessage.id,
|
||||
@@ -355,18 +450,30 @@ export const layer: Layer.Layer<
|
||||
return
|
||||
|
||||
case "finish-step": {
|
||||
const completedSnapshot = yield* snapshot.track()
|
||||
const usage = Session.getUsage({
|
||||
model: ctx.model,
|
||||
usage: value.usage,
|
||||
metadata: value.providerMetadata,
|
||||
})
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Step.Ended.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
finish: value.finishReason,
|
||||
cost: usage.cost,
|
||||
tokens: usage.tokens,
|
||||
snapshot: completedSnapshot,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
ctx.assistantMessage.finish = value.finishReason
|
||||
ctx.assistantMessage.cost += usage.cost
|
||||
ctx.assistantMessage.tokens = usage.tokens
|
||||
yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
reason: value.finishReason,
|
||||
snapshot: yield* snapshot.track(),
|
||||
snapshot: completedSnapshot,
|
||||
messageID: ctx.assistantMessage.id,
|
||||
sessionID: ctx.assistantMessage.sessionID,
|
||||
type: "step-finish",
|
||||
@@ -404,6 +511,13 @@ export const layer: Layer.Layer<
|
||||
}
|
||||
|
||||
case "text-start":
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Text.Started.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
ctx.currentText = {
|
||||
id: PartID.ascending(),
|
||||
messageID: ctx.assistantMessage.id,
|
||||
@@ -442,6 +556,14 @@ export const layer: Layer.Layer<
|
||||
},
|
||||
{ text: ctx.currentText.text },
|
||||
)).text
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Text.Ended.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
text: ctx.currentText.text,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
{
|
||||
const end = Date.now()
|
||||
ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end }
|
||||
@@ -568,13 +690,24 @@ export const layer: Layer.Layer<
|
||||
Effect.retry(
|
||||
SessionRetry.policy({
|
||||
parse,
|
||||
set: (info) =>
|
||||
status.set(ctx.sessionID, {
|
||||
set: (info) => {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Retried.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
attempt: info.attempt,
|
||||
error: {
|
||||
message: info.message,
|
||||
isRetryable: true,
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
return status.set(ctx.sessionID, {
|
||||
type: "retry",
|
||||
attempt: info.attempt,
|
||||
message: info.message,
|
||||
next: info.next,
|
||||
}),
|
||||
})
|
||||
},
|
||||
}),
|
||||
),
|
||||
Effect.catch(halt),
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { and, desc, eq } from "@/storage/db"
|
||||
import type { Database } from "@/storage/db"
|
||||
import { SessionMessage } from "@/v2/session-message"
|
||||
import { SessionMessageUpdater } from "@/v2/session-message-updater"
|
||||
import { SessionEvent } from "@/v2/session-event"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { SessionMessageTable, SessionTable } from "./session.sql"
|
||||
import type { SessionID } from "./schema"
|
||||
import { Schema } from "effect"
|
||||
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
|
||||
type SessionMessageData = NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>
|
||||
|
||||
function encodeDateTimes(value: unknown): unknown {
|
||||
if (DateTime.isDateTime(value)) return DateTime.toEpochMillis(value)
|
||||
if (Array.isArray(value)) return value.map(encodeDateTimes)
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, encodeDateTimes(item)]))
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function encodeMessageData(value: unknown): SessionMessageData {
|
||||
return encodeDateTimes(value) as SessionMessageData
|
||||
}
|
||||
|
||||
function sqlite(db: Database.TxOrDb, sessionID: SessionID): SessionMessageUpdater.Adapter<void> {
|
||||
return {
|
||||
getCurrentAssistant() {
|
||||
return db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "assistant")))
|
||||
.orderBy(desc(SessionMessageTable.id))
|
||||
.all()
|
||||
.map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type }))
|
||||
.find((message): message is SessionMessage.Assistant => message.type === "assistant" && !message.time.completed)
|
||||
},
|
||||
getCurrentCompaction() {
|
||||
return db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
|
||||
.orderBy(desc(SessionMessageTable.id))
|
||||
.all()
|
||||
.map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type }))
|
||||
.find((message): message is SessionMessage.Compaction => message.type === "compaction")
|
||||
},
|
||||
getCurrentShell(callID) {
|
||||
return db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "shell")))
|
||||
.orderBy(desc(SessionMessageTable.id))
|
||||
.all()
|
||||
.map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type }))
|
||||
.find((message): message is SessionMessage.Shell => message.type === "shell" && message.callID === callID)
|
||||
},
|
||||
updateAssistant(assistant) {
|
||||
const { id, type, ...data } = assistant
|
||||
db.update(SessionMessageTable)
|
||||
.set({ data: encodeMessageData(data) })
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.id, id),
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
eq(SessionMessageTable.type, type),
|
||||
),
|
||||
)
|
||||
.run()
|
||||
},
|
||||
updateCompaction(compaction) {
|
||||
const { id, type, ...data } = compaction
|
||||
db.update(SessionMessageTable)
|
||||
.set({ data: encodeMessageData(data) })
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.id, id),
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
eq(SessionMessageTable.type, type),
|
||||
),
|
||||
)
|
||||
.run()
|
||||
},
|
||||
updateShell(shell) {
|
||||
const { id, type, ...data } = shell
|
||||
db.update(SessionMessageTable)
|
||||
.set({ data: encodeMessageData(data) })
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.id, id),
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
eq(SessionMessageTable.type, type),
|
||||
),
|
||||
)
|
||||
.run()
|
||||
},
|
||||
appendMessage(message) {
|
||||
const { id, type, ...data } = message
|
||||
db.insert(SessionMessageTable)
|
||||
.values([
|
||||
{
|
||||
id,
|
||||
session_id: sessionID,
|
||||
type,
|
||||
time_created: DateTime.toEpochMillis(message.time.created),
|
||||
data: encodeMessageData(data),
|
||||
},
|
||||
])
|
||||
.run()
|
||||
},
|
||||
finish() {},
|
||||
}
|
||||
}
|
||||
|
||||
function update(db: Database.TxOrDb, event: SessionEvent.Event) {
|
||||
SessionMessageUpdater.update(sqlite(db, event.data.sessionID), event)
|
||||
}
|
||||
|
||||
export default [
|
||||
SyncEvent.project(SessionEvent.AgentSwitched.Sync, (db, data, event) => {
|
||||
db.update(SessionTable)
|
||||
.set({
|
||||
agent: data.agent,
|
||||
time_updated: DateTime.toEpochMillis(data.timestamp),
|
||||
})
|
||||
.where(eq(SessionTable.id, data.sessionID))
|
||||
.run()
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.agent.switched", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.ModelSwitched.Sync, (db, data, event) => {
|
||||
db.update(SessionTable)
|
||||
.set({
|
||||
model: {
|
||||
id: data.id,
|
||||
providerID: data.providerID,
|
||||
variant: data.variant,
|
||||
},
|
||||
time_updated: DateTime.toEpochMillis(data.timestamp),
|
||||
})
|
||||
.where(eq(SessionTable.id, data.sessionID))
|
||||
.run()
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.model.switched", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Prompted.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.prompted", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Synthetic.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.synthetic", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Shell.Started.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.shell.started", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Shell.Ended.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.shell.ended", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Step.Started.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.started", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Step.Ended.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.ended", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Text.Started.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.text.started", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Text.Delta.Sync, () => {}),
|
||||
SyncEvent.project(SessionEvent.Text.Ended.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.text.ended", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Tool.Input.Started.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.input.started", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Tool.Input.Delta.Sync, () => {}),
|
||||
SyncEvent.project(SessionEvent.Tool.Input.Ended.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.input.ended", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Tool.Called.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.called", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Tool.Success.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.success", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Tool.Error.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.error", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Reasoning.Started.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.reasoning.started", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Reasoning.Delta.Sync, () => {}),
|
||||
SyncEvent.project(SessionEvent.Reasoning.Ended.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.reasoning.ended", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Retried.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.retried", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Compaction.Started.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.compaction.started", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Compaction.Delta.Sync, () => {}),
|
||||
SyncEvent.project(SessionEvent.Compaction.Ended.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.compaction.ended", data })
|
||||
}),
|
||||
]
|
||||
@@ -5,7 +5,8 @@ import { SyncEvent } from "@/sync"
|
||||
import * as Session from "./session"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { SessionTable, MessageTable, PartTable } from "./session.sql"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Log } from "@opencode-ai/core/util/log"
|
||||
import nextProjectors from "./projectors-next"
|
||||
|
||||
const log = Log.create({ service: "session.projector" })
|
||||
|
||||
@@ -136,4 +137,6 @@ export default [
|
||||
log.warn("ignored late part update", { partID: id, messageID, sessionID })
|
||||
}
|
||||
}),
|
||||
|
||||
...nextProjectors,
|
||||
]
|
||||
|
||||
@@ -54,6 +54,13 @@ import { InstanceState } from "@/effect/instance-state"
|
||||
import { TaskTool, type TaskPromptOps } from "@/tool/task"
|
||||
import { SessionRunState } from "./run-state"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { EventV2 } from "@/v2/event"
|
||||
import { SessionEvent } from "@/v2/session-event"
|
||||
import { AgentAttachment, FileAttachment, Source } from "@/v2/session-prompt"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
import { eq } from "@/storage/db"
|
||||
import * as Database from "@/storage/db"
|
||||
import { SessionTable } from "./session.sql"
|
||||
|
||||
// @ts-ignore
|
||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
@@ -785,6 +792,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
providerID: model.providerID,
|
||||
}
|
||||
yield* sessions.updateMessage(msg)
|
||||
const callID = ulid()
|
||||
const started = Date.now()
|
||||
const part: MessageV2.ToolPart = {
|
||||
type: "tool",
|
||||
id: PartID.ascending(),
|
||||
@@ -794,11 +803,17 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
callID: ulid(),
|
||||
state: {
|
||||
status: "running",
|
||||
time: { start: Date.now() },
|
||||
time: { start: started },
|
||||
input: { command: input.command },
|
||||
},
|
||||
}
|
||||
yield* sessions.updatePart(part)
|
||||
EventV2.run(SessionEvent.Shell.Started.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(started),
|
||||
callID,
|
||||
command: input.command,
|
||||
})
|
||||
return { msg, part, cwd: ctx.directory }
|
||||
}).pipe(Effect.ensuring(markReady))
|
||||
|
||||
@@ -813,14 +828,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
if (aborted) {
|
||||
output += "\n\n" + ["<metadata>", "User aborted the command", "</metadata>"].join("\n")
|
||||
}
|
||||
const completed = Date.now()
|
||||
EventV2.run(SessionEvent.Shell.Ended.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(completed),
|
||||
callID: part.callID,
|
||||
output,
|
||||
})
|
||||
if (!msg.time.completed) {
|
||||
msg.time.completed = Date.now()
|
||||
msg.time.completed = completed
|
||||
yield* sessions.updateMessage(msg)
|
||||
}
|
||||
if (part.state.status === "running") {
|
||||
part.state = {
|
||||
status: "completed",
|
||||
time: { ...part.state.time, end: Date.now() },
|
||||
time: { ...part.state.time, end: completed },
|
||||
input: part.state.input,
|
||||
title: "",
|
||||
metadata: { output, description: "" },
|
||||
@@ -934,6 +956,34 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
format: input.format,
|
||||
}
|
||||
|
||||
const current = Database.use((db) =>
|
||||
db
|
||||
.select({ agent: SessionTable.agent, model: SessionTable.model })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, input.sessionID))
|
||||
.get(),
|
||||
)
|
||||
if (current?.agent !== info.agent) {
|
||||
EventV2.run(SessionEvent.AgentSwitched.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
agent: info.agent,
|
||||
})
|
||||
}
|
||||
if (
|
||||
current?.model?.providerID !== info.model.providerID ||
|
||||
current.model.id !== info.model.modelID ||
|
||||
current.model.variant !== info.model.variant
|
||||
) {
|
||||
EventV2.run(SessionEvent.ModelSwitched.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
id: info.model.modelID,
|
||||
providerID: info.model.providerID,
|
||||
variant: info.model.variant,
|
||||
})
|
||||
}
|
||||
|
||||
yield* Effect.addFinalizer(() => instruction.clear(info.id))
|
||||
|
||||
type Draft<T> = T extends MessageV2.Part ? Omit<T, "id"> & { id?: string } : never
|
||||
@@ -1250,6 +1300,69 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
|
||||
yield* sessions.updateMessage(info)
|
||||
for (const part of parts) yield* sessions.updatePart(part)
|
||||
const nextPrompt = parts.reduce(
|
||||
(result, part) => {
|
||||
if (part.type === "text") {
|
||||
if (part.synthetic) result.synthetic.push(part.text)
|
||||
else result.text.push(part.text)
|
||||
}
|
||||
if (part.type === "file") {
|
||||
result.files.push(
|
||||
new FileAttachment({
|
||||
uri: part.url,
|
||||
mime: part.mime,
|
||||
name: part.filename,
|
||||
source: part.source
|
||||
? new Source({
|
||||
start: part.source.text.start,
|
||||
end: part.source.text.end,
|
||||
text: part.source.text.value,
|
||||
})
|
||||
: undefined,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (part.type === "agent") {
|
||||
result.agents.push(
|
||||
new AgentAttachment({
|
||||
name: part.name,
|
||||
source: part.source
|
||||
? new Source({
|
||||
start: part.source.start,
|
||||
end: part.source.end,
|
||||
text: part.source.value,
|
||||
})
|
||||
: undefined,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return result
|
||||
},
|
||||
{
|
||||
text: [] as string[],
|
||||
files: [] as FileAttachment[],
|
||||
agents: [] as AgentAttachment[],
|
||||
synthetic: [] as string[],
|
||||
},
|
||||
)
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Prompted.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
prompt: {
|
||||
text: nextPrompt.text.join("\n"),
|
||||
files: nextPrompt.files,
|
||||
agents: nextPrompt.agents,
|
||||
},
|
||||
})
|
||||
for (const text of nextPrompt.synthetic) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Synthetic.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
text,
|
||||
})
|
||||
}
|
||||
|
||||
return { info, parts }
|
||||
}, Effect.scoped)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core"
|
||||
import { ProjectTable } from "../project/project.sql"
|
||||
import type { MessageV2 } from "./message-v2"
|
||||
import type { SessionEntry } from "../v2/session-entry"
|
||||
import type { SessionMessage } from "../v2/session-message"
|
||||
import type { Snapshot } from "../snapshot"
|
||||
import type { Permission } from "../permission"
|
||||
import type { ProjectID } from "../project/schema"
|
||||
@@ -11,6 +11,7 @@ import { Timestamps } from "../storage/schema.sql"
|
||||
|
||||
type PartData = Omit<MessageV2.Part, "id" | "sessionID" | "messageID">
|
||||
type InfoData = Omit<MessageV2.Info, "id" | "sessionID">
|
||||
type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
|
||||
|
||||
export const SessionTable = sqliteTable(
|
||||
"session",
|
||||
@@ -34,6 +35,12 @@ export const SessionTable = sqliteTable(
|
||||
summary_diffs: text({ mode: "json" }).$type<Snapshot.FileDiff[]>(),
|
||||
revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(),
|
||||
permission: text({ mode: "json" }).$type<Permission.Ruleset>(),
|
||||
agent: text(),
|
||||
model: text({ mode: "json" }).$type<{
|
||||
id: string
|
||||
providerID: string
|
||||
variant?: string
|
||||
}>(),
|
||||
...Timestamps,
|
||||
time_compacting: integer(),
|
||||
time_archived: integer(),
|
||||
@@ -96,22 +103,22 @@ export const TodoTable = sqliteTable(
|
||||
],
|
||||
)
|
||||
|
||||
export const SessionEntryTable = sqliteTable(
|
||||
"session_entry",
|
||||
export const SessionMessageTable = sqliteTable(
|
||||
"session_message",
|
||||
{
|
||||
id: text().$type<SessionEntry.ID>().primaryKey(),
|
||||
id: text().$type<SessionMessage.ID>().primaryKey(),
|
||||
session_id: text()
|
||||
.$type<SessionID>()
|
||||
.notNull()
|
||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||
type: text().$type<SessionEntry.Type>().notNull(),
|
||||
type: text().$type<SessionMessage.Type>().notNull(),
|
||||
...Timestamps,
|
||||
data: text({ mode: "json" }).notNull().$type<Omit<SessionEntry.Entry, "type" | "id">>(),
|
||||
data: text({ mode: "json" }).notNull().$type<SessionMessageData>(),
|
||||
},
|
||||
(table) => [
|
||||
index("session_entry_session_idx").on(table.session_id),
|
||||
index("session_entry_session_type_idx").on(table.session_id, table.type),
|
||||
index("session_entry_time_created_idx").on(table.time_created),
|
||||
index("session_message_session_idx").on(table.session_id),
|
||||
index("session_message_session_type_idx").on(table.session_id, table.type),
|
||||
index("session_message_time_created_idx").on(table.time_created),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import { Snapshot } from "@/snapshot"
|
||||
import { ProjectID } from "../project/schema"
|
||||
import { WorkspaceID } from "../control-plane/schema"
|
||||
import { SessionID, MessageID, PartID } from "./schema"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { Permission } from "@/permission"
|
||||
@@ -78,6 +79,10 @@ export function fromRow(row: SessionRow): Info {
|
||||
path: row.path ?? undefined,
|
||||
parentID: row.parent_id ?? undefined,
|
||||
title: row.title,
|
||||
agent: row.agent ?? undefined,
|
||||
model: row.model
|
||||
? { id: ModelID.make(row.model.id), providerID: ProviderID.make(row.model.providerID), variant: row.model.variant }
|
||||
: undefined,
|
||||
version: row.version,
|
||||
summary,
|
||||
share,
|
||||
@@ -102,6 +107,8 @@ export function toRow(info: Info) {
|
||||
directory: info.directory,
|
||||
path: info.path,
|
||||
title: info.title,
|
||||
agent: info.agent,
|
||||
model: info.model,
|
||||
version: info.version,
|
||||
share_url: info.share?.url,
|
||||
summary_additions: info.summary?.additions,
|
||||
@@ -160,6 +167,12 @@ const Revert = Schema.Struct({
|
||||
diff: optionalOmitUndefined(Schema.String),
|
||||
})
|
||||
|
||||
const Model = Schema.Struct({
|
||||
id: ModelID,
|
||||
providerID: ProviderID,
|
||||
variant: optionalOmitUndefined(Schema.String),
|
||||
})
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
id: SessionID,
|
||||
slug: Schema.String,
|
||||
@@ -171,6 +184,8 @@ export const Info = Schema.Struct({
|
||||
summary: optionalOmitUndefined(Summary),
|
||||
share: optionalOmitUndefined(Share),
|
||||
title: Schema.String,
|
||||
agent: optionalOmitUndefined(Schema.String),
|
||||
model: optionalOmitUndefined(Model),
|
||||
version: Schema.String,
|
||||
time: Time,
|
||||
permission: optionalOmitUndefined(Permission.Ruleset),
|
||||
@@ -201,6 +216,8 @@ export const CreateInput = Schema.optional(
|
||||
Schema.Struct({
|
||||
parentID: Schema.optional(SessionID),
|
||||
title: Schema.optional(Schema.String),
|
||||
agent: Schema.optional(Schema.String),
|
||||
model: Schema.optional(Model),
|
||||
permission: Schema.optional(Permission.Ruleset),
|
||||
workspaceID: Schema.optional(WorkspaceID),
|
||||
}),
|
||||
@@ -272,6 +289,8 @@ const UpdatedInfo = Schema.Struct({
|
||||
summary: Schema.optional(Schema.NullOr(Summary)),
|
||||
share: Schema.optional(UpdatedShare),
|
||||
title: Schema.optional(Schema.NullOr(Schema.String)),
|
||||
agent: Schema.optional(Schema.NullOr(Schema.String)),
|
||||
model: Schema.optional(Schema.NullOr(Model)),
|
||||
version: Schema.optional(Schema.NullOr(Schema.String)),
|
||||
time: Schema.optional(UpdatedTime),
|
||||
permission: Schema.optional(Schema.NullOr(Permission.Ruleset)),
|
||||
@@ -404,6 +423,8 @@ export interface Interface {
|
||||
readonly create: (input?: {
|
||||
parentID?: SessionID
|
||||
title?: string
|
||||
agent?: string
|
||||
model?: Schema.Schema.Type<typeof Model>
|
||||
permission?: Permission.Ruleset
|
||||
workspaceID?: WorkspaceID
|
||||
}) => Effect.Effect<Info>
|
||||
@@ -464,6 +485,8 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
|
||||
const createNext = Effect.fn("Session.createNext")(function* (input: {
|
||||
id?: SessionID
|
||||
title?: string
|
||||
agent?: string
|
||||
model?: Schema.Schema.Type<typeof Model>
|
||||
parentID?: SessionID
|
||||
workspaceID?: WorkspaceID
|
||||
directory: string
|
||||
@@ -481,6 +504,8 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
|
||||
workspaceID: input.workspaceID,
|
||||
parentID: input.parentID,
|
||||
title: input.title ?? createDefaultTitle(!!input.parentID),
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
permission: input.permission,
|
||||
time: {
|
||||
created: Date.now(),
|
||||
@@ -591,6 +616,8 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
|
||||
const create = Effect.fn("Session.create")(function* (input?: {
|
||||
parentID?: SessionID
|
||||
title?: string
|
||||
agent?: string
|
||||
model?: Schema.Schema.Type<typeof Model>
|
||||
permission?: Permission.Ruleset
|
||||
workspaceID?: WorkspaceID
|
||||
}) {
|
||||
@@ -601,6 +628,8 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
|
||||
directory: ctx.directory,
|
||||
path: sessionPath(ctx.worktree, ctx.directory),
|
||||
title: input?.title,
|
||||
agent: input?.agent,
|
||||
model: input?.model,
|
||||
permission: input?.permission,
|
||||
workspaceID: input?.workspaceID ?? workspace,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user