refactor(schema): apply session review decisions (#35793)

This commit is contained in:
Kit Langton 2026-07-07 22:10:11 -04:00 committed by GitHub
parent d27746e5b3
commit ed6ad272ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
142 changed files with 4321 additions and 3256 deletions

View file

@ -8,7 +8,7 @@ import type {
QuestionRequest,
Session,
SessionStatus,
SnapshotFileDiff,
FileDiffInfo,
Todo,
} from "@opencode-ai/sdk/v2/client"
import type { State, VcsCache } from "./types"
@ -188,7 +188,7 @@ export function applyDirectoryEvent(input: {
break
}
case "session.diff": {
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
break
}

View file

@ -5,7 +5,7 @@ import type {
PermissionRequest,
QuestionRequest,
SessionStatus,
SnapshotFileDiff,
FileDiffInfo,
Todo,
} from "@opencode-ai/sdk/v2/client"
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
@ -33,7 +33,7 @@ describe("app session cache", () => {
test("dropSessionCaches clears orphaned parts without message rows", () => {
const store: {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, SnapshotFileDiff[] | undefined>
session_diff: Record<string, FileDiffInfo[] | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
@ -67,7 +67,7 @@ describe("app session cache", () => {
const m = msg("msg_1", "ses_1")
const store: {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, SnapshotFileDiff[] | undefined>
session_diff: Record<string, FileDiffInfo[] | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>

View file

@ -4,7 +4,7 @@ import type {
PermissionRequest,
QuestionRequest,
SessionStatus,
SnapshotFileDiff,
FileDiffInfo,
Todo,
} from "@opencode-ai/sdk/v2/client"
@ -12,7 +12,7 @@ export const SESSION_CACHE_LIMIT = 40
type SessionCache = {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, SnapshotFileDiff[] | undefined>
session_diff: Record<string, FileDiffInfo[] | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>

View file

@ -13,7 +13,7 @@ import type {
ReferenceInfo,
Session,
SessionStatus,
SnapshotFileDiff,
FileDiffInfo,
Todo,
VcsInfo,
} from "@opencode-ai/sdk/v2/client"
@ -51,7 +51,7 @@ export type State = {
}
session_working(id: string): boolean
session_diff: {
[sessionID: string]: SnapshotFileDiff[]
[sessionID: string]: FileDiffInfo[]
}
todo: {
[sessionID: string]: Todo[]

View file

@ -8,7 +8,7 @@ import type {
QuestionRequest,
Session,
SessionStatus,
SnapshotFileDiff,
FileDiffInfo,
Todo,
} from "@opencode-ai/sdk/v2/client"
import { batch } from "solid-js"
@ -139,7 +139,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
const [data, setData] = createStore({
info: {} as Record<string, Session | undefined>,
session_status: {} as Record<string, SessionStatus>,
session_diff: {} as Record<string, SnapshotFileDiff[]>,
session_diff: {} as Record<string, FileDiffInfo[]>,
todo: {} as Record<string, Todo[]>,
permission: {} as Record<string, PermissionRequest[]>,
question: {} as Record<string, QuestionRequest[]>,
@ -769,7 +769,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
return
}
case "session.diff": {
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" }))
return
}

View file

@ -1,6 +1,6 @@
import { createEffect, onCleanup, type JSX } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { SessionReview } from "@opencode-ai/session-ui/session-review"
import type {
SessionReviewCommentActions,
@ -14,7 +14,7 @@ import type { LineComment } from "@/context/comments"
export type DiffStyle = "unified" | "split"
type ReviewDiff = SnapshotFileDiff | VcsFileDiff
type ReviewDiff = FileDiffInfo | VcsFileDiff
export interface SessionReviewTabProps {
title?: JSX.Element

View file

@ -8,7 +8,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@ -23,7 +23,6 @@ import { useFile, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
import {
@ -36,15 +35,9 @@ import {
import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
return typeof value.file === "string"
}
export function SessionSidePanel(props: {
canReview: () => boolean
diffs: () => (SnapshotFileDiff | VcsFileDiff)[]
diffs: () => (FileDiffInfo | VcsFileDiff)[]
diffsReady: () => boolean
empty: () => string
hasReview: () => boolean
@ -59,7 +52,6 @@ export function SessionSidePanel(props: {
}) {
const layout = useLayout()
const settings = useSettings()
const sync = useSync()
const file = useFile()
const language = useLanguage()
const command = useCommand()
@ -88,7 +80,7 @@ export function SessionSidePanel(props: {
})
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
const diffs = createMemo(() => props.diffs().filter(renderDiff))
const diffs = createMemo(() => props.diffs())
const diffFiles = createMemo(() => diffs().map((d) => d.file))
const kinds = createMemo(() => {
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {

View file

@ -1,17 +1,13 @@
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { Kind } from "@/components/file-tree-v2"
import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
export type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
export type RenderDiff = FileDiffInfo | VcsFileDiff
export function normalizePath(p: string) {
return normalizeFileTreeV2Path(p)
}
export function filterRenderableDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
return typeof value.file === "string"
}
export function reviewDiffKinds(diffs: RenderDiff[]) {
const merge = (a: Kind | undefined, b: Kind) => {
if (!a) return b

View file

@ -1,5 +1,5 @@
import { createMemo, createSignal, Show, type JSX } from "solid-js"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
import {
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX,
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
@ -21,16 +21,11 @@ import type {
import FileTreeV2 from "@/components/file-tree-v2"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
import {
filterRenderableDiff,
filterReviewFiles,
reviewDiffKinds,
type RenderDiff,
} from "@/pages/session/v2/review-diff-kinds"
import { filterReviewFiles, reviewDiffKinds, type RenderDiff } from "@/pages/session/v2/review-diff-kinds"
import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
type ReviewDiff = SnapshotFileDiff | VcsFileDiff
type ReviewDiff = FileDiffInfo | VcsFileDiff
export type ReviewPanelV2Props = {
title?: JSX.Element
@ -54,7 +49,7 @@ export type ReviewPanelV2Props = {
export function ReviewPanelV2(props: ReviewPanelV2Props) {
const sdk = useSDK()
const diffs = createMemo(() => props.diffs().filter(filterRenderableDiff))
const diffs = createMemo(() => props.diffs())
const filteredFiles = createMemo(() =>
filterReviewFiles(
diffs().map((diff) => diff.file),

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/sdk/v2"
import type { Message } from "@opencode-ai/sdk/v2/client"
import { diffs, message } from "./diffs"
@ -9,7 +9,7 @@ const item = {
additions: 1,
deletions: 1,
status: "modified",
} satisfies SnapshotFileDiff
} satisfies FileDiffInfo
describe("diffs", () => {
test("keeps valid arrays", () => {

View file

@ -1,7 +1,7 @@
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/sdk/v2"
import type { Message } from "@opencode-ai/sdk/v2/client"
type Diff = SnapshotFileDiff | VcsFileDiff
type Diff = FileDiffInfo
function diff(value: unknown): value is Diff {
if (!value || typeof value !== "object" || Array.isArray(value)) return false

View file

@ -37,7 +37,8 @@ function defaultCost(model: CurrentModel) {
export function runAgent(input: CurrentAgent): RunAgent {
return {
name: input.id,
id: input.id,
name: input.name,
description: input.description,
mode: input.mode,
hidden: input.hidden,
@ -53,7 +54,7 @@ export function runCommand(input: CurrentCommand): RunCommand {
export function runSkill(input: CurrentSkill): RunCommand {
return {
name: input.name,
name: input.id,
description: input.description,
source: "skill",
}

View file

@ -333,10 +333,10 @@ export function createPromptState(input: PromptInput): PromptState {
.map((item) => ({
kind: "mention",
display: "@" + item.name,
value: item.name,
value: item.id,
part: {
type: "agent",
name: item.name,
name: item.id,
source: {
start: 0,
end: 0,

View file

@ -281,7 +281,6 @@ export async function runNonInteractivePrompt(input: Input) {
metadata: {
structured: event.data.structured,
content: event.data.content,
outputPaths: event.data.outputPaths,
result: event.data.result,
providerCall: current.provider,
providerResult: { executed: event.data.executed, state: event.data.resultState },

View file

@ -89,12 +89,11 @@ async function execute(input: RunCommandInput, prepared: Prepared, transport: Tr
!explicitModel && !sessionModel
? await client.model
.default({ location: { directory: cwd, workspace } })
.then((result) =>
result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined,
)
.then((result) => (result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined))
: undefined
const model = pickRunModel(explicitModel, input.variant, sessionModel, defaultModel)
if (input.variant && !model) return reportError(input, "Cannot select a variant before selecting a model", session?.id)
if (input.variant && !model)
return reportError(input, "Cannot select a variant before selecting a model", session?.id)
if (model) {
await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model })
const available = await client.model.list({ location: { directory: cwd, workspace } })
@ -112,9 +111,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, transport: Tr
if (!session && input.title !== undefined) {
await client.session.rename({
sessionID: selected.id,
title:
input.title ||
prepared.message.slice(0, 50) + (prepared.message.length > 50 ? "..." : ""),
title: input.title || prepared.message.slice(0, 50) + (prepared.message.length > 50 ? "..." : ""),
})
}
@ -200,7 +197,7 @@ async function validateAgent(client: OpenCodeClient, directory: string, name?: s
warning(`failed to list agents${server ? ` from ${server}` : ""}. Falling back to default agent`)
return
}
const agent = agents.find((item) => item.name === name)
const agent = agents.find((item) => item.id === name)
if (!agent) {
warning(`agent "${name}" not found. Falling back to default agent`)
return

View file

@ -16,7 +16,7 @@
// backgrounding is intentionally absent: subagent jobs block the parent
// session, so only whole-session `v2.session.background(parentID)` exists.
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
import type { SessionMessage, SessionMessageAssistantTool, ToolPart } from "@opencode-ai/sdk/v2"
import type { SessionMessageAssistantTool, SessionMessageInfo, ToolPart } from "@opencode-ai/sdk/v2"
import { Locale } from "@opencode-ai/tui/util/locale"
import type { FooterSubagentDetail, FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types"
@ -54,7 +54,7 @@ export function legacyTool(input: {
callID: tool.id,
tool: tool.name,
}
if (tool.state.status === "pending") {
if (tool.state.status === "streaming") {
return {
...base,
state: { status: "pending", input: {}, raw: tool.state.input },
@ -83,7 +83,6 @@ export function legacyTool(input: {
metadata: {
structured: tool.state.structured,
content: tool.state.content,
outputPaths: tool.state.outputPaths,
result: tool.state.result,
providerCall,
providerResult,
@ -179,7 +178,7 @@ export type SubagentTrackerInput = {
export type SubagentTracker = {
main(event: V2Event): void
foreign(sessionID: string, event: V2Event): void
hydrate(next: { messages: SessionMessage[]; active: Record<string, unknown> }): Promise<void>
hydrate(next: { messages: SessionMessageInfo[]; active: Record<string, unknown> }): Promise<void>
select(sessionID: string | undefined): void
snapshot(): FooterSubagentState
}
@ -316,7 +315,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
messageID,
tool: item,
})
if (item.state.status === "pending") return
if (item.state.status === "streaming") return
child.callIDs.add(item.id)
if (item.state.status === "running") {
setFrame(child, `tool:${item.id}`, toolCommit(part, "start"))
@ -327,7 +326,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
setFrame(child, `tool:${item.id}`, toolCommit(part, "final"))
}
const rebuild = (child: ChildState, messages: SessionMessage[]) => {
const rebuild = (child: ChildState, messages: SessionMessageInfo[]) => {
child.frames = []
child.text.clear()
child.projectedText.clear()
@ -412,7 +411,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
for (const [id, prompt] of pendingPrompts) {
if (!child.prompts.has(id)) child.prompts.set(id, prompt)
}
rebuild(child, structuredClone(response.data).toReversed() as SessionMessage[])
rebuild(child, structuredClone(response.data).toReversed() as SessionMessageInfo[])
for (const [id, tool] of pendingTools) {
if (!child.finishedTools.has(id) && !child.tools.has(id)) child.tools.set(id, tool)
}
@ -622,7 +621,6 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
input: current?.input ?? {},
structured: event.data.structured,
content: event.data.content,
outputPaths: event.data.outputPaths,
result: event.data.result,
},
time: {

View file

@ -2,7 +2,7 @@ import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/p
import type {
PermissionRequest,
QuestionRequest,
SessionMessage,
SessionMessageInfo,
SessionMessageAssistantTool,
} from "@opencode-ai/sdk/v2"
import { Event } from "@opencode-ai/schema/event"
@ -389,7 +389,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
messageID,
tool: item,
})
if (item.state.status === "pending") return
if (item.state.status === "streaming") return
if (item.state.status === "running") {
if (state.tools.get(item.id)?.running) return
state.tools.set(item.id, {
@ -417,7 +417,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
])
}
const renderMessage = (message: SessionMessage, render: boolean, reuseVisibleWait: boolean) => {
const renderMessage = (message: SessionMessageInfo, render: boolean, reuseVisibleWait: boolean) => {
if (message.type === "user") {
const waiting = state.wait?.messageID === message.id
if (waiting && state.wait) state.wait.promoted = true
@ -438,34 +438,34 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return
}
if (message.type === "shell") {
state.shellCommands.set(message.shell.id, message.shell.command)
if (state.shellWait?.messageID === message.id) state.shellWait.callID = message.shell.id
state.shellCommands.set(message.shellID, message.command)
if (state.shellWait?.messageID === message.id) state.shellWait.callID = message.shellID
const completed = message.time.completed !== undefined
if (!render) {
// Suppressed history: mark settled shells rendered so live redelivery
// stays silent. A still-running shell stays unmarked and renders in
// full when its live shell.ended event arrives.
if (completed) {
state.shellStarted.add(message.shell.id)
state.shellEnded.add(message.shell.id)
state.shellStarted.add(message.shellID)
state.shellEnded.add(message.shellID)
}
return
}
if (!state.shellStarted.has(message.shell.id)) {
state.shellStarted.add(message.shell.id)
if (!state.shellStarted.has(message.shellID)) {
state.shellStarted.add(message.shellID)
write([
shellCommit(message.shell.id, message.shell.command, {
shellCommit(message.shellID, message.command, {
text: "running shell",
phase: "start",
toolState: "running",
}),
])
}
if (completed && message.output && !state.shellEnded.has(message.shell.id)) {
state.shellEnded.add(message.shell.id)
write(shellTerminal(message.shell.id, message.shell.command, message.shell, message.output))
if (completed && message.output && !state.shellEnded.has(message.shellID)) {
state.shellEnded.add(message.shellID)
write(shellTerminal(message.shellID, message.command, message, message.output))
}
if (completed && state.shellWait?.callID === message.shell.id) state.shellWait.resolve()
if (completed && state.shellWait?.callID === message.shellID) state.shellWait.resolve()
return
}
if (message.type !== "assistant") return
@ -534,7 +534,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
input.sdk.question.list({ sessionID: input.sessionID }),
input.sdk.session.active(),
])
const projected = structuredClone(messages.data).toReversed() as SessionMessage[]
const projected = structuredClone(messages.data).toReversed() as SessionMessageInfo[]
for (const message of projected) renderMessage(message, next.render, next.reuseVisibleWait)
state.permissions = permissions.map(permission)
state.questions = questions.map(question)
@ -762,7 +762,6 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
input: current?.input ?? {},
structured: event.data.structured,
content: event.data.content,
outputPaths: event.data.outputPaths,
result: event.data.result,
},
time: { created: current?.started ?? event.created, ran: current?.started, completed: event.created },

View file

@ -113,6 +113,7 @@ export type FooterQueuedPrompt = {
}
export type RunAgent = {
id: string
name: string
description?: string
mode: "subagent" | "primary" | "all"

File diff suppressed because it is too large Load diff

View file

@ -38,7 +38,7 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () =>
expect(ProjectV2.Directory).toBe(Project.Directory)
expect(ProjectV2.Directories).toBe(Project.Directories)
expect(CoreSessionInput.Admitted).toBe(SessionInput.Admitted)
expect(CoreSessionMessage.Message).toBe(SessionMessage.Message)
expect(CoreSessionMessage.Info).toBe(SessionMessage.Info)
expect(Api.groups["server.session"].identifier).toBe("server.session")
expect(Api.groups["server.project"].identifier).toBe("server.project")
expect(Object.keys(ClientApi.groups)).toEqual(Object.keys(Api.groups))

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,8 @@ import { State } from "./state"
export const ID = Agent.ID
export type ID = typeof ID.Type
export const Name = Agent.Name
export type Name = Agent.Name
export const defaultID = ID.make("build")
export const Color = Agent.Color

View file

@ -1,6 +1,7 @@
export * as ConfigProviderPlugin from "./provider"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Stream } from "effect"
import { Config } from "../../config"
import { ModelV2 } from "../../model"
@ -91,8 +92,8 @@ export const Plugin = define({
input: cost.input,
output: cost.output,
cache: {
read: cost.cache?.read ?? 0,
write: cost.cache?.write ?? 0,
read: cost.cache?.read ?? Money.USDPerMillionTokens.zero,
write: cost.cache?.write ?? Money.USDPerMillionTokens.zero,
},
}))
}

View file

@ -1,6 +1,7 @@
export * as ConfigProvider from "./provider"
import { Schema } from "effect"
import { Money } from "@opencode-ai/schema/money"
import { ModelV2 } from "../model"
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
@ -17,8 +18,8 @@ export class Request extends Schema.Class<Request>("ConfigV2.Provider.Request")(
}) {}
class Cache extends Schema.Class<Cache>("ConfigV2.Model.Cost.Cache")({
read: Schema.Finite.pipe(Schema.optional),
write: Schema.Finite.pipe(Schema.optional),
read: Money.USDPerMillionTokens.pipe(Schema.optional),
write: Money.USDPerMillionTokens.pipe(Schema.optional),
}) {}
class Cost extends Schema.Class<Cost>("ConfigV2.Model.Cost")({
@ -26,8 +27,8 @@ class Cost extends Schema.Class<Cost>("ConfigV2.Model.Cost")({
type: Schema.Literal("context"),
size: Schema.Int,
}).pipe(Schema.optional),
input: Schema.Finite,
output: Schema.Finite,
input: Money.USDPerMillionTokens,
output: Money.USDPerMillionTokens,
cache: Cache.pipe(Schema.optional),
}) {}

View file

@ -48,5 +48,6 @@ export const migrations = (
import("./migration/20260705180000_rename_instructions"),
import("./migration/20260706223930_add-session-fork"),
import("./migration/20260707010146_durable_session_inbox"),
import("./migration/20260707120000_migrate_prelaunch_v2_state"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]

View file

@ -0,0 +1,227 @@
import { sql } from "drizzle-orm"
import { Effect, Schema } from "effect"
import type { DatabaseMigration } from "../migration"
const decodeJson = Schema.decodeUnknownSync(Schema.UnknownFromJsonString)
const isObject = Schema.is(Schema.Record(Schema.String, Schema.Unknown))
export default {
id: "20260707120000_migrate_prelaunch_v2_state",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(
sql`DELETE FROM session_message WHERE type = 'compaction' AND json_extract(data, '$.status') = 'queued'`,
)
const messages = yield* tx.all<{ id: string; type: string; data: string }>(
sql`SELECT id, type, data FROM session_message WHERE type IN ('skill', 'shell', 'assistant', 'compaction', 'synthetic')`,
)
for (const row of messages) {
const data = object(decodeJson(row.data))
yield* tx.run(
sql`UPDATE session_message SET data = ${JSON.stringify(messageData(row.type, data))} WHERE id = ${row.id}`,
)
}
yield* tx.run(sql`DELETE FROM event WHERE type = 'session.compaction.delta.1'`)
const events = yield* tx.all<{ id: string; aggregateID: string; seq: number; type: string; data: string }>(sql`
SELECT id, aggregate_id as aggregateID, seq, type, data
FROM event
WHERE type IN (
'session.skill.activated.1',
'session.skill.activated.2',
'session.compaction.started.1',
'session.compaction.started.2',
'session.compaction.ended.1',
'session.compaction.failed.1',
'session.compaction.failed.2',
'session.revert.staged.1',
'session.revert.staged.2'
)
ORDER BY aggregate_id, seq
`)
const compactionReasons = new Map<string, "auto" | "manual">()
for (const row of events) {
const data = object(decodeJson(row.data))
if (row.type.startsWith("session.compaction.ended.")) {
compactionReasons.delete(row.aggregateID)
continue
}
const event = eventData(row.type, data, compactionReasons.get(row.aggregateID))
if (row.type.startsWith("session.compaction.started."))
compactionReasons.set(row.aggregateID, event.data.reason === "auto" ? "auto" : "manual")
if (row.type.startsWith("session.compaction.failed.")) compactionReasons.delete(row.aggregateID)
yield* tx.run(
sql`UPDATE event SET type = ${event.type}, data = ${JSON.stringify(event.data)} WHERE id = ${row.id}`,
)
}
})
},
} satisfies DatabaseMigration.Migration
function messageData(type: string, data: Record<string, unknown>) {
if (type === "skill")
return defined({
metadata: data.metadata,
time: data.time,
skill: data.skill ?? data.id ?? data.name,
name: data.name,
text: data.text,
})
if (type === "shell") {
const shell = object(data.shell)
return defined({
metadata: data.metadata,
time: data.time,
shellID: data.shellID ?? shell.id,
command: data.command ?? shell.command,
status: data.status ?? shell.status,
exit: data.exit ?? shell.exit,
output: data.output,
})
}
if (type === "assistant")
return defined({
metadata: data.metadata,
time: data.time,
agent: data.agent,
model: data.model,
content: Array.isArray(data.content) ? data.content.map(assistantContent) : data.content,
snapshot: data.snapshot,
finish: data.finish,
cost: data.cost,
tokens: data.tokens,
error: data.error,
retry: data.retry,
})
if (type === "compaction") {
if (data.status === "failed")
return defined({
metadata: data.metadata,
time: data.time,
status: data.status,
reason: data.reason,
error: data.error ?? genericCompactionError,
})
return defined({
metadata: data.metadata,
time: data.time,
status: data.status,
reason: data.reason,
summary: data.summary,
recent: data.recent,
})
}
if (type === "synthetic")
return defined({ metadata: data.metadata, time: data.time, text: data.text, description: data.description })
const { sessionID: _, ...current } = data
return current
}
function assistantContent(value: unknown) {
const content = object(value)
if (content.type === "text") return defined({ type: content.type, text: content.text })
if (content.type === "reasoning")
return defined({ type: content.type, text: content.text, state: content.state, time: content.time })
if (content.type !== "tool") return content
return defined({
type: content.type,
id: content.id,
name: content.name,
executed: content.executed,
providerState: content.providerState,
providerResultState: content.providerResultState,
state: toolState(content.state),
time: content.time,
})
}
function toolState(value: unknown) {
const state = object(value)
if (state.status === "pending" || state.status === "streaming")
return defined({ status: "streaming", input: state.input })
if (state.status === "running")
return defined({ status: state.status, input: state.input, structured: state.structured, content: state.content })
if (state.status === "completed")
return defined({
status: state.status,
input: state.input,
structured: state.structured,
content: state.content,
result: state.result,
})
if (state.status === "error")
return defined({
status: state.status,
input: state.input,
structured: state.structured,
content: state.content,
error: state.error,
result: state.result,
})
return state
}
function eventData(type: string, data: Record<string, unknown>, compactionReason?: "auto" | "manual") {
if (type.startsWith("session.skill.activated."))
return {
type: "session.skill.activated.1",
data: defined({ sessionID: data.sessionID, id: data.id ?? data.name, name: data.name, text: data.text }),
}
if (type.startsWith("session.compaction.started."))
return {
type: "session.compaction.started.1",
data: defined({
sessionID: data.sessionID,
reason: data.reason,
recent: data.recent ?? "",
inputID: data.inputID,
}),
}
if (type.startsWith("session.compaction.failed."))
return {
type: "session.compaction.failed.1",
data: defined({
sessionID: data.sessionID,
reason: data.reason ?? compactionReason ?? "manual",
error: data.error ?? genericCompactionError,
inputID: data.inputID,
}),
}
const revert = object(data.revert)
return {
type: "session.revert.staged.1",
data: defined({
sessionID: data.sessionID,
revert: defined({
messageID: revert.messageID,
partID: revert.partID,
snapshot: revert.snapshot,
files: Array.isArray(revert.files)
? revert.files.map((value) => {
const file = object(value)
return defined({
file: file.file ?? file.path,
patch: file.patch,
additions: file.additions,
deletions: file.deletions,
status: file.status,
})
})
: undefined,
}),
}),
}
}
const genericCompactionError = {
type: "compaction.failed",
message: "Compaction failed before recording an error",
}
function object(value: unknown): Record<string, unknown> {
return isObject(value) ? value : {}
}
function defined(value: Record<string, unknown>) {
return Object.fromEntries(Object.entries(value).filter((entry) => entry[1] !== undefined))
}

View file

@ -1,6 +1,6 @@
export * as File from "./file"
import { Revert } from "@opencode-ai/schema/revert"
import { FileDiff } from "@opencode-ai/schema/file-diff"
export const Diff = Revert.FileDiff
export const Diff = FileDiff.Info
export type Diff = typeof Diff.Type

View file

@ -606,7 +606,7 @@ const layer = Layer.effect(
file,
])).text
return {
path: file,
file,
status,
additions: binary ? 0 : Number(stats[0] ?? 0),
deletions: binary ? 0 : Number(stats[1] ?? 0),

View file

@ -2,6 +2,7 @@ import path from "path"
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { ModelsDev } from "@opencode-ai/schema/models-dev"
import { Money } from "@opencode-ai/schema/money"
import { Global } from "./global"
import { Flag } from "./flag/flag"
import { Flock } from "./util/flock"
@ -18,10 +19,10 @@ export type CatalogModelStatus = typeof CatalogModelStatus.Type
const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}`
const CostTier = Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
cache_read: Schema.optional(Schema.Finite),
cache_write: Schema.optional(Schema.Finite),
input: Money.USDPerMillionTokens,
output: Money.USDPerMillionTokens,
cache_read: Schema.optional(Money.USDPerMillionTokens),
cache_write: Schema.optional(Money.USDPerMillionTokens),
tier: Schema.Struct({
type: Schema.Literal("context"),
size: Schema.Finite,
@ -29,17 +30,17 @@ const CostTier = Schema.Struct({
})
const Cost = Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
cache_read: Schema.optional(Schema.Finite),
cache_write: Schema.optional(Schema.Finite),
input: Money.USDPerMillionTokens,
output: Money.USDPerMillionTokens,
cache_read: Schema.optional(Money.USDPerMillionTokens),
cache_write: Schema.optional(Money.USDPerMillionTokens),
tiers: Schema.optional(Schema.Array(CostTier)),
context_over_200k: Schema.optional(
Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
cache_read: Schema.optional(Schema.Finite),
cache_write: Schema.optional(Schema.Finite),
input: Money.USDPerMillionTokens,
output: Money.USDPerMillionTokens,
cache_read: Schema.optional(Money.USDPerMillionTokens),
cache_write: Schema.optional(Money.USDPerMillionTokens),
}),
),
})

View file

@ -125,6 +125,7 @@ export const Plugin = define({
yield* ctx.agent.transform((draft) => {
draft.update(AgentV2.defaultID, (item) => {
item.name = AgentV2.Name.make("Build")
item.description = "The default agent. Executes tools based on configured permissions."
item.mode = "primary"
item.permissions.push(
@ -136,6 +137,7 @@ export const Plugin = define({
})
draft.update(AgentV2.ID.make("plan"), (item) => {
item.name = AgentV2.Name.make("Plan")
item.description = "Plan mode. Disallows all edit tools."
item.mode = "primary"
item.permissions.push(
@ -155,6 +157,7 @@ export const Plugin = define({
})
draft.update(AgentV2.ID.make("general"), (item) => {
item.name = AgentV2.Name.make("General")
item.description =
"General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel."
item.mode = "subagent"
@ -167,6 +170,7 @@ export const Plugin = define({
})
draft.update(AgentV2.ID.make("explore"), (item) => {
item.name = AgentV2.Name.make("Explore")
item.description =
'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.'
item.system = PROMPT_EXPLORE
@ -189,6 +193,7 @@ export const Plugin = define({
})
draft.update(AgentV2.ID.make("compaction"), (item) => {
item.name = AgentV2.Name.make("Compaction")
item.mode = "primary"
item.hidden = true
item.system = PROMPT_COMPACTION
@ -196,6 +201,7 @@ export const Plugin = define({
})
draft.update(AgentV2.ID.make("title"), (item) => {
item.name = AgentV2.Name.make("Title")
item.mode = "primary"
item.hidden = true
item.system = PROMPT_TITLE
@ -203,6 +209,7 @@ export const Plugin = define({
})
draft.update(AgentV2.ID.make("summary"), (item) => {
item.name = AgentV2.Name.make("Summary")
item.mode = "primary"
item.hidden = true
item.system = PROMPT_SUMMARY

View file

@ -1,5 +1,6 @@
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import type { ModelV2Info } from "@opencode-ai/sdk/v2/types"
import { Money } from "@opencode-ai/schema/money"
import type { ModelInfo } from "@opencode-ai/sdk/v2/types"
import { Effect, Stream } from "effect"
import { EventV2 } from "../event"
import { ModelV2 } from "../model"
@ -11,13 +12,13 @@ function released(date: string) {
return Number.isFinite(time) ? time : 0
}
function cost(input: ModelsDev.Model["cost"]): ModelV2Info["cost"] {
function cost(input: ModelsDev.Model["cost"]): ModelInfo["cost"] {
const base = {
input: input?.input ?? 0,
output: input?.output ?? 0,
input: input?.input ?? Money.USDPerMillionTokens.zero,
output: input?.output ?? Money.USDPerMillionTokens.zero,
cache: {
read: input?.cache_read ?? 0,
write: input?.cache_write ?? 0,
read: input?.cache_read ?? Money.USDPerMillionTokens.zero,
write: input?.cache_write ?? Money.USDPerMillionTokens.zero,
},
}
return [
@ -27,8 +28,8 @@ function cost(input: ModelsDev.Model["cost"]): ModelV2Info["cost"] {
input: item.input,
output: item.output,
cache: {
read: item.cache_read ?? 0,
write: item.cache_write ?? 0,
read: item.cache_read ?? Money.USDPerMillionTokens.zero,
write: item.cache_write ?? Money.USDPerMillionTokens.zero,
},
})) ?? []),
...(input?.context_over_200k
@ -41,8 +42,8 @@ function cost(input: ModelsDev.Model["cost"]): ModelV2Info["cost"] {
input: input.context_over_200k.input,
output: input.context_over_200k.output,
cache: {
read: input.context_over_200k.cache_read ?? 0,
write: input.context_over_200k.cache_write ?? 0,
read: input.context_over_200k.cache_read ?? Money.USDPerMillionTokens.zero,
write: input.context_over_200k.cache_write ?? Money.USDPerMillionTokens.zero,
},
},
]
@ -50,13 +51,13 @@ function cost(input: ModelsDev.Model["cost"]): ModelV2Info["cost"] {
]
}
function mergeCost(base: ModelV2Info["cost"], override: ModelsDev.Model["cost"] | undefined) {
function mergeCost(base: ModelInfo["cost"], override: ModelsDev.Model["cost"] | undefined) {
if (!override) return base
const next = cost(override)
const [baseDefault, ...baseTiers] = base
const [nextDefault, ...nextTiers] = next
const tierKey = (item: ModelV2Info["cost"][number]) => `${item.tier?.type ?? "base"}:${item.tier?.size ?? 0}`
const merge = (left: ModelV2Info["cost"][number], right: ModelV2Info["cost"][number]) => ({
const tierKey = (item: ModelInfo["cost"][number]) => `${item.tier?.type ?? "base"}:${item.tier?.size ?? 0}`
const merge = (left: ModelInfo["cost"][number], right: ModelInfo["cost"][number]) => ({
...left,
...right,
tier: right.tier ?? left.tier,
@ -67,12 +68,25 @@ function mergeCost(base: ModelV2Info["cost"], override: ModelsDev.Model["cost"]
const current = tiers.get(tierKey(item))
tiers.set(tierKey(item), current ? merge(current, item) : item)
}
return [merge(baseDefault ?? { input: 0, output: 0, cache: { read: 0, write: 0 } }, nextDefault), ...tiers.values()]
return [
merge(
baseDefault ?? {
input: Money.USDPerMillionTokens.zero,
output: Money.USDPerMillionTokens.zero,
cache: {
read: Money.USDPerMillionTokens.zero,
write: Money.USDPerMillionTokens.zero,
},
},
nextDefault,
),
...tiers.values(),
]
}
const OPENAI_INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"]
function reasoningVariants(provider: ModelsDev.Provider, model: ModelsDev.Model): NonNullable<ModelV2Info["variants"]> {
function reasoningVariants(provider: ModelsDev.Provider, model: ModelsDev.Model): NonNullable<ModelInfo["variants"]> {
const npm = model.provider?.npm ?? provider.npm
const options = model.reasoning_options ?? []
const effort = options.find((option) => option.type === "effort")
@ -117,7 +131,7 @@ function settingsForEffort(npm: string | undefined, effort: string): ProviderV2.
function budgetVariants(
npm: string | undefined,
option: Extract<NonNullable<ModelsDev.Model["reasoning_options"]>[number], { type: "budget_tokens" }>,
): NonNullable<ModelV2Info["variants"]> {
): NonNullable<ModelInfo["variants"]> {
const max = option.max
const high =
option.max === undefined
@ -146,7 +160,7 @@ function modeName(model: ModelsDev.Model, mode: string) {
return `${model.name} ${mode.charAt(0).toUpperCase()}${mode.slice(1)}`
}
function mergeVariants(model: ModelV2Info, next: NonNullable<ModelV2Info["variants"]>) {
function mergeVariants(model: ModelInfo, next: NonNullable<ModelInfo["variants"]>) {
const variants = model.variants ?? []
const existing = new Map(variants.map((variant) => [variant.id, variant]))
const nextIDs = new Set(next.map((variant) => variant.id))
@ -157,13 +171,13 @@ function mergeVariants(model: ModelV2Info, next: NonNullable<ModelV2Info["varian
}
function applyModel(
draft: ModelV2Info,
draft: ModelInfo,
model: ModelsDev.Model,
input: {
readonly name?: string
readonly cost?: ModelV2Info["cost"]
readonly cost?: ModelInfo["cost"]
readonly request?: NonNullable<NonNullable<ModelsDev.Model["experimental"]>["modes"]>[string]["provider"]
readonly variants?: NonNullable<ModelV2Info["variants"]>
readonly variants?: NonNullable<ModelInfo["variants"]>
} = {},
) {
draft.name = input.name ?? model.name

View file

@ -10,6 +10,7 @@ import { Integration } from "../../integration"
import { ModelV2 } from "../../model"
import { ProviderV2 } from "../../provider"
import { ConfigProviderV1 } from "../../v1/config/provider"
import { Money } from "@opencode-ai/schema/money"
import { ConfigProviderOptionsV1 } from "../../v1/config/provider-options"
import { ConfigV1 } from "../../v1/config/config"
@ -220,20 +221,23 @@ function withoutCredentials(body: Readonly<Record<string, unknown>> | undefined)
function remoteCost(input: NonNullable<(typeof ConfigProviderV1.Model.Type)["cost"]>) {
const base = {
input: input.input,
output: input.output,
cache: { read: input.cache_read ?? 0, write: input.cache_write ?? 0 },
input: Money.USDPerMillionTokens.make(input.input),
output: Money.USDPerMillionTokens.make(input.output),
cache: {
read: Money.USDPerMillionTokens.make(input.cache_read ?? 0),
write: Money.USDPerMillionTokens.make(input.cache_write ?? 0),
},
}
if (!input.context_over_200k) return [base]
return [
base,
{
tier: { type: "context" as const, size: 200_000 },
input: input.context_over_200k.input,
output: input.context_over_200k.output,
input: Money.USDPerMillionTokens.make(input.context_over_200k.input),
output: Money.USDPerMillionTokens.make(input.context_over_200k.output),
cache: {
read: input.context_over_200k.cache_read ?? 0,
write: input.context_over_200k.cache_write ?? 0,
read: Money.USDPerMillionTokens.make(input.context_over_200k.cache_read ?? 0),
write: Money.USDPerMillionTokens.make(input.context_over_200k.cache_write ?? 0),
},
},
]

View file

@ -33,7 +33,8 @@ export const Plugin = define({
SkillV2.EmbeddedSource.make({
type: "embedded",
skill: SkillV2.Info.make({
name: "opencode",
id: SkillV2.ID.make("opencode"),
name: SkillV2.Name.make("OpenCode"),
description: OpencodeDescription,
location: AbsolutePath.make("/builtin/opencode.md"),
content: OpencodeContent,
@ -44,7 +45,8 @@ export const Plugin = define({
SkillV2.EmbeddedSource.make({
type: "embedded",
skill: SkillV2.Info.make({
name: "report",
id: SkillV2.ID.make("report"),
name: SkillV2.Name.make("Report"),
description: REPORT_DESCRIPTION,
slash: true,
location: AbsolutePath.make("/builtin/report.md"),
@ -103,15 +105,22 @@ const configuredPlugins = Effect.fn("SkillPlugin.configuredPlugins")(function* (
})
function terminal() {
return [
process.env.TERM_PROGRAM ? `TERM_PROGRAM=${process.env.TERM_PROGRAM}` : undefined,
process.env.TERM ? `TERM=${process.env.TERM}` : undefined,
process.env.COLORTERM ? `COLORTERM=${process.env.COLORTERM}` : undefined,
]
.filter((item): item is string => item !== undefined)
.join(", ") || "Unavailable: terminal environment variables are not set"
return (
[
process.env.TERM_PROGRAM ? `TERM_PROGRAM=${process.env.TERM_PROGRAM}` : undefined,
process.env.TERM ? `TERM=${process.env.TERM}` : undefined,
process.env.COLORTERM ? `COLORTERM=${process.env.COLORTERM}` : undefined,
]
.filter((item): item is string => item !== undefined)
.join(", ") || "Unavailable: terminal environment variables are not set"
)
}
function shell() {
return process.env.SHELL ?? process.env.ComSpec ?? process.env.COMSPEC ?? "Unavailable: shell environment variable is not set"
return (
process.env.SHELL ??
process.env.ComSpec ??
process.env.COMSPEC ??
"Unavailable: shell environment variable is not set"
)
}

View file

@ -1,7 +1,7 @@
export * as SessionV2 from "./session"
export * from "./session/schema"
import { DateTime, Effect, Layer, Schema, Context, Stream, Scope } from "effect"
import { Effect, Layer, Schema, Context, Stream, Scope } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session"
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
import { ProjectV2 } from "./project"
@ -19,6 +19,7 @@ import { SessionSchema } from "./session/schema"
import { AbsolutePath, PositiveInt, RelativePath } from "./schema"
import { AgentV2 } from "./agent"
import { SessionV1 } from "./v1/session"
import { Money } from "@opencode-ai/schema/money"
import { InstallationVersion } from "./installation/version"
import { Slug } from "./util/slug"
import { ProjectTable } from "./project/sql"
@ -34,7 +35,7 @@ import { SessionEvent } from "./session/event"
import { SessionInput } from "./session/input"
import { Snapshot } from "./snapshot"
import { SessionRevert } from "./session/revert"
import { Revert } from "@opencode-ai/schema/revert"
import { Session } from "@opencode-ai/schema/session"
import { FSUtil } from "./fs-util"
import { Mime } from "./mime"
import type { EventLog } from "@opencode-ai/schema/event-log"
@ -46,8 +47,8 @@ import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
import { KeyedMutex } from "./effect/keyed-mutex"
import { fileURLToPath } from "url"
export const RevertState = Revert.State
export type RevertState = Revert.State
export const RevertState = Session.Revert
export type RevertState = Session.Revert
// get project -> project.locations
//
@ -136,7 +137,7 @@ export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.Bus
sessionID: SessionSchema.ID,
}) {}
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
skill: Schema.String,
skill: SkillV2.ID,
}) {}
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
@ -170,14 +171,14 @@ export interface Interface {
id: SessionMessage.ID
direction: "previous" | "next"
}
}) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
}) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
readonly message: (input: {
sessionID: SessionSchema.ID
messageID: SessionMessage.ID
}) => Effect.Effect<SessionMessage.Message | undefined>
}) => Effect.Effect<SessionMessage.Info | undefined>
readonly context: (
sessionID: SessionSchema.ID,
) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
/**
* Durable, ordered, gap-free session log read. Replays public durable
* session events after the exclusive `after` cursor, emits a `Synced`
@ -191,7 +192,10 @@ export interface Interface {
after?: number
follow?: boolean
}) => Stream.Stream<SessionEvent.DurableEvent | EventLog.Synced, NotFoundError>
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, NotFoundError>
readonly switchAgent: (input: {
sessionID: SessionSchema.ID
agent: AgentV2.ID
}) => Effect.Effect<void, NotFoundError>
readonly switchModel: (input: {
sessionID: SessionSchema.ID
model: ModelV2.Ref
@ -209,7 +213,7 @@ export interface Interface {
sessionID: SessionSchema.ID
command: string
arguments?: string
agent?: string
agent?: AgentV2.ID
model?: ModelV2.Ref
files?: PromptInput.Prompt["files"]
agents?: PromptInput.Prompt["agents"]
@ -227,7 +231,7 @@ export interface Interface {
readonly skill: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
skill: string
skill: SkillV2.ID
resume?: boolean
}) => Effect.Effect<void, NotFoundError | SkillNotFoundError>
readonly compact: (
@ -250,7 +254,7 @@ export interface Interface {
sessionID: SessionSchema.ID
messageID: SessionMessage.ID
files?: boolean
}) => Effect.Effect<Revert.State, NotFoundError | MessageNotFoundError | BusyError | Snapshot.Error>
}) => Effect.Effect<Session.Revert, NotFoundError | MessageNotFoundError | BusyError | Snapshot.Error>
readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError | Snapshot.Error>
readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError>
}
@ -273,7 +277,7 @@ const layer = Layer.effect(
const scope = yield* Scope.Scope
const activeShells = new Set<SessionSchema.ID>()
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
@ -322,7 +326,7 @@ const layer = Layer.effect(
variant: input.model.variant,
}
: undefined,
cost: 0,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: now, updated: now },
})
@ -529,7 +533,7 @@ const layer = Layer.effect(
})
const model = command.model ?? commandAgent?.model ?? input.model
if (agent !== undefined && session.agent !== AgentV2.ID.make(agent))
yield* result.switchAgent({ sessionID: input.sessionID, agent })
yield* result.switchAgent({ sessionID: input.sessionID, agent: AgentV2.ID.make(agent) })
if (model !== undefined) yield* result.switchModel({ sessionID: input.sessionID, model })
return yield* result.prompt({
@ -591,12 +595,13 @@ const layer = Layer.effect(
skill: Effect.fn("V2Session.skill")(function* (input) {
const session = yield* result.get(input.sessionID)
const skills = yield* SkillV2.Service.pipe(Effect.provide(locations.get(session.location)))
const skill = (yield* skills.list()).find((item) => item.name === input.skill)
const skill = (yield* skills.list()).find((item) => item.id === input.skill)
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
yield* events.publish(
SessionEvent.Skill.Activated,
{
sessionID: input.sessionID,
id: skill.id,
name: skill.name,
text: skill.content,
},

View file

@ -64,19 +64,21 @@ type Dependencies = {
export type AutoInput = {
readonly sessionID: SessionSchema.ID
readonly messages: readonly SessionMessage.Message[]
readonly messages: readonly SessionMessage.Info[]
readonly request: LLMRequest
}
type CompactInput = {
readonly sessionID: SessionSchema.ID
readonly messages: readonly SessionMessage.Message[]
readonly messages: readonly SessionMessage.Info[]
readonly model: Model
readonly inputID?: SessionMessage.ID
}
export type ManualInput = {
readonly session: SessionSchema.Info
readonly messages: readonly SessionMessage.Message[]
readonly messages: readonly SessionMessage.Info[]
readonly inputID: SessionMessage.ID
}
export interface Interface {
@ -99,7 +101,7 @@ export const serializeToolContent = (content: SessionMessage.ToolStateCompleted[
)
.join("\n")
const serialize = (message: SessionMessage.Message) => {
const serialize = (message: SessionMessage.Info) => {
if (message.type === "user") {
const files =
message.files?.map(
@ -128,7 +130,7 @@ const serialize = (message: SessionMessage.Message) => {
if (message.type === "system") return `[System update]: ${message.text}`
if (message.type === "synthetic") return `[Synthetic context]: ${message.text}`
if (message.type === "skill") return `[Skill activated: ${message.name}]\n${message.text}`
if (message.type === "shell") return `[Shell]: ${message.shell.command}\n${truncate(message.output?.output ?? "")}`
if (message.type === "shell") return `[Shell]: ${message.command}\n${truncate(message.output?.output ?? "")}`
return ""
}
@ -147,7 +149,7 @@ const settings = (documents: readonly Config.Entry[]) => {
}
const select = (
messages: readonly SessionMessage.Message[],
messages: readonly SessionMessage.Info[],
tokens: number,
): { readonly head: string; readonly recent: string } | undefined => {
const conversation = messages
@ -198,6 +200,7 @@ const make = (dependencies: Dependencies) => {
readonly context: readonly string[]
readonly recent: string
readonly output?: number
readonly inputID?: SessionMessage.ID
}) {
const context = input.model.route.defaults.limits?.context
if (context === undefined || context <= 0) return false
@ -208,6 +211,8 @@ const make = (dependencies: Dependencies) => {
yield* dependencies.events.publish(SessionEvent.Compaction.Started, {
sessionID: input.sessionID,
reason: input.reason,
recent: input.recent,
inputID: input.inputID,
})
const chunks: string[] = []
@ -235,9 +240,27 @@ const make = (dependencies: Dependencies) => {
}),
Effect.as(true),
Effect.catchTag("LLM.Error", () => Effect.succeed(false)),
Effect.onInterrupt(() =>
input.reason === "auto"
? dependencies.events.publish(SessionEvent.Compaction.Failed, {
sessionID: input.sessionID,
reason: input.reason,
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
inputID: input.inputID,
})
: Effect.void,
),
)
const summary = chunks.join("")
if (!summarized || failed || !summary.trim()) return false
if (!summarized || failed || !summary.trim()) {
yield* dependencies.events.publish(SessionEvent.Compaction.Failed, {
sessionID: input.sessionID,
reason: input.reason,
error: { type: "compaction.failed", message: "Compaction produced no summary" },
inputID: input.inputID,
})
return false
}
yield* dependencies.events.publish(SessionEvent.Compaction.Ended, {
sessionID: input.sessionID,
reason: input.reason,
@ -284,6 +307,7 @@ const make = (dependencies: Dependencies) => {
),
recent: forcedShortContext ? "" : selected.recent,
output: input.output,
inputID: input.inputID,
})
})
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: CompactInput) {
@ -327,6 +351,7 @@ export const layer = Layer.effect(
sessionID: input.session.id,
messages: input.messages,
model: resolved.model,
inputID: input.inputID,
})
}),
})

View file

@ -8,7 +8,7 @@ import { InstructionCheckpointTable, SessionMessageTable } from "./sql"
type DatabaseService = Database.Interface["db"]
const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* db

View file

@ -1,4 +1,4 @@
import { DateTime } from "effect"
import { DateTime, Schema } from "effect"
import { AgentV2 } from "../agent"
import { Location } from "../location"
import { ModelV2 } from "../model"
@ -9,7 +9,10 @@ import { WorkspaceV2 } from "../workspace"
import { SessionSchema } from "./schema"
import { SessionTable } from "./sql"
import { SessionMessage } from "./message"
import { Snapshot } from "../snapshot"
import { PersistedRevert } from "@opencode-ai/schema/session-revert"
import { Money } from "@opencode-ai/schema/money"
const decodeRevert = Schema.decodeUnknownSync(PersistedRevert)
export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.Info {
return SessionSchema.Info.make({
@ -31,7 +34,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
variant: ModelV2.VariantID.make(row.model.variant ?? "default"),
}
: undefined,
cost: row.cost,
cost: Money.USD.make(row.cost),
tokens: {
input: row.tokens_input,
output: row.tokens_output,
@ -46,7 +49,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
workspaceID: row.workspace_id ? WorkspaceV2.ID.make(row.workspace_id) : undefined,
}),
subpath: row.path ? RelativePath.make(row.path) : undefined,
revert: row.revert ? { ...row.revert, messageID: SessionMessage.ID.make(row.revert.messageID) } : undefined,
revert: row.revert ? decodeRevert(row.revert) : undefined,
time: {
created: DateTime.makeUnsafe(row.time_created),
updated: DateTime.makeUnsafe(row.time_updated),

View file

@ -2,7 +2,7 @@ export * as SessionInput from "./input"
import { and, asc, eq, isNull } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
import { Admitted, Compaction, Delivery, Entry, PromptEntry } from "@opencode-ai/schema/session-input"
import { Admitted, Compaction, Delivery, Info, PromptEntry } from "@opencode-ai/schema/session-input"
import type { Database } from "../database/database"
import type { EventV2 } from "../event"
import { KeyedMutex } from "../effect/keyed-mutex"
@ -14,7 +14,7 @@ import { SessionInputTable, SessionMessageTable } from "./sql"
type DatabaseService = Database.Interface["db"]
export { Admitted, Compaction, Delivery, Entry, PromptEntry }
export { Admitted, Compaction, Delivery, Info, PromptEntry }
const decodePrompt = Schema.decodeUnknownSync(Prompt)
const encodePrompt = Schema.encodeSync(Prompt)
@ -24,7 +24,7 @@ export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict
id: SessionMessage.ID,
}) {}
const fromRow = (row: typeof SessionInputTable.$inferSelect): Entry => {
const fromRow = (row: typeof SessionInputTable.$inferSelect): Info => {
const base = {
admittedSeq: row.admitted_seq,
id: SessionMessage.ID.make(row.id),

View file

@ -4,7 +4,7 @@ import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
export type MemoryState = {
messages: SessionMessage.Message[]
messages: SessionMessage.Info[]
}
export interface Adapter {
@ -14,13 +14,13 @@ export interface Adapter {
messageID: SessionMessage.ID,
) => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
readonly getShell: (
shellID: SessionMessage.Shell["shell"]["id"],
shellID: SessionMessage.Shell["shellID"],
) => Effect.Effect<SessionMessage.Shell | undefined, never, never>
readonly getCompaction: () => Effect.Effect<SessionMessage.Compaction | undefined, never, never>
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void, never, never>
readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void, never, never>
readonly updateCompaction: (compaction: SessionMessage.Compaction) => Effect.Effect<void, never, never>
readonly appendMessage: (message: SessionMessage.Message) => Effect.Effect<void, never, never>
readonly appendMessage: (message: SessionMessage.Info) => Effect.Effect<void, never, never>
}
export function memory(state: MemoryState): Adapter {
@ -29,9 +29,7 @@ export function memory(state: MemoryState): Adapter {
const shellIndex = (messageID: SessionMessage.ID) =>
state.messages.findLastIndex((message) => message.id === messageID)
const compactionIndex = () =>
state.messages.findLastIndex(
(message) => message.type === "compaction" && (message.status === "queued" || message.status === "running"),
)
state.messages.findLastIndex((message) => message.type === "compaction" && message.status === "running")
// A newer step supersedes stale incomplete rows; never resume an older assistant projection.
const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant")
@ -64,7 +62,7 @@ export function memory(state: MemoryState): Adapter {
getShell(shellID) {
return Effect.sync(() => {
return state.messages.find((message): message is SessionMessage.Shell => {
return message.type === "shell" && message.shell.id === shellID
return message.type === "shell" && message.shellID === shellID
})
})
},
@ -186,13 +184,13 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
id: SessionMessage.ID.fromEvent(event.id),
type: "system",
text: event.data.text,
metadata: event.metadata,
time: { created: event.created },
}),
),
"session.synthetic": (event) => {
return adapter.appendMessage(
SessionMessage.Synthetic.make({
sessionID: event.data.sessionID,
text: event.data.text,
description: event.data.description,
metadata: event.data.metadata,
@ -207,8 +205,10 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
SessionMessage.Skill.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "skill",
skill: event.data.id,
name: event.data.name,
text: event.data.text,
metadata: event.metadata,
time: { created: event.created },
}),
)
@ -219,7 +219,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
id: SessionMessage.ID.fromEvent(event.id),
type: "shell",
metadata: event.metadata,
shell: event.data.shell,
shellID: event.data.shell.id,
command: event.data.shell.command,
status: event.data.shell.status,
time: { created: event.created },
}),
)
@ -230,7 +232,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
if (currentShell) {
yield* adapter.updateShell(
produce(currentShell, (draft) => {
draft.shell = castDraft(event.data.shell)
draft.status = event.data.shell.status
draft.exit = event.data.shell.exit
draft.output = event.data.output
draft.time.completed = event.created
}),
@ -270,6 +273,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
type: "assistant",
agent: event.data.agent,
model: event.data.model,
metadata: event.metadata,
time: { created: event.created },
content: [],
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
@ -329,7 +333,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
id: event.data.callID,
name: event.data.name,
time: { created: event.created },
state: SessionMessage.ToolStatePending.make({ status: "pending", input: "" }),
state: SessionMessage.ToolStateStreaming.make({ status: "streaming", input: "" }),
}),
),
)
@ -339,7 +343,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
"session.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
if (match && match.state.status === "streaming") match.state.input = event.data.text
})
},
"session.tool.called": (event) => {
@ -382,7 +386,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
input: match.state.input,
structured: event.data.structured,
content: [...event.data.content],
outputPaths: event.data.outputPaths ? [...event.data.outputPaths] : [],
result: event.data.result,
}),
)
@ -392,7 +395,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
"session.tool.failed": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match && (match.state.status === "pending" || match.state.status === "running")) {
if (match && (match.state.status === "streaming" || match.state.status === "running")) {
match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState
match.time.completed = event.created
@ -448,31 +451,30 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}
})
},
"session.compaction.admitted": (event) =>
"session.compaction.admitted": () => Effect.void,
"session.compaction.started": (event) =>
adapter.appendMessage(
SessionMessage.Compaction.make({
id: event.data.inputID,
SessionMessage.CompactionRunning.make({
id: event.data.inputID ?? SessionMessage.ID.fromEvent(event.id),
type: "compaction",
status: "queued",
status: "running",
metadata: event.metadata,
reason: "manual",
reason: event.data.reason,
summary: "",
recent: "",
recent: event.data.recent ?? "",
time: { created: event.created },
}),
),
"session.compaction.started": (event) =>
"session.compaction.delta": (event) =>
Effect.gen(function* () {
if (event.data.reason !== "manual") return
const current = yield* adapter.getCompaction()
if (!current) return
yield* adapter.updateCompaction({ ...current, status: "running" })
if (current?.status !== "running") return
yield* adapter.updateCompaction({ ...current, summary: current.summary + event.data.text })
}),
"session.compaction.delta": () => Effect.void,
"session.compaction.ended": (event) => {
return Effect.gen(function* () {
const current = event.data.reason === "manual" ? yield* adapter.getCompaction() : undefined
if (current) {
const current = yield* adapter.getCompaction()
if (current?.status === "running") {
yield* adapter.updateCompaction({
...current,
status: "completed",
@ -496,11 +498,20 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
)
})
},
"session.compaction.failed": () =>
"session.compaction.failed": (event) =>
Effect.gen(function* () {
const current = yield* adapter.getCompaction()
if (!current) return
yield* adapter.updateCompaction({ ...current, status: "failed" })
const failed = SessionMessage.CompactionFailed.make({
id: current?.id ?? event.data.inputID ?? SessionMessage.ID.fromEvent(event.id),
type: "compaction",
status: "failed",
metadata: current?.metadata ?? event.metadata,
reason: event.data.reason,
error: event.data.error,
time: current?.time ?? { created: event.created },
})
if (current?.status === "running") return yield* adapter.updateCompaction(failed)
yield* adapter.appendMessage(failed)
}),
"session.revert.staged": () => Effect.void,
"session.revert.cleared": () => Effect.void,

View file

@ -24,15 +24,14 @@ import {
} from "./sql"
import type { DeepMutable } from "../schema"
import { Slug } from "../util/slug"
import { Money } from "@opencode-ai/schema/money"
type DatabaseService = Database.Interface["db"]
type MessageEvent = Exclude<
SessionEvent.DurableEvent,
typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type
>
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
type MessageEvent = Exclude<CurrentDurableEvent, typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type>
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
const encodeMessage = Schema.encodeSync(SessionMessage.Message)
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
export class SessionAlreadyProjected extends Error {}
@ -87,7 +86,14 @@ function sessionRow(info: SessionV1.SessionInfo): typeof SessionTable.$inferInse
tokens_reasoning: (info.tokens ?? { reasoning: 0 }).reasoning,
tokens_cache_read: (info.tokens ?? { cache: { read: 0 } }).cache.read,
tokens_cache_write: (info.tokens ?? { cache: { write: 0 } }).cache.write,
revert: info.revert ? { ...info.revert, messageID: SessionMessage.ID.make(info.revert.messageID) } : null,
revert: info.revert
? {
messageID: SessionMessage.ID.make(info.revert.messageID),
partID: info.revert.partID,
snapshot: info.revert.snapshot,
diff: info.revert.diff,
}
: null,
permission: info.permission ? [...info.permission] : undefined,
time_created: info.time.created,
time_updated: info.time.updated,
@ -151,7 +157,7 @@ const publishSessionUsage = Effect.fn("SessionProjector.publishUsage")(function*
if (!row) return
yield* events.publish(SessionEvent.UsageUpdated, {
sessionID,
cost: row.cost,
cost: Money.USD.make(row.cost),
tokens: {
input: row.input,
output: row.output,
@ -257,7 +263,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
eq(SessionMessageTable.session_id, event.data.parentID),
gt(SessionMessageTable.seq, cursor),
lt(SessionMessageTable.seq, copiedSeq + 1),
sql`${SessionMessageTable.type} != 'compaction' or json_extract(${SessionMessageTable.data}, '$.status') not in ('queued', 'running')`,
sql`${SessionMessageTable.type} != 'compaction' or json_extract(${SessionMessageTable.data}, '$.status') != 'running'`,
),
)
.orderBy(asc(SessionMessageTable.seq))
@ -280,7 +286,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
seq: row.seq,
time_created: row.time_created,
time_updated: row.time_updated,
data: row.type === "synthetic" ? { ...row.data, sessionID: event.data.sessionID } : row.data,
data: row.data,
}
}),
)
@ -336,7 +342,7 @@ function run(db: DatabaseService, event: MessageEvent) {
return Effect.gen(function* () {
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type })
const updateMessage = (message: SessionMessage.Message) => {
const updateMessage = (message: SessionMessage.Info) => {
if (event.durable === undefined)
return Effect.die(new Error("Durable Session event is missing aggregate sequence"))
const encoded = encodeMessage(message)
@ -353,7 +359,7 @@ function run(db: DatabaseService, event: MessageEvent) {
.run()
.pipe(Effect.orDie)
}
const appendMessage = (message: SessionMessage.Message) => insertMessage(db, event, message)
const appendMessage = (message: SessionMessage.Info) => insertMessage(db, event, message)
const adapter: SessionMessageUpdater.Adapter = {
getModel() {
return db
@ -412,7 +418,7 @@ function run(db: DatabaseService, event: MessageEvent) {
and(
eq(SessionMessageTable.session_id, event.data.sessionID),
eq(SessionMessageTable.type, "shell"),
sql`json_extract(${SessionMessageTable.data}, '$.shell.id') = ${shellID}`,
sql`json_extract(${SessionMessageTable.data}, '$.shellID') = ${shellID}`,
),
)
.orderBy(desc(SessionMessageTable.seq))
@ -433,7 +439,7 @@ function run(db: DatabaseService, event: MessageEvent) {
and(
eq(SessionMessageTable.session_id, event.data.sessionID),
eq(SessionMessageTable.type, "compaction"),
sql`json_extract(${SessionMessageTable.data}, '$.status') in ('queued', 'running')`,
sql`json_extract(${SessionMessageTable.data}, '$.status') = 'running'`,
),
)
.orderBy(desc(SessionMessageTable.seq))
@ -454,7 +460,7 @@ function run(db: DatabaseService, event: MessageEvent) {
})
}
function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, message: SessionMessage.Message) {
function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, message: SessionMessage.Info) {
if (event.durable === undefined) return Effect.die(new Error("Durable Session event is missing aggregate sequence"))
const encoded = encodeMessage(message)
const { id, type, ...data } = encoded
@ -661,14 +667,12 @@ const layer = Layer.effectDiscard(
Effect.gen(function* () {
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
const admitted = yield* SessionInput.projectCompactionAdmitted(db, {
yield* SessionInput.projectCompactionAdmitted(db, {
admittedSeq: event.durable.seq,
id: event.data.inputID,
sessionID: event.data.sessionID,
timeCreated: event.created,
})
if (admitted.id !== event.data.inputID) return
yield* run(db, event)
}),
)
yield* events.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
@ -676,15 +680,7 @@ const layer = Layer.effectDiscard(
yield* events.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
yield* events.project(SessionEvent.InstructionsUpdated, (event) => run(db, event))
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
yield* events.project(SessionEvent.Skill.Activated, (event) =>
insertMessage(db, event, {
id: SessionMessage.ID.fromEvent(event.id),
type: "skill",
name: event.data.name,
text: event.data.text,
time: { created: event.created },
}),
)
yield* events.project(SessionEvent.Skill.Activated, (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))
@ -730,22 +726,26 @@ const layer = Layer.effectDiscard(
yield* run(db, event)
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
yield* SessionInput.settleCompaction(db, {
sessionID: event.data.sessionID,
handledSeq: event.durable.seq,
})
if (event.data.reason === "manual")
yield* SessionInput.settleCompaction(db, {
sessionID: event.data.sessionID,
handledSeq: event.durable.seq,
})
}),
)
yield* events.project(SessionEvent.RevertEvent.Staged, (event) =>
db
.update(SessionTable)
.set({
revert: { ...event.data.revert, files: event.data.revert.files ? [...event.data.revert.files] : undefined },
time_updated: DateTime.toEpochMillis(event.created),
})
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie, Effect.asVoid),
Effect.gen(function* () {
const revert = event.data.revert
yield* db
.update(SessionTable)
.set({
revert: { ...revert, files: revert.files ? [...revert.files] : undefined },
time_updated: DateTime.toEpochMillis(event.created),
})
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
}),
)
yield* events.project(SessionEvent.RevertEvent.Cleared, (event) =>
db

View file

@ -1,7 +1,7 @@
export * as SessionRevert from "./revert"
import { and, asc, eq, gt } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
import { Effect, Schema } from "effect"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { RelativePath } from "../schema"
@ -46,7 +46,7 @@ const plan = Effect.fn("SessionRevert.plan")(function* (input: BoundaryInput) {
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
const files = new Map<RelativePath, Snapshot.ID>()
for (const row of rows) {
const message = yield* decode({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie)
@ -70,7 +70,7 @@ export const stage = Effect.fn("SessionRevert.stage")(function* (input: {
const next = yield* plan({ sessionID: input.session.id, messageID: input.messageID })
const restore = new Map<RelativePath, Snapshot.ID>()
if (original) {
for (const file of input.session.revert?.files ?? []) restore.set(file.path, original)
for (const file of input.session.revert?.files ?? []) restore.set(RelativePath.make(file.file), original)
}
if (input.files !== false) for (const [file, tree] of next) restore.set(file, tree)
if (restore.size) yield* snapshot.restore({ files: restore })
@ -81,10 +81,6 @@ export const stage = Effect.fn("SessionRevert.stage")(function* (input: {
const revert = {
messageID: input.messageID,
snapshot: original,
diff: files
.map((file) => file.patch)
.join("")
.trim(),
files,
} satisfies SessionSchema.Info["revert"]
yield* events.publish(SessionEvent.RevertEvent.Staged, {
@ -100,7 +96,7 @@ export const clear = Effect.fn("SessionRevert.clear")(function* (session: Sessio
const original = session.revert.snapshot ? Snapshot.ID.make(session.revert.snapshot) : undefined
if (original)
yield* snapshot.restore({
files: new Map((session.revert.files ?? []).map((file) => [file.path, original])),
files: new Map((session.revert.files ?? []).map((file) => [RelativePath.make(file.file), original])),
})
const events = yield* EventV2.Service
yield* events.publish(SessionEvent.RevertEvent.Cleared, {

View file

@ -11,6 +11,7 @@ import {
type ProviderErrorEvent,
} from "@opencode-ai/llm"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Money } from "@opencode-ai/schema/money"
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
import { AgentV2 } from "../../agent"
import { Config } from "../../config"
@ -67,13 +68,13 @@ export function calculateCost(costs: ModelV2.Info["cost"], tokens: StepTokens) {
.filter((cost) => cost.tier?.type === "context" && context > cost.tier.size)
.toSorted((a, b) => (b.tier?.size ?? 0) - (a.tier?.size ?? 0))[0]
const cost = tier ?? costs.find((cost) => cost.tier === undefined)
if (!cost) return 0
return (
if (!cost) return Money.USD.zero
return Money.USD.make(
(tokens.input * cost.input +
(tokens.output + tokens.reasoning) * cost.output +
tokens.cache.read * cost.cache.read +
tokens.cache.write * cost.cache.write) /
1_000_000
1_000_000,
)
}
@ -165,7 +166,7 @@ const layer = Layer.effect(
for (const message of yield* store.context(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
if (tool.type !== "tool" || (tool.state.status !== "streaming" && tool.state.status !== "running")) continue
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID,
assistantMessageID: message.id,
@ -300,8 +301,7 @@ const layer = Layer.effect(
// Durable publishes are serialized so tool fibers and step settlement never interleave
// mid-event.
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = [], error?: SessionError.Error) =>
serialized(publisher.publish(event, outputPaths, error))
const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
let overflowFailure: ProviderErrorEvent | undefined
const providerStream = llm.stream(hookedRequest).pipe(
Stream.runForEach((event) =>
@ -359,7 +359,6 @@ const layer = Layer.effect(
result: settlement.result,
output: settlement.output,
}),
settlement.outputPaths ?? [],
settlement.error,
).pipe(
Effect.andThen(
@ -599,12 +598,30 @@ const layer = Layer.effect(
return yield* compaction.compactManual({
session,
messages: yield* store.context(sessionID),
inputID: pending.id,
})
}),
).pipe(Effect.exit)
if (Exit.isSuccess(compacted) && compacted.value) return true
yield* events.publish(SessionEvent.Compaction.Failed, { sessionID })
if (Exit.isFailure(compacted)) return yield* Effect.failCause(compacted.cause)
if (Exit.isFailure(compacted)) {
const unsettled = yield* SessionInput.pendingCompaction(db, sessionID)
if (unsettled)
yield* events.publish(SessionEvent.Compaction.Failed, {
sessionID,
reason: "manual",
error: { type: "compaction.failed", message: Cause.pretty(compacted.cause) },
inputID: unsettled.id,
})
return yield* Effect.failCause(compacted.cause)
}
const unsettled = yield* SessionInput.pendingCompaction(db, sessionID)
if (unsettled)
yield* events.publish(SessionEvent.Compaction.Failed, {
sessionID,
reason: "manual",
error: { type: "compaction.failed", message: "Compaction could not start" },
inputID: unsettled.id,
})
return true
}),
)

View file

@ -6,13 +6,16 @@ import { SessionEvent } from "../event"
import { SessionMessage } from "../message"
import { SessionSchema } from "../schema"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Money } from "@opencode-ai/schema/money"
import { AgentV2 } from "../../agent"
import { Snapshot } from "../../snapshot"
type Input = {
readonly sessionID: SessionSchema.ID
readonly agent: string
readonly agent: AgentV2.ID
readonly model: ModelV2.Ref
readonly provider: string
readonly snapshot?: string
readonly snapshot?: Snapshot.ID
readonly assistantMessageID?: SessionMessage.ID
}
@ -226,7 +229,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
})
const publishStepFailure = Effect.fnUntraced(function* (usage?: {
readonly cost: number
readonly cost: Money.USD
readonly tokens: ReturnType<typeof tokens>
}) {
if (stepFailed || stepFailure === undefined) return
@ -265,11 +268,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(new Error(`Unknown tool call: ${callID}`))
}
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (
event: LLMEvent,
outputPaths: ReadonlyArray<string> = [],
error?: SessionError.Error,
) {
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent, error?: SessionError.Error) {
switch (event.type) {
case "step-start":
yield* startAssistant()
@ -395,7 +394,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
assistantMessageID: tool.assistantMessageID,
callID: event.id,
...result,
outputPaths,
...(executed ? { result: event.result } : {}),
executed,
resultState,

View file

@ -69,7 +69,7 @@ const providerMetadata = (
): ProviderMetadata | undefined => (state === undefined ? undefined : { [provider]: state })
const toolInput = (tool: SessionMessage.AssistantTool) =>
tool.state.status === "pending"
tool.state.status === "streaming"
? Option.getOrElse(decodeToolInput(tool.state.input), () => tool.state.input)
: tool.state.input
@ -168,7 +168,7 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => {
]
}
function toLLMMessage(message: SessionMessage.Message, model: ModelV2.Ref): Message[] {
function toLLMMessage(message: SessionMessage.Info, model: ModelV2.Ref): Message[] {
switch (message.type) {
case "agent-switched":
case "model-switched":
@ -202,7 +202,7 @@ function toLLMMessage(message: SessionMessage.Message, model: ModelV2.Ref): Mess
Message.make({
id: message.id,
role: "user",
content: `Shell command: ${message.shell.command}\n\n${message.output?.output ?? ""}`,
content: `Shell command: ${message.command}\n\n${message.output?.output ?? ""}`,
metadata: message.metadata,
}),
]
@ -232,5 +232,5 @@ ${message.recent}
}
/** Translate projected V2 Session history into canonical @opencode-ai/llm context. */
export const toLLMMessages = (messages: readonly SessionMessage.Message[], model: ModelV2.Ref) =>
export const toLLMMessages = (messages: readonly SessionMessage.Info[], model: ModelV2.Ref) =>
messages.flatMap((message) => toLLMMessage(message, model))

View file

@ -5,7 +5,7 @@ import { ProjectTable } from "../project/sql"
import type { SessionMessage } from "./message"
import type { Prompt } from "@opencode-ai/schema/prompt"
import type { SessionInput } from "./input"
import type { Snapshot } from "../snapshot"
import type { FileDiff } from "@opencode-ai/schema/file-diff"
import { PermissionV1 } from "../v1/permission"
import { ProjectV2 } from "../project"
import type { SessionSchema } from "./schema"
@ -13,10 +13,11 @@ import type { MessageID, PartID, SessionV1 } from "../v1/session"
import { WorkspaceV2 } from "../workspace"
import { Timestamps } from "../database/schema.sql"
import type { Instructions } from "../instructions/index"
import type { Revert } from "@opencode-ai/schema/revert"
import type { Session } from "@opencode-ai/schema/session"
import type { RevertV1 } from "@opencode-ai/schema/session-revert"
import type { Schema } from "effect"
type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
type SessionMessageData = Omit<(typeof SessionMessage.Info)["Encoded"], "type" | "id">
type V1MessageData = Omit<SessionV1.Info, "id" | "sessionID">
type V1PartData = Omit<SessionV1.Part, "id" | "sessionID" | "messageID">
@ -41,7 +42,7 @@ export const SessionTable = sqliteTable(
summary_additions: integer(),
summary_deletions: integer(),
summary_files: integer(),
summary_diffs: text({ mode: "json" }).$type<Snapshot.LegacyFileDiff[]>(),
summary_diffs: text({ mode: "json" }).$type<FileDiff.LegacyInfo[]>(),
metadata: text({ mode: "json" }).$type<Record<string, unknown>>(),
cost: real().notNull().default(0),
tokens_input: integer().notNull().default(0),
@ -49,7 +50,7 @@ export const SessionTable = sqliteTable(
tokens_reasoning: integer().notNull().default(0),
tokens_cache_read: integer().notNull().default(0),
tokens_cache_write: integer().notNull().default(0),
revert: text({ mode: "json" }).$type<Revert.State>(),
revert: text({ mode: "json" }).$type<Session.Revert | RevertV1>(),
permission: text({ mode: "json" }).$type<PermissionV1.Ruleset>(),
agent: text(),
model: text({ mode: "json" }).$type<{
@ -148,7 +149,7 @@ export const SessionInputTable = sqliteTable(
.$type<SessionSchema.ID>()
.notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }),
type: text().$type<SessionInput.Entry["type"]>().notNull(),
type: text().$type<SessionInput.Info["type"]>().notNull(),
prompt: text({ mode: "json" }).$type<Prompt>(),
delivery: text().$type<SessionInput.Delivery>(),
admitted_seq: integer().notNull(),

View file

@ -13,10 +13,10 @@ import { fromRow } from "./info"
export interface Interface {
readonly get: (sessionID: Session.ID) => Effect.Effect<Session.Info | undefined>
readonly context: (sessionID: Session.ID) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
readonly context: (sessionID: Session.ID) => Effect.Effect<SessionMessage.Info[], MessageDecodeError>
readonly message: (
messageID: SessionMessage.ID,
) => Effect.Effect<{ readonly sessionID: Session.ID; readonly message: SessionMessage.Message } | undefined>
) => Effect.Effect<{ readonly sessionID: Session.ID; readonly message: SessionMessage.Info } | undefined>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionStore") {}
@ -25,7 +25,7 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { db } = yield* Database.Service
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
return Service.of({
get: Effect.fn("SessionStore.get")(function* (sessionID) {

View file

@ -28,11 +28,15 @@ export type Source = typeof Source.Type
export const Info = Skill.Info
export type Info = Skill.Info
export const ID = Skill.ID
export type ID = Skill.ID
export const Name = Skill.Name
export type Name = Skill.Name
export const Event = Skill.Event
export const available = (skills: ReadonlyArray<Info>, agent: AgentV2.Info) =>
skills.filter((skill) => PermissionV2.evaluate("skill", skill.name, agent.permissions).effect !== "deny")
skills.filter((skill) => PermissionV2.evaluate("skill", skill.id, agent.permissions).effect !== "deny")
const Frontmatter = Schema.Struct({
name: Schema.String.pipe(Schema.optional),
@ -96,7 +100,7 @@ const layer = Layer.effect(
source: Source.key(source),
type: source.type,
directories: [],
skills: [source.skill.name],
skills: [source.skill.id],
})
return { skills: [source.skill], directories: [] }
}
@ -112,15 +116,13 @@ const layer = Layer.effect(
if (!markdown) continue
const frontmatter = decodeFrontmatter(markdown.data).valueOrUndefined
if (!frontmatter) continue
const name =
frontmatter.name !== undefined
? frontmatter.name
: path.dirname(filepath) === directory
? path.basename(filepath, ".md")
: undefined
if (!name) continue
const id =
path.dirname(filepath) === directory
? path.basename(filepath, ".md")
: path.basename(path.dirname(filepath))
skills.push({
name,
id: ID.make(id),
name: Name.make(frontmatter.name ?? id),
description: frontmatter.description,
slash: metadataBoolean(frontmatter.metadata, "opencode/slash") ?? frontmatter.slash,
autoinvoke: metadataBoolean(frontmatter.metadata, "opencode/autoinvoke"),
@ -133,7 +135,7 @@ const layer = Layer.effect(
source: Source.key(source),
type: source.type,
directories,
skills: skills.map((skill) => skill.name),
skills: skills.map((skill) => skill.id),
})
return { skills, directories }
})
@ -148,7 +150,7 @@ const layer = Layer.effect(
yield* Effect.logInfo("skill cache invalidated", {
file,
sources: invalidated.map(([key]) => key),
skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.name)),
skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.id)),
})
yield* events.publish(Event.Updated, {}).pipe(Effect.asVoid)
})
@ -159,12 +161,12 @@ const layer = Layer.effect(
)
const list = Effect.fn("SkillV2.list")(function* () {
const skills = new Map<string, Info>()
const skills = new Map<ID, Info>()
for (const source of state.get().sources) {
const key = Source.key(source)
const loaded = cache.get(key) ?? (yield* load(source))
cache.set(key, loaded)
for (const skill of loaded.skills) skills.set(skill.name, skill)
for (const skill of loaded.skills) skills.set(skill.id, skill)
}
return Array.from(skills.values())
})
@ -180,4 +182,8 @@ const layer = Layer.effect(
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [SkillDiscovery.node, FSUtil.node, EventV2.node] })
export const node = makeLocationNode({
service: Service,
layer,
deps: [SkillDiscovery.node, FSUtil.node, EventV2.node],
})

View file

@ -8,7 +8,8 @@ import { SkillV2 } from "../skill"
import { Instructions } from "../instructions/index"
const Summary = Schema.Struct({
name: Schema.String,
id: SkillV2.ID,
name: SkillV2.Name,
description: Schema.String,
})
type Summary = typeof Summary.Type
@ -16,6 +17,7 @@ type Summary = typeof Summary.Type
const entries = (skills: ReadonlyArray<Summary>) =>
skills.flatMap((skill) => [
" <skill>",
` <id>${skill.id}</id>`,
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
" </skill>",
@ -34,8 +36,8 @@ const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary
const diff = Instructions.diffByKey(
previous,
current,
(skill) => skill.name,
(before, after) => before.description !== after.description,
(skill) => skill.id,
(before, after) => before.name !== after.name || before.description !== after.description,
)
// Additions and removals render as small deltas; anything else restates the full list.
if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0))
@ -50,7 +52,7 @@ const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary
...(diff.removed.length === 0
? []
: [
`The following skills are no longer available and must not be used: ${diff.removed.map((skill) => skill.name).join(", ")}.`,
`The following skill IDs are no longer available and must not be used: ${diff.removed.map((skill) => skill.id).join(", ")}.`,
]),
].join("\n")
}
@ -77,9 +79,9 @@ const layer = Layer.effect(
.flatMap((skill) =>
skill.description === undefined || skill.autoinvoke === false
? []
: [{ name: skill.name, description: skill.description }],
: [{ id: skill.id, name: skill.name, description: skill.description }],
)
.toSorted((a, b) => a.name.localeCompare(b.name))
.toSorted((a, b) => a.id.localeCompare(b.id))
return Instructions.make({
key: Instructions.Key.make("core/skill-guidance"),
codec: Schema.toCodecJson(Schema.Array(Summary)),

View file

@ -10,10 +10,10 @@ import { Git } from "./git"
import { Global } from "./global"
import { Location } from "./location"
import { AbsolutePath, RelativePath } from "./schema"
import { ID } from "@opencode-ai/schema/snapshot"
import { Hash } from "./util/hash"
export const ID = Schema.String.pipe(Schema.brand("Snapshot.ID"))
export type ID = typeof ID.Type
export { ID }
export class Error extends Schema.TaggedErrorClass<Error>()("Snapshot.Error", {
operation: Schema.Literals(["capture", "files", "diff", "preview", "restore"]),
@ -253,12 +253,3 @@ function failure(operation: Error["operation"], cause: unknown) {
cause,
})
}
/** Legacy persisted session diff shape. */
export type LegacyFileDiff = {
file?: string
patch?: string
additions: number
deletions: number
status?: "added" | "deleted" | "modified"
}

View file

@ -13,11 +13,11 @@ export const name = "skill"
const FILE_LIMIT = 10
export const Input = Schema.Struct({
name: Schema.String.annotate({ description: "The name of the skill from the available skills list" }),
id: SkillV2.ID.annotate({ description: "The ID of the skill from the available skills list" }),
})
export const Output = Schema.Struct({
name: Schema.String,
name: SkillV2.Name,
directory: Schema.String,
output: Schema.String,
})
@ -27,7 +27,7 @@ export const description = [
"",
"Use this tool to inject the skill's instructions and resources into the current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc. in the same directory as the skill.",
"",
"The skill name must match one of the available skills in the instructions.",
"The skill ID must match one of the available skills in the instructions.",
].join("\n")
export const toModelOutput = (skill: SkillV2.Info, files: ReadonlyArray<string>) => {
@ -70,13 +70,13 @@ export const Plugin = {
execute: (input, context) =>
Effect.gen(function* () {
const current = yield* skills.list()
const skill = current.find((skill) => skill.name === input.name)
if (!skill) return yield* unableToLoad(input.name)
const skill = current.find((skill) => skill.id === input.id)
if (!skill) return yield* unableToLoad(input.id)
return yield* Effect.gen(function* () {
yield* permission.assert({
action: name,
resources: [skill.name],
save: [skill.name],
resources: [skill.id],
save: [skill.id],
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
@ -94,7 +94,7 @@ export const Plugin = {
directory,
output: toModelOutput(skill, files),
}
}).pipe(Effect.mapError((error) => unableToLoad(input.name, error)))
}).pipe(Effect.mapError((error) => unableToLoad(input.id, error)))
}),
}),
),

View file

@ -1,4 +1,5 @@
import { describe, expect } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Fiber, Layer, Stream } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
@ -298,13 +299,31 @@ describe("CatalogV2", () => {
catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => {
model.capabilities.input = ["text"]
model.capabilities.output = ["text"]
model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }]
model.cost = [
{
input: Money.USDPerMillionTokens.make(1),
output: Money.USDPerMillionTokens.make(1),
cache: {
read: Money.USDPerMillionTokens.zero,
write: Money.USDPerMillionTokens.zero,
},
},
]
model.time.released = Date.now()
})
catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => {
model.capabilities.input = ["text"]
model.capabilities.output = ["text"]
model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }]
model.cost = [
{
input: Money.USDPerMillionTokens.make(10),
output: Money.USDPerMillionTokens.make(10),
cache: {
read: Money.USDPerMillionTokens.zero,
write: Money.USDPerMillionTokens.zero,
},
},
]
model.time.released = Date.now()
})
})

View file

@ -4,6 +4,7 @@ import { describe, expect } from "bun:test"
import { Effect, PubSub, Schema, Stream } from "effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { CommandV2 } from "@opencode-ai/core/command"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@ -87,7 +88,7 @@ Review files`,
name: "review",
template: "Review files",
description: "File review",
agent: "reviewer",
agent: AgentV2.ID.make("reviewer"),
model: {
providerID: ProviderV2.ID.make("anthropic"),
id: ModelV2.ID.make("claude"),

View file

@ -1,4 +1,5 @@
import { describe, expect } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Schema } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Config } from "@opencode-ai/core/config"
@ -253,7 +254,17 @@ describe("ConfigProviderPlugin.Plugin", () => {
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
expect(model.enabled).toBe(false)
expect(model.limit).toEqual({ context: 100, output: 75 })
expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }])
expect(model.cost).toEqual([
{
input: Money.USDPerMillionTokens.make(1),
output: Money.USDPerMillionTokens.make(2),
cache: {
read: Money.USDPerMillionTokens.zero,
write: Money.USDPerMillionTokens.zero,
},
tier: undefined,
},
])
expect(model.settings).toEqual({ baseURL: "https://example.test", retained: true })
expect(model.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.variants?.map((variant) => variant.id)).toEqual([

View file

@ -4,7 +4,7 @@ import { fileURLToPath } from "url"
import path from "path"
import { SqliteClient } from "@effect/sql-sqlite-bun"
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { Effect, Layer } from "effect"
import { Effect, Layer, Schema } from "effect"
import { eq, inArray, sql } from "drizzle-orm"
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
import { migrations } from "@opencode-ai/core/database/migration.gen"
@ -17,6 +17,7 @@ import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/
import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input"
import resetSessionEventsMigration from "@opencode-ai/core/database/migration/20260703200000_reset_v2_session_events"
import durableSessionInboxMigration from "@opencode-ai/core/database/migration/20260707010146_durable_session_inbox"
import migratePrelaunchV2StateMigration from "@opencode-ai/core/database/migration/20260707120000_migrate_prelaunch_v2_state"
import renameInstructionsMigration from "@opencode-ai/core/database/migration/20260705180000_rename_instructions"
import addSessionForkMigration from "@opencode-ai/core/database/migration/20260706223930_add-session-fork"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@ -26,6 +27,7 @@ import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionSchema } from "@opencode-ai/core/session/schema"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionTable } from "@opencode-ai/core/session/sql"
import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata"
import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
@ -42,6 +44,256 @@ const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
describe("DatabaseMigration", () => {
test("migrates pre-launch V2 state in place", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(
sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`,
)
yield* db.run(
sql`CREATE TABLE session_input (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, prompt text, delivery text, admitted_seq integer NOT NULL, promoted_seq integer, time_created integer NOT NULL)`,
)
yield* db.run(
sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, created integer NOT NULL, type text NOT NULL, data text NOT NULL)`,
)
yield* db.run(
sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL, owner_id text)`,
)
yield* db.run(
sql`CREATE TABLE instruction_checkpoint (session_id text PRIMARY KEY, baseline text NOT NULL, snapshot text NOT NULL, baseline_seq integer NOT NULL)`,
)
const messages = [
["msg_skill", "skill", { name: "effect", text: "Use Effect", time: { created: 1 } }],
[
"msg_shell",
"shell",
{
shell: { id: "sh_old", command: "pwd", status: "exited", exit: 0, cwd: "/tmp" },
output: { output: "/tmp", cursor: 4, size: 4, truncated: false },
time: { created: 2, completed: 3 },
},
],
[
"msg_assistant",
"assistant",
{
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{
type: "tool",
id: "call_old",
name: "read",
provider: "removed",
state: { status: "pending", input: '{"path":"README.md"}', title: "removed" },
time: { created: 3 },
},
],
time: { created: 3 },
},
],
[
"msg_failed",
"compaction",
{
status: "failed",
reason: "manual",
summary: "removed",
recent: "removed",
time: { created: 4 },
},
],
[
"msg_queued",
"compaction",
{ status: "queued", reason: "manual", summary: "", recent: "", time: { created: 5 } },
],
[
"msg_synthetic",
"synthetic",
{ sessionID: "ses_test", text: "context", description: "source", time: { created: 6 } },
],
[
"msg_running",
"compaction",
{ status: "running", reason: "auto", summary: "partial", recent: "recent", time: { created: 7 } },
],
[
"msg_completed",
"compaction",
{ status: "completed", reason: "auto", summary: "summary", recent: "recent", time: { created: 8 } },
],
] as const
for (const [id, type, data] of messages)
yield* db.run(
sql`INSERT INTO session_message VALUES (${id}, 'ses_test', ${type}, 1, 10, 11, ${JSON.stringify(data)})`,
)
yield* db.run(
sql`INSERT INTO session_input VALUES ('msg_queued', 'ses_test', 'compaction', NULL, NULL, 4, NULL, 5)`,
)
yield* db.run(sql`INSERT INTO event_sequence VALUES ('ses_test', 9, 'owner')`)
yield* db.run(sql`INSERT INTO instruction_checkpoint VALUES ('ses_test', 'baseline', '{"source":"value"}', 7)`)
const events = [
["evt_skill", 1, 101, "session.skill.activated.1", { sessionID: "ses_test", name: "effect", text: "Use" }],
["evt_started", 2, 102, "session.compaction.started.1", { sessionID: "ses_test", reason: "auto" }],
["evt_delta", 3, 103, "session.compaction.delta.1", { sessionID: "ses_test", text: "partial" }],
["evt_failed", 4, 104, "session.compaction.failed.1", { sessionID: "ses_test" }],
[
"evt_revert",
5,
105,
"session.revert.staged.1",
{
sessionID: "ses_test",
revert: {
messageID: "msg_skill",
snapshot: "tree",
diff: "removed",
files: [{ path: "src/a.ts", patch: "@@", additions: 1, deletions: 0, status: "modified" }],
},
},
],
[
"evt_skill_current",
6,
106,
"session.skill.activated.2",
{ sessionID: "ses_test", id: "effect-id", name: "Effect", text: "Use" },
],
] as const
for (const [id, seq, created, type, data] of events)
yield* db.run(
sql`INSERT INTO event VALUES (${id}, 'ses_test', ${seq}, ${created}, ${type}, ${JSON.stringify(data)})`,
)
yield* DatabaseMigration.applyOnly(db, [migratePrelaunchV2StateMigration])
const rows = yield* db.all<{
id: string
type: string
seq: number
time_created: number
time_updated: number
data: string
}>(sql`SELECT id, type, seq, time_created, time_updated, data FROM session_message ORDER BY id`)
for (const row of rows)
Schema.decodeUnknownSync(SessionMessage.Info)({ ...JSON.parse(row.data), id: row.id, type: row.type })
expect(rows.every((row) => row.seq === 1 && row.time_created === 10 && row.time_updated === 11)).toBe(true)
expect(rows.map((row) => [row.id, JSON.parse(row.data)])).toEqual([
[
"msg_assistant",
expect.objectContaining({
content: [expect.objectContaining({ state: { status: "streaming", input: '{"path":"README.md"}' } })],
}),
],
["msg_completed", expect.objectContaining({ status: "completed", summary: "summary", recent: "recent" })],
[
"msg_failed",
{
time: { created: 4 },
status: "failed",
reason: "manual",
error: {
type: "compaction.failed",
message: "Compaction failed before recording an error",
},
},
],
["msg_running", expect.objectContaining({ status: "running", summary: "partial", recent: "recent" })],
["msg_shell", expect.objectContaining({ shellID: "sh_old", command: "pwd", status: "exited", exit: 0 })],
["msg_skill", { time: { created: 1 }, skill: "effect", name: "effect", text: "Use Effect" }],
["msg_synthetic", { time: { created: 6 }, text: "context", description: "source" }],
])
expect(yield* db.get(sql`SELECT * FROM session_input`)).toEqual({
id: "msg_queued",
session_id: "ses_test",
type: "compaction",
prompt: null,
delivery: null,
admitted_seq: 4,
promoted_seq: null,
time_created: 5,
})
const migratedEvents = yield* db.all<{
id: string
aggregate_id: string
seq: number
created: number
type: string
data: string
}>(sql`SELECT * FROM event ORDER BY seq`)
expect(migratedEvents.map((event) => ({ ...event, data: JSON.parse(event.data) }))).toEqual([
{
id: "evt_skill",
aggregate_id: "ses_test",
seq: 1,
created: 101,
type: "session.skill.activated.1",
data: { sessionID: "ses_test", id: "effect", name: "effect", text: "Use" },
},
{
id: "evt_started",
aggregate_id: "ses_test",
seq: 2,
created: 102,
type: "session.compaction.started.1",
data: { sessionID: "ses_test", reason: "auto", recent: "" },
},
{
id: "evt_failed",
aggregate_id: "ses_test",
seq: 4,
created: 104,
type: "session.compaction.failed.1",
data: {
sessionID: "ses_test",
reason: "auto",
error: {
type: "compaction.failed",
message: "Compaction failed before recording an error",
},
},
},
{
id: "evt_revert",
aggregate_id: "ses_test",
seq: 5,
created: 105,
type: "session.revert.staged.1",
data: {
sessionID: "ses_test",
revert: {
messageID: "msg_skill",
snapshot: "tree",
files: [{ file: "src/a.ts", patch: "@@", additions: 1, deletions: 0, status: "modified" }],
},
},
},
{
id: "evt_skill_current",
aggregate_id: "ses_test",
seq: 6,
created: 106,
type: "session.skill.activated.1",
data: { sessionID: "ses_test", id: "effect-id", name: "Effect", text: "Use" },
},
])
expect(yield* db.get(sql`SELECT * FROM event_sequence`)).toEqual({
aggregate_id: "ses_test",
seq: 9,
owner_id: "owner",
})
expect(yield* db.get(sql`SELECT * FROM instruction_checkpoint`)).toEqual({
session_id: "ses_test",
baseline: "baseline",
snapshot: '{"source":"value"}',
baseline_seq: 7,
})
}),
)
})
test("resets incompatible V2 Session event history", async () => {
await run(
Effect.gen(function* () {

View file

@ -146,7 +146,7 @@ describe("Git trees", () => {
RelativePath.make("scope/tracked.txt"),
])
const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 1 })
expect(diffs.map((item) => [item.path, item.status])).toEqual([
expect(diffs.map((item) => [item.file, item.status])).toEqual([
[RelativePath.make("scope/added.txt"), "added"],
[RelativePath.make("scope/tracked.txt"), "modified"],
])
@ -154,7 +154,7 @@ describe("Git trees", () => {
const files = new Map([[RelativePath.make("scope/tracked.txt"), before]])
const preview = yield* git.tree.preview({ repository, current: after, files, context: 1 })
expect(preview).toHaveLength(1)
expect(preview[0]?.path).toBe(RelativePath.make("scope/tracked.txt"))
expect(preview[0]?.file).toBe(RelativePath.make("scope/tracked.txt"))
yield* git.tree.restore({ repository, files })
expect(yield* read(path.join(root.path, "scope", "tracked.txt"))).toBe("one\n")
expect(yield* read(path.join(root.path, "scope", "added.txt"))).toBe("added\n")

View file

@ -3,6 +3,7 @@ import path from "path"
import { describe, expect } from "bun:test"
import { Config } from "@opencode-ai/schema/config"
import { Plugin } from "@opencode-ai/schema/plugin"
import { Money } from "@opencode-ai/schema/money"
import { Context, DateTime, Effect, Equal, Hash, RcMap, Schema, Stream } from "effect"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
import { AgentV2 } from "@opencode-ai/core/agent"
@ -356,7 +357,7 @@ describe("LocationServiceMap", () => {
id: ModelV2.ID.make("chat"),
providerID: ProviderV2.ID.make("unavailable"),
},
cost: 0,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location,
@ -405,7 +406,7 @@ describe("LocationServiceMap", () => {
providerID: ProviderV2.ID.make("aliased"),
variant: ModelV2.VariantID.make("high"),
},
cost: 0,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location,

View file

@ -1,5 +1,6 @@
import path from "path"
import { describe, expect } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Layer } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
@ -51,23 +52,31 @@ describe("ModelsDevPlugin", () => {
temperature: true,
tool_call: true,
cost: {
input: 2.5,
output: 15,
input: Money.USDPerMillionTokens.make(2.5),
output: Money.USDPerMillionTokens.make(15),
tiers: [
{
tier: { type: "context", size: 272_000 },
input: 3,
output: 18,
cache_read: 0.25,
input: Money.USDPerMillionTokens.make(3),
output: Money.USDPerMillionTokens.make(18),
cache_read: Money.USDPerMillionTokens.make(0.25),
},
],
context_over_200k: { input: 5, output: 22.5, cache_read: 0.5 },
context_over_200k: {
input: Money.USDPerMillionTokens.make(5),
output: Money.USDPerMillionTokens.make(22.5),
cache_read: Money.USDPerMillionTokens.make(0.5),
},
},
limit: { context: 1_050_000, input: 922_000, output: 128_000 },
experimental: {
modes: {
fast: {
cost: { input: 5, output: 30, cache_read: 0.5 },
cost: {
input: Money.USDPerMillionTokens.make(5),
output: Money.USDPerMillionTokens.make(30),
cache_read: Money.USDPerMillionTokens.make(0.5),
},
provider: {
headers: { "x-mode": "fast" },
body: { service_tier: "priority" },
@ -107,18 +116,31 @@ describe("ModelsDevPlugin", () => {
variants: [],
})
expect(fast?.cost).toEqual([
{ input: 5, output: 30, cache: { read: 0.5, write: 0 } },
{
input: Money.USDPerMillionTokens.make(5),
output: Money.USDPerMillionTokens.make(30),
cache: {
read: Money.USDPerMillionTokens.make(0.5),
write: Money.USDPerMillionTokens.zero,
},
},
{
tier: { type: "context", size: 272_000 },
input: 3,
output: 18,
cache: { read: 0.25, write: 0 },
input: Money.USDPerMillionTokens.make(3),
output: Money.USDPerMillionTokens.make(18),
cache: {
read: Money.USDPerMillionTokens.make(0.25),
write: Money.USDPerMillionTokens.zero,
},
},
{
tier: { type: "context", size: 200_000 },
input: 5,
output: 22.5,
cache: { read: 0.5, write: 0 },
input: Money.USDPerMillionTokens.make(5),
output: Money.USDPerMillionTokens.make(22.5),
cache: {
read: Money.USDPerMillionTokens.make(0.5),
write: Money.USDPerMillionTokens.zero,
},
},
])
}),

View file

@ -1,4 +1,5 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { Money } from "@opencode-ai/schema/money"
import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect"
@ -185,7 +186,16 @@ describe("OpenAIPlugin", () => {
draft.package = item.package
})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5"), (model) => {
model.cost = [{ input: 1, output: 2, cache: { read: 0.1, write: 0 } }]
model.cost = [
{
input: Money.USDPerMillionTokens.make(1),
output: Money.USDPerMillionTokens.make(2),
cache: {
read: Money.USDPerMillionTokens.make(0.1),
write: Money.USDPerMillionTokens.zero,
},
},
]
})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5-pro"), () => {})
catalog.model.update(item.id, ModelV2.ID.make("gpt-4.1"), () => {})

View file

@ -1,4 +1,5 @@
import { describe, expect } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
@ -65,7 +66,16 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
)
}
const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }]
const cost = (input: number, output = 0) => [
{
input: Money.USDPerMillionTokens.make(input),
output: Money.USDPerMillionTokens.make(output),
cache: {
read: Money.USDPerMillionTokens.zero,
write: Money.USDPerMillionTokens.zero,
},
},
]
describe("OpencodePlugin", () => {
it.effect("registers account and service account methods", () =>

View file

@ -37,17 +37,19 @@ describe("SkillPlugin.Plugin", () => {
Effect.provide(NodeFileSystem.layer),
)
const skills = yield* skill.list()
const report = skills.find((item) => item.name === "report")
const report = skills.find((item) => item.id === "report")
expect(skills).toContainEqual(
expect.objectContaining({
name: "opencode",
id: "opencode",
name: "OpenCode",
description: expect.stringContaining("any question about OpenCode itself"),
}),
)
expect(skills).toContainEqual(
expect.objectContaining({
name: "report",
id: "report",
name: "Report",
description: expect.stringContaining("opencode issue"),
}),
)

View file

@ -15,6 +15,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionInput } from "@opencode-ai/core/session/input"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Prompt } from "@opencode-ai/schema/prompt"
import { SessionProjector } from "@opencode-ai/core/session/projector"
@ -104,13 +105,10 @@ describe("SessionV2.compact", () => {
expect(second.id).toBe(first.id)
expect(requests).toHaveLength(0)
expect((yield* session.context(created.id)).find((message) => message.id === first.id)).toMatchObject({
type: "compaction",
status: "queued",
reason: "manual",
summary: "",
recent: "",
expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, created.id)).toMatchObject({
id: first.id,
})
expect((yield* session.context(created.id)).find((message) => message.id === first.id)).toBeUndefined()
}),
)
})

View file

@ -141,7 +141,13 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
.subscribe(SessionEvent.Compaction.Delta)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
expect(yield* compaction.compactManual({ session, messages: [userMessage] })).toBe(true)
expect(
yield* compaction.compactManual({
session,
messages: [userMessage],
inputID: SessionMessage.ID.make("msg_manual_compaction"),
}),
).toBe(true)
expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual(["manual summary"])
expect(requests).toHaveLength(1)

View file

@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import path from "path"
import { DateTime, Effect, Layer, Stream } from "effect"
import { Money } from "@opencode-ai/schema/money"
import { AgentV2 } from "@opencode-ai/core/agent"
import { asc, eq } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
@ -210,7 +211,7 @@ describe("SessionV2.create", () => {
expect(forked.parentID).toBeUndefined()
expect(forkContext).toMatchObject([
{ type: "user", text: "First" },
{ type: "synthetic", text: "parent note", sessionID: forked.id },
{ type: "synthetic", text: "parent note" },
])
expect(forkContext.map((message) => message.id)).not.toEqual(parentContext.map((message) => message.id))
expect(history).toHaveLength(1)
@ -273,14 +274,14 @@ describe("SessionV2.create", () => {
yield* events.publish(SessionEvent.Step.Started, {
sessionID: parent.id,
assistantMessageID,
agent: "build",
agent: AgentV2.ID.make("build"),
model,
})
yield* events.publish(SessionEvent.Step.Ended, {
sessionID: parent.id,
assistantMessageID,
finish: "stop",
cost: 0.75,
cost: Money.USD.make(0.75),
tokens: { input: 6, output: 3, reasoning: 1, cache: { read: 2, write: 1 } },
})
@ -533,7 +534,7 @@ describe("SessionV2.create", () => {
const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell")
expect(shell).toMatchObject({ type: "shell", shell: { command: "echo hello", status: "exited", exit: 0 } })
expect(shell).toMatchObject({ type: "shell", command: "echo hello", status: "exited", exit: 0 })
expect(shell?.output?.output).toContain("hello")
expect(shell?.output?.truncated).toBe(false)
expect(shell?.time.completed).toBeDefined()
@ -553,8 +554,8 @@ describe("SessionV2.create", () => {
const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell")
expect(shell).toMatchObject({ type: "shell", shell: { command: "false", status: "exited" } })
expect(shell?.shell.exit).not.toBe(0)
expect(shell).toMatchObject({ type: "shell", command: "false", status: "exited" })
expect(shell?.exit).not.toBe(0)
expect(shell?.time.completed).toBeDefined()
}),
),
@ -565,7 +566,7 @@ describe("SessionV2.create", () => {
const session = yield* SessionV2.Service
const created = yield* session.create({ location })
yield* session.switchAgent({ sessionID: created.id, agent: "plan" })
yield* session.switchAgent({ sessionID: created.id, agent: AgentV2.ID.make("plan") })
expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" })
expect(
@ -580,7 +581,7 @@ describe("SessionV2.create", () => {
const missing = SessionV2.ID.make("ses_missing_agent_switch")
expect(
yield* session.switchAgent({ sessionID: missing, agent: "plan" }).pipe(
yield* session.switchAgent({ sessionID: missing, agent: AgentV2.ID.make("plan") }).pipe(
Effect.flip,
Effect.map((error) => error._tag),
),

View file

@ -303,7 +303,6 @@ describe("SessionInstructions", () => {
const synthetic = SessionMessage.Synthetic.make({
id: SessionMessage.ID.make("msg_synthetic"),
type: "synthetic",
sessionID: SessionV2.ID.make("ses_test"),
text: "Instructions from: /repo/sub/AGENTS.md\ncontent",
description: "Loaded /repo/sub/AGENTS.md",
metadata: { instruction: { paths: ["/repo/sub/AGENTS.md"] } },

View file

@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer, Schema, Stream } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { AgentV2 } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
@ -87,11 +88,11 @@ describe("SessionV2.log", () => {
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const created = yield* session.create({ location })
yield* session.switchAgent({ sessionID: created.id, agent: "one" })
yield* session.switchAgent({ sessionID: created.id, agent: AgentV2.ID.make("one") })
// Not in the durable manifest, so reads must skip it without failing.
yield* events.publish(GapEvent, { sessionID: created.id, value: "filtered" })
yield* session.switchAgent({ sessionID: created.id, agent: "two" })
yield* session.switchAgent({ sessionID: created.id, agent: "three" })
yield* session.switchAgent({ sessionID: created.id, agent: AgentV2.ID.make("two") })
yield* session.switchAgent({ sessionID: created.id, agent: AgentV2.ID.make("three") })
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id, after: 1 })))

View file

@ -1,7 +1,8 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Fiber, Option, Schema, Stream } from "effect"
import { asc, eq } from "drizzle-orm"
import { asc, eq, sql } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
import { AgentV2 } from "@opencode-ai/core/agent"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { EventV2 } from "@opencode-ai/core/event"
@ -15,9 +16,11 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Prompt } from "@opencode-ai/schema/prompt"
import { Money } from "@opencode-ai/schema/money"
import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { fromRow } from "@opencode-ai/core/session/info"
import { SessionInput } from "@opencode-ai/core/session/input"
import { Shell } from "@opencode-ai/schema/shell"
import {
@ -35,7 +38,8 @@ const sessionID = SessionV2.ID.make("ses_projector_test")
const created = DateTime.makeUnsafe(0)
const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }
const previousModel = { ...model, variant: ModelV2.VariantID.make("medium") }
const encodeMessage = Schema.encodeSync(SessionMessage.Message)
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
const build = AgentV2.defaultID
const assistantRow = (
id: SessionMessage.ID,
@ -48,12 +52,108 @@ const assistantRow = (
type,
...data
} = encodeMessage(
SessionMessage.Assistant.make({ id, type: "assistant", agent: "build", model, content: [], time, ...usage }),
SessionMessage.Assistant.make({ id, type: "assistant", agent: build, model, content: [], time, ...usage }),
)
return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(time.created), data }
}
describe("SessionProjector", () => {
it.effect("does not settle a pending manual compaction on an auto failure", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
const events = yield* EventV2.Service
const inputID = SessionMessage.ID.make("msg_manual_compaction")
yield* SessionInput.admitCompaction(db, events, { id: inputID, sessionID })
yield* events.publish(SessionEvent.Compaction.Failed, {
sessionID,
reason: "auto",
error: { type: "compaction.failed", message: "Auto compaction failed" },
})
expect(yield* SessionInput.pendingCompaction(db, sessionID)).toMatchObject({ id: inputID })
}),
)
it.effect("loads legacy revert storage into canonical state", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
const legacy = JSON.stringify({
messageID: "msg_boundary",
snapshot: "tree",
diff: "legacy patch",
files: [{ path: "src/old.ts", status: "modified", additions: 1, deletions: 0, patch: "@@" }],
})
yield* db.run(sql`update session set revert = ${legacy} where id = ${sessionID}`)
const stored = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()
if (!stored) return yield* Effect.die("Session row missing")
const storedRevert = fromRow(stored).revert
expect(String(storedRevert?.messageID)).toBe("msg_boundary")
expect(String(storedRevert?.snapshot)).toBe("tree")
expect(storedRevert?.files).toEqual([
{ file: "src/old.ts", status: "modified", additions: 1, deletions: 0, patch: "@@" },
])
}),
)
it.effect("folds live compaction deltas into running memory state", () =>
Effect.gen(function* () {
const state = {
messages: [
SessionMessage.CompactionRunning.make({
id: SessionMessage.ID.make("msg_compaction"),
type: "compaction",
status: "running",
reason: "manual",
summary: "partial ",
recent: "recent",
time: { created },
}),
],
}
yield* SessionMessageUpdater.update(
SessionMessageUpdater.memory(state),
SessionEvent.Compaction.Delta.make({
id: EventV2.ID.make("evt_delta"),
type: "session.compaction.delta",
created,
data: { sessionID, text: "summary" },
}),
)
expect(state.messages[0]).toMatchObject({ status: "running", summary: "partial summary", recent: "recent" })
}),
)
it.effect("projects staged, cleared, and committed reverts", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
@ -89,7 +189,7 @@ describe("SessionProjector", () => {
1,
{ created },
{
cost: 0.5,
cost: Money.USD.make(0.5),
tokens: { input: 4, output: 1, reasoning: 1, cache: { read: 1, write: 0 } },
},
),
@ -98,7 +198,7 @@ describe("SessionProjector", () => {
2,
{ created },
{
cost: 0.75,
cost: Money.USD.make(0.75),
tokens: { input: 6, output: 3, reasoning: 1, cache: { read: 2, write: 1 } },
},
),
@ -111,7 +211,7 @@ describe("SessionProjector", () => {
const events = yield* EventV2.Service
yield* events.publish(SessionEvent.RevertEvent.Staged, {
sessionID,
revert: { messageID: boundary, snapshot: Snapshot.ID.make("tree"), diff: "patch", files: [] },
revert: { messageID: boundary, snapshot: Snapshot.ID.make("tree"), files: [] },
})
expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toMatchObject({
messageID: boundary,
@ -132,7 +232,7 @@ describe("SessionProjector", () => {
(yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id),
).toEqual([earlier])
expect(yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()).toMatchObject({
cost: 1.25,
cost: Money.USD.make(1.25),
tokens_input: 10,
tokens_output: 4,
tokens_reasoning: 2,
@ -285,7 +385,7 @@ describe("SessionProjector", () => {
yield* events.publish(SessionEvent.AgentSelected, {
sessionID,
agent: "build",
agent: build,
})
yield* events.publish(SessionEvent.ModelSelected, {
sessionID,
@ -327,6 +427,7 @@ describe("SessionProjector", () => {
yield* events.publish(SessionEvent.Compaction.Started, {
sessionID,
reason: "manual",
recent: "recent context",
})
yield* events.publish(SessionEvent.Compaction.Delta, {
sessionID,
@ -336,18 +437,18 @@ describe("SessionProjector", () => {
yield* db
.select({ id: EventTable.id })
.from(EventTable)
.where(eq(EventTable.type, SessionEvent.Compaction.Delta.type))
.where(sql`${EventTable.type} like 'session.compaction.delta.%'`)
.all()
.pipe(Effect.orDie),
).toEqual([])
).toHaveLength(0)
expect(
yield* db
.select({ id: SessionMessageTable.id })
.select({ data: SessionMessageTable.data })
.from(SessionMessageTable)
.where(eq(SessionMessageTable.type, "compaction"))
.all()
.pipe(Effect.orDie),
).toEqual([])
).toEqual([{ data: expect.objectContaining({ status: "running", summary: "", recent: "recent context" }) }])
yield* events.publish(SessionEvent.Compaction.Ended, {
sessionID,
reason: "manual",
@ -363,7 +464,7 @@ describe("SessionProjector", () => {
.all()
.pipe(Effect.orDie)
const messages = rows.map((row) =>
Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }),
Schema.decodeUnknownSync(SessionMessage.Info)({ ...row.data, id: row.id, type: row.type }),
)
expect(messages.map((message) => message.type)).toEqual([
@ -379,7 +480,9 @@ describe("SessionProjector", () => {
})
expect(messages.find((message) => message.type === "model-switched")).toMatchObject({ previous: previousModel })
expect(messages.find((message) => message.type === "shell")).toMatchObject({
shell: { command: "pwd", status: "exited", exit: 0 },
command: "pwd",
status: "exited",
exit: 0,
output: { output: "/project", truncated: false },
time: { completed: DateTime.makeUnsafe(0) },
})
@ -419,11 +522,7 @@ describe("SessionProjector", () => {
.pipe(Effect.orDie)
const events = yield* EventV2.Service
const id = SessionMessage.ID.make("msg_creator_collision")
const {
id: _,
type,
...data
} = encodeMessage({ id, sessionID, type: "synthetic", text: "existing", time: { created } })
const { id: _, type, ...data } = encodeMessage({ id, type: "synthetic", text: "existing", time: { created } })
yield* db
.insert(SessionMessageTable)
.values({ id, session_id: sessionID, type, seq: 0, time_created: 0, data })
@ -433,7 +532,7 @@ describe("SessionProjector", () => {
.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID: id,
agent: "build",
agent: build,
model,
})
.pipe(Effect.exit)
@ -450,7 +549,7 @@ describe("SessionProjector", () => {
const stale = SessionMessage.Assistant.make({
id: SessionMessage.ID.make("msg_assistant_stale"),
type: "assistant",
agent: "build",
agent: build,
model,
content: [],
time: { created },
@ -458,7 +557,7 @@ describe("SessionProjector", () => {
const completed = SessionMessage.Assistant.make({
id: SessionMessage.ID.make("msg_assistant_completed"),
type: "assistant",
agent: "build",
agent: build,
model,
content: [],
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
@ -493,7 +592,7 @@ describe("SessionProjector", () => {
const events = yield* EventV2.Service
const first = SessionMessage.ID.make("msg_retry_first")
const second = SessionMessage.ID.make("msg_retry_second")
yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: first, agent: "build", model })
yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: first, agent: build, model })
yield* events.publish(SessionEvent.RetryScheduled, {
sessionID,
assistantMessageID: first,
@ -503,7 +602,7 @@ describe("SessionProjector", () => {
})
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type })
Schema.decodeUnknownSync(SessionMessage.Info)({ ...row.data, id: row.id, type: row.type })
const firstRow = yield* db
.select()
.from(SessionMessageTable)
@ -515,7 +614,7 @@ describe("SessionProjector", () => {
retry: { attempt: 2, at: DateTime.makeUnsafe(2_000), error: { type: "provider.transport" } },
})
yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: second, agent: "build", model })
yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: second, agent: build, model })
yield* events.publish(SessionEvent.RetryScheduled, {
sessionID,
assistantMessageID: second,
@ -574,7 +673,7 @@ describe("SessionProjector", () => {
sessionID,
assistantMessageID: SessionMessage.ID.make("msg_assistant_2"),
finish: "stop",
cost: 1.25,
cost: Money.USD.make(1.25),
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
})
@ -586,13 +685,13 @@ describe("SessionProjector", () => {
.all()
.pipe(Effect.orDie)
const messages = rows.map((row) =>
Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }),
Schema.decodeUnknownSync(SessionMessage.Info)({ ...row.data, id: row.id, type: row.type }),
)
expect(messages[0]).not.toHaveProperty("time.completed")
expect(messages[1]).toMatchObject({
type: "assistant",
finish: "stop",
cost: 1.25,
cost: Money.USD.make(1.25),
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
time: { completed: DateTime.makeUnsafe(0) },
})
@ -608,7 +707,7 @@ describe("SessionProjector", () => {
})
expect(Option.getOrThrow(yield* Fiber.join(usageUpdated)).data).toEqual({
sessionID,
cost: 1.25,
cost: Money.USD.make(1.25),
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
})
}),
@ -661,13 +760,13 @@ describe("SessionProjector", () => {
.all()
.pipe(Effect.orDie)
const messages = rows.map((row) =>
Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }),
Schema.decodeUnknownSync(SessionMessage.Info)({ ...row.data, id: row.id, type: row.type }),
)
expect(messages).toEqual([
SessionMessage.Assistant.make({
id: SessionMessage.ID.make("msg_assistant_completed"),
type: "assistant",
agent: "build",
agent: build,
model,
content: [SessionMessage.AssistantText.make({ type: "text", text: "" })],
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
@ -675,7 +774,7 @@ describe("SessionProjector", () => {
SessionMessage.Assistant.make({
id: SessionMessage.ID.make("msg_assistant_stale"),
type: "assistant",
agent: "build",
agent: build,
model,
content: [],
time: { created },

View file

@ -6,6 +6,7 @@ import path from "path"
import { pathToFileURL } from "url"
import { eq } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
import { AgentV2 } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
@ -105,7 +106,7 @@ const eventCount = (type: string) =>
),
)
const encodeMessage = Schema.encodeSync(SessionMessage.Message)
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
const assistantRow = (id: SessionMessage.ID, seq: number) => {
const {
id: _,
@ -115,7 +116,7 @@ const assistantRow = (id: SessionMessage.ID, seq: number) => {
SessionMessage.Assistant.make({
id,
type: "assistant",
agent: "build",
agent: AgentV2.ID.make("build"),
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
content: [],
time: { created: DateTime.makeUnsafe(0) },
@ -677,7 +678,6 @@ describe("SessionV2.prompt", () => {
...data
} = encodeMessage({
id: messageID,
sessionID,
type: "synthetic",
text: "Existing history",
time: { created: DateTime.makeUnsafe(0) },

View file

@ -5,13 +5,14 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { AgentAttachment, Base64, FileAttachment } from "@opencode-ai/schema/prompt"
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
import { SessionV2 } from "@opencode-ai/core/session"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Shell } from "@opencode-ai/schema/shell"
import { DateTime } from "effect"
const created = DateTime.makeUnsafe(0)
const id = (value: string) => SessionMessage.ID.make(`msg_${value}`)
const model = ModelV2.Ref.make({ id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") })
const build = AgentV2.defaultID
describe("toLLMMessages", () => {
test("omits empty assistant turns", () => {
@ -19,7 +20,7 @@ describe("toLLMMessages", () => {
SessionMessage.Assistant.make({
id: id(value),
type: "assistant",
agent: "build",
agent: build,
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
content,
time: { created, completed: created },
@ -56,7 +57,7 @@ describe("toLLMMessages", () => {
SessionMessage.AgentSelected.make({
id: id("agent"),
type: "agent-switched",
agent: "build",
agent: build,
time: { created },
}),
SessionMessage.ModelSelected.make({
@ -82,24 +83,16 @@ describe("toLLMMessages", () => {
SessionMessage.Synthetic.make({
id: id("synthetic"),
type: "synthetic",
sessionID: SessionV2.ID.make("ses_translate"),
text: "Synthetic context",
time: { created },
}),
SessionMessage.Shell.make({
id: id("shell"),
type: "shell",
shell: Shell.Info.make({
id: Shell.ID.make("sh_test"),
status: "exited",
command: "pwd",
cwd: "/project",
shell: "/bin/sh",
file: "/tmp/sh_test.out",
exit: 0,
metadata: {},
time: { started: 0, completed: 0 },
}),
shellID: Shell.ID.make("sh_test"),
status: "exited",
command: "pwd",
exit: 0,
output: { output: "/project", cursor: 8, size: 8, truncated: false },
time: { created, completed: created },
}),
@ -282,7 +275,7 @@ Recent work
SessionMessage.Assistant.make({
id: id("assistant"),
type: "assistant",
agent: "build",
agent: build,
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
content: [
SessionMessage.AssistantText.make({ type: "text", text: "Checking" }),
@ -295,7 +288,7 @@ Recent work
type: "tool",
id: "pending",
name: "read",
state: SessionMessage.ToolStatePending.make({ status: "pending", input: '{"path":"README.md"}' }),
state: SessionMessage.ToolStateStreaming.make({ status: "streaming", input: '{"path":"README.md"}' }),
time: { created },
}),
SessionMessage.AssistantTool.make({
@ -437,7 +430,7 @@ Recent work
SessionMessage.Assistant.make({
id: id("assistant-openai-reasoning"),
type: "assistant",
agent: "build",
agent: build,
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
content: [
SessionMessage.AssistantReasoning.make({
@ -467,7 +460,7 @@ Recent work
SessionMessage.Assistant.make({
id: id("assistant-failed"),
type: "assistant",
agent: "build",
agent: build,
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
content: [
SessionMessage.AssistantReasoning.make({
@ -536,7 +529,7 @@ Recent work
SessionMessage.Assistant.make({
id: id("assistant-old-model"),
type: "assistant",
agent: "build",
agent: build,
model: { id: ModelV2.ID.make("old-model"), providerID: ProviderV2.ID.make("provider") },
content: [
SessionMessage.AssistantReasoning.make({
@ -631,7 +624,7 @@ Recent work
SessionMessage.Assistant.make({
id: id("assistant-alias"),
type: "assistant",
agent: "build",
agent: build,
model: { id: ModelV2.ID.make("fast"), providerID: ProviderV2.ID.make("provider") },
content: [
SessionMessage.AssistantReasoning.make({

View file

@ -2,6 +2,7 @@ import { describe, expect } from "bun:test"
import { LLM, Model } from "@opencode-ai/llm"
import { LLMClient } from "@opencode-ai/llm/route"
import { DateTime, Effect } from "effect"
import { Money } from "@opencode-ai/schema/money"
import { Headers } from "effect/unstable/http"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
@ -131,7 +132,7 @@ describe("SessionRunnerModel", () => {
providerID: catalog.providerID,
variant: ModelV2.VariantID.make("high"),
},
cost: 0,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: AbsolutePath.make("/project") },
@ -170,7 +171,7 @@ describe("SessionRunnerModel", () => {
projectID: ProjectV2.ID.global,
title: "test",
model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") },
cost: 0,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: AbsolutePath.make("/project") },
@ -200,7 +201,7 @@ describe("SessionRunnerModel", () => {
providerID: catalog.providerID,
variant: ModelV2.VariantID.make("unknown"),
},
cost: 0,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: AbsolutePath.make("/project") },
@ -236,7 +237,7 @@ describe("SessionRunnerModel", () => {
projectID: ProjectV2.ID.global,
title: "test",
model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") },
cost: 0,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: AbsolutePath.make("/project") },

View file

@ -1,7 +1,9 @@
import { expect, test } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { LLMEvent } from "@opencode-ai/llm"
import { Money } from "@opencode-ai/schema/money"
import { EventV2 } from "@opencode-ai/core/event"
import { AgentV2 } from "@opencode-ai/core/agent"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionV2 } from "@opencode-ai/core/session"
@ -40,7 +42,7 @@ const capture = () => {
published,
publisher: createLLMEventPublisher(events, {
sessionID,
agent: "build",
agent: AgentV2.ID.make("build"),
model: {
id: ModelV2.ID.make("model"),
providerID: ProviderV2.ID.make("provider"),
@ -193,7 +195,7 @@ test("content-filter finish retains failure evidence until step closeout", async
if (!settlement) throw new Error("Expected content-filter settlement")
await Effect.runPromise(
publisher.publishStepFailure({
cost: 1.25,
cost: Money.USD.make(1.25),
tokens: settlement.tokens,
}),
)

View file

@ -30,6 +30,7 @@ import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionInput } from "@opencode-ai/core/session/input"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { Money } from "@opencode-ai/schema/money"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
@ -135,8 +136,23 @@ test("calculates step cost using the matching context tier", () => {
expect(
SessionRunnerLLM.calculateCost(
[
{ input: 1, output: 2, cache: { read: 0.1, write: 0.5 } },
{ tier: { type: "context", size: 100 }, input: 3, output: 4, cache: { read: 0.2, write: 0.6 } },
{
input: Money.USDPerMillionTokens.make(1),
output: Money.USDPerMillionTokens.make(2),
cache: {
read: Money.USDPerMillionTokens.make(0.1),
write: Money.USDPerMillionTokens.make(0.5),
},
},
{
tier: { type: "context", size: 100 },
input: Money.USDPerMillionTokens.make(3),
output: Money.USDPerMillionTokens.make(4),
cache: {
read: Money.USDPerMillionTokens.make(0.2),
write: Money.USDPerMillionTokens.make(0.6),
},
},
],
{ input: 80, output: 10, reasoning: 2, cache: { read: 20, write: 1 } },
),
@ -146,10 +162,20 @@ test("calculates step cost using the matching context tier", () => {
test("does not apply an ineligible tier without base pricing", () => {
expect(
SessionRunnerLLM.calculateCost(
[{ tier: { type: "context", size: 100 }, input: 3, output: 4, cache: { read: 0.2, write: 0.6 } }],
[
{
tier: { type: "context", size: 100 },
input: Money.USDPerMillionTokens.make(3),
output: Money.USDPerMillionTokens.make(4),
cache: {
read: Money.USDPerMillionTokens.make(0.2),
write: Money.USDPerMillionTokens.make(0.6),
},
},
],
{ input: 80, output: 10, reasoning: 2, cache: { read: 20, write: 0 } },
),
).toBe(0)
).toBe(Money.USD.zero)
})
const authorizations: Tool.Context[] = []
@ -485,7 +511,7 @@ const recordedStepSettlementEvents = (id: SessionV2.ID, assistantMessageID: Sess
const hostedCall = (id: string, query: string) =>
LLMEvent.toolCall({ id, name: "web_search", input: { query }, providerExecuted: true })
const requireAssistant = (messages: readonly SessionMessage.Message[]) => {
const requireAssistant = (messages: readonly SessionMessage.Info[]) => {
const assistant = messages.find((message) => message.type === "assistant")
if (!assistant) throw new Error("Assistant message missing")
return assistant
@ -581,7 +607,7 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string
LLMEvent.toolInputStart({ id, name: "echo" }),
...chunks.map((text) => LLMEvent.toolInputDelta({ id, name: "echo", text })),
]
const expectedContent = { type: "tool", id, state: { status: "pending", input: text } }
const expectedContent = { type: "tool", id, state: { status: "streaming", input: text } }
return {
delta: SessionEvent.Tool.Input.Delta,
partialEvents,
@ -1056,7 +1082,7 @@ describe("SessionRunnerLLM", () => {
skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills")
yield* events.publish(SessionEvent.AgentSelected, {
sessionID,
agent: "reviewer",
agent: AgentV2.ID.make("reviewer"),
})
yield* admit(session, "Second")
yield* session.resume(sessionID)
@ -1082,7 +1108,7 @@ describe("SessionRunnerLLM", () => {
return events
.publish(SessionEvent.AgentSelected, {
sessionID,
agent: "reviewer",
agent: AgentV2.ID.make("reviewer"),
})
.pipe(Effect.asVoid)
})
@ -1265,6 +1291,7 @@ describe("SessionRunnerLLM", () => {
yield* events.publish(SessionEvent.Compaction.Started, {
sessionID,
reason: "manual",
recent: "",
})
yield* events.publish(SessionEvent.Compaction.Ended, {
sessionID,
@ -1308,10 +1335,7 @@ describe("SessionRunnerLLM", () => {
expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, sessionID)).toMatchObject({
id: first.id,
})
expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toMatchObject({
type: "compaction",
status: "queued",
})
expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toBeUndefined()
yield* admit(session, "Steer after compaction")
yield* session.prompt({
@ -1370,6 +1394,57 @@ describe("SessionRunnerLLM", () => {
type: "compaction",
status: "failed",
})
expect(
(yield* recordedEventTypes(sessionID)).filter(
(type) => type === EventV2.versionedType(SessionEvent.Compaction.Failed.type, 1),
),
).toHaveLength(1)
}),
)
it.effect("settles an admitted manual compaction that cannot start", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const compaction = yield* session.compact({ sessionID })
yield* session.resume(sessionID)
expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, sessionID)).toBeUndefined()
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "failed",
reason: "manual",
error: { message: "Compaction could not start" },
})
expect(
(yield* recordedEventTypes(sessionID)).filter(
(type) => type === EventV2.versionedType(SessionEvent.Compaction.Failed.type, 1),
),
).toHaveLength(1)
}),
)
it.effect("settles an admitted manual compaction when pre-start resolution throws", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const compaction = yield* session.compact({ sessionID })
modelResolveHook = Effect.die("model resolution failed")
expect(yield* Effect.exit(session.resume(sessionID))).toMatchObject({ _tag: "Failure" })
expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, sessionID)).toBeUndefined()
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "failed",
reason: "manual",
})
expect(
(yield* recordedEventTypes(sessionID)).filter(
(type) => type === EventV2.versionedType(SessionEvent.Compaction.Failed.type, 1),
),
).toHaveLength(1)
}),
)
@ -1511,9 +1586,10 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(2)
const context = yield* session.context(sessionID)
expect(context.some((message) => message.type === "compaction")).toBe(false)
expect(context.slice(-2)).toMatchObject([
expect(context).toContainEqual(expect.objectContaining({ type: "compaction", status: "failed", reason: "auto" }))
expect(context.slice(-3)).toMatchObject([
{ type: "user", text: "Continue" },
{ type: "compaction", status: "failed", reason: "auto" },
{ type: "assistant", finish: "error", error: { message: "prompt too long" } },
])
}),
@ -1540,7 +1616,9 @@ describe("SessionRunnerLLM", () => {
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
streamGate = undefined
expect(requests).toHaveLength(2)
expect((yield* session.context(sessionID)).some((message) => message.type === "compaction")).toBe(false)
expect(yield* session.context(sessionID)).toContainEqual(
expect.objectContaining({ type: "compaction", status: "failed", reason: "auto" }),
)
}),
)
@ -1557,6 +1635,7 @@ describe("SessionRunnerLLM", () => {
yield* events.publish(SessionEvent.Compaction.Started, {
sessionID,
reason: "manual",
recent: "",
})
yield* events.publish(SessionEvent.Compaction.Ended, {
sessionID,
@ -2299,7 +2378,7 @@ describe("SessionRunnerLLM", () => {
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID,
agent: "build",
agent: AgentV2.ID.make("build"),
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
})
yield* events.publish(SessionEvent.Tool.Input.Started, {
@ -2356,7 +2435,7 @@ describe("SessionRunnerLLM", () => {
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID,
agent: "build",
agent: AgentV2.ID.make("build"),
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
})
yield* events.publish(SessionEvent.Tool.Input.Started, {
@ -2407,7 +2486,7 @@ describe("SessionRunnerLLM", () => {
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID,
agent: "build",
agent: AgentV2.ID.make("build"),
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
})
yield* events.publish(SessionEvent.Tool.Input.Started, {

View file

@ -26,7 +26,8 @@ const skills = Layer.mock(SkillV2.Service, {
list: () =>
Effect.succeed([
SkillV2.Info.make({
name: "effect",
id: SkillV2.ID.make("effect"),
name: SkillV2.Name.make("Effect"),
description: "Effect guidance",
location: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")),
content: "Use Effect",
@ -60,10 +61,10 @@ describe("SessionV2.skill", () => {
const session = yield* sessions.create({ location })
const id = SessionMessage.ID.make("msg_caller_skill")
yield* sessions.skill({ id, sessionID: session.id, skill: "effect", resume: false })
yield* sessions.skill({ id, sessionID: session.id, skill: SkillV2.ID.make("effect"), resume: false })
expect(yield* sessions.messages({ sessionID: session.id })).toContainEqual(
expect.objectContaining({ id, type: "skill", name: "effect", text: "Use Effect" }),
expect.objectContaining({ id, type: "skill", skill: "effect", name: "Effect", text: "Use Effect" }),
)
}),
)

View file

@ -4,6 +4,7 @@ import { DateTime, Effect, Schema } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { AgentV2 } from "@opencode-ai/core/agent"
import { EventTable } from "@opencode-ai/core/event/sql"
import { ModelV2 } from "@opencode-ai/core/model"
import { Project } from "@opencode-ai/core/project"
@ -51,7 +52,7 @@ describe("Tool.Progress", () => {
yield* service.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID,
agent: "build",
agent: AgentV2.ID.make("build"),
model,
})
const readAssistant = Effect.gen(function* () {

View file

@ -76,6 +76,7 @@ test("Core reuses the canonical shared schemas", async () => {
const schemas = [
[AgentV2.ID, Agent.ID],
[AgentV2.Name, Agent.Name],
[AgentV2.Color, Agent.Color],
[AgentV2.Info, Agent.Info],
[coreCommand.Info, Command.Info],
@ -145,7 +146,7 @@ test("Core reuses the canonical shared schemas", async () => {
[coreSessionMessage.Synthetic, SessionMessage.Synthetic],
[coreSessionMessage.System, SessionMessage.System],
[coreSessionMessage.Shell, SessionMessage.Shell],
[coreSessionMessage.ToolStatePending, SessionMessage.ToolStatePending],
[coreSessionMessage.ToolStateStreaming, SessionMessage.ToolStateStreaming],
[coreSessionMessage.ToolStateRunning, SessionMessage.ToolStateRunning],
[coreSessionMessage.ToolStateCompleted, SessionMessage.ToolStateCompleted],
[coreSessionMessage.ToolStateError, SessionMessage.ToolStateError],
@ -156,7 +157,7 @@ test("Core reuses the canonical shared schemas", async () => {
[coreSessionMessage.AssistantContent, SessionMessage.AssistantContent],
[coreSessionMessage.Assistant, SessionMessage.Assistant],
[coreSessionMessage.Compaction, SessionMessage.Compaction],
[coreSessionMessage.Message, SessionMessage.Message],
[coreSessionMessage.Info, SessionMessage.Info],
[coreSessionTodo.Info, SessionTodo.Info],
[coreSessionTodo.Event, SessionTodo.Event],
[coreSkill.DirectorySource, Skill.DirectorySource],

View file

@ -88,13 +88,15 @@ describe("SkillV2", () => {
])
expect(yield* skill.list()).toEqual([
SkillV2.Info.make({
name: "foo",
id: SkillV2.ID.make("foo"),
name: SkillV2.Name.make("foo"),
slash: true,
location: AbsolutePath.make(path.join(first, "foo.md")),
content: "# foo",
}),
{
name: "review",
id: SkillV2.ID.make("review"),
name: SkillV2.Name.make("review"),
description: "Second",
location: AbsolutePath.make(path.join(second, "review", "SKILL.md")),
content: "# review",
@ -129,8 +131,8 @@ describe("SkillV2", () => {
const skill = yield* SkillV2.Service
yield* skill.transform((editor) => editor.source({ type: "url", url: "https://example.test/skills/" }))
expect((yield* skill.list()).map((item) => item.name)).toEqual(["deploy"])
expect((yield* skill.list()).map((item) => item.name)).toEqual(["deploy"])
expect((yield* skill.list()).map((item) => item.name)).toEqual([SkillV2.Name.make("deploy")])
expect((yield* skill.list()).map((item) => item.name)).toEqual([SkillV2.Name.make("deploy")])
expect(pulls).toBe(1)
expect(SkillV2.available(yield* skill.list(), (yield* agents.get(AgentV2.ID.make("reviewer")))!)).toEqual([])
}),
@ -165,7 +167,8 @@ metadata:
expect(yield* skill.list()).toEqual([
{
name: "manual",
id: SkillV2.ID.make("manual"),
name: SkillV2.Name.make("manual"),
description: "Manual only",
slash: true,
autoinvoke: false,

View file

@ -11,24 +11,28 @@ import { it } from "../lib/effect"
const build = AgentV2.ID.make("build")
const effect = SkillV2.Info.make({
name: "effect",
id: SkillV2.ID.make("effect"),
name: SkillV2.Name.make("Effect"),
description: "Build applications with Effect",
location: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")),
content: "Effect guidance",
})
const hidden = SkillV2.Info.make({
name: "hidden",
id: SkillV2.ID.make("hidden"),
name: SkillV2.Name.make("Hidden"),
location: AbsolutePath.make(path.resolve("/skills/hidden/SKILL.md")),
content: "Undescribed guidance",
})
const denied = SkillV2.Info.make({
name: "denied",
id: SkillV2.ID.make("denied"),
name: SkillV2.Name.make("Denied"),
description: "Must not be advertised",
location: AbsolutePath.make(path.resolve("/skills/denied/SKILL.md")),
content: "Denied guidance",
})
const manual = SkillV2.Info.make({
name: "manual",
id: SkillV2.ID.make("manual"),
name: SkillV2.Name.make("Manual"),
description: "Load only when explicitly selected",
autoinvoke: false,
location: AbsolutePath.make(path.resolve("/skills/manual/SKILL.md")),
@ -59,7 +63,8 @@ describe("SkillGuidance", () => {
"Use the skill tool to load a skill when a task matches its description.",
"<available_skills>",
" <skill>",
" <name>effect</name>",
" <id>effect</id>",
" <name>Effect</name>",
" <description>Build applications with Effect</description>",
" </skill>",
"</available_skills>",
@ -74,7 +79,7 @@ describe("SkillGuidance", () => {
.pipe(Effect.flatMap((context) => Instructions.reconcile(context, initialized.applied))),
).toMatchObject({
_tag: "Updated",
text: "The following skills are no longer available and must not be used: effect.",
text: "The following skill IDs are no longer available and must not be used: effect.",
})
}).pipe(Effect.provide(layer(() => skills)))
})
@ -82,7 +87,8 @@ describe("SkillGuidance", () => {
it.effect("announces added and removed skills as deltas without restating the list", () => {
const agent = AgentV2.Info.make(AgentV2.Info.empty(build))
const debugging = SkillV2.Info.make({
name: "debugging",
id: SkillV2.ID.make("debugging"),
name: SkillV2.Name.make("Debugging"),
description: "Diagnose hard bugs",
location: AbsolutePath.make(path.resolve("/skills/debugging/SKILL.md")),
content: "Debugging guidance",
@ -103,7 +109,8 @@ describe("SkillGuidance", () => {
text: [
"New skills are available in addition to those previously listed:",
" <skill>",
" <name>debugging</name>",
" <id>debugging</id>",
" <name>Debugging</name>",
" <description>Diagnose hard bugs</description>",
" </skill>",
].join("\n"),
@ -117,7 +124,7 @@ describe("SkillGuidance", () => {
)
expect(removed).toMatchObject({
_tag: "Updated",
text: "The following skills are no longer available and must not be used: effect.",
text: "The following skill IDs are no longer available and must not be used: effect.",
})
}).pipe(Effect.provide(layer(() => skills)))
})
@ -192,7 +199,7 @@ describe("SkillGuidance", () => {
const guidance = yield* SkillGuidance.Service
expect(
(yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(Instructions.initialize))).text,
).toContain("<name>effect</name>")
).toContain("<name>Effect</name>")
}).pipe(Effect.provide(layer(() => [effect])))
})

View file

@ -56,7 +56,7 @@ describe("Snapshot", () => {
const plan = new Map([[RelativePath.make("scope/tracked.txt"), before]])
const preview = yield* snapshot.preview({ files: plan, context: 1 })
expect(preview).toHaveLength(1)
expect(preview[0]?.path).toBe(RelativePath.make("scope/tracked.txt"))
expect(preview[0]?.file).toBe(RelativePath.make("scope/tracked.txt"))
yield* snapshot.restore({ files: plan })
expect(yield* read(path.join(location, "tracked.txt"))).toBe("one\n")
expect(yield* read(path.join(location, "added.txt"))).toBe("added\n")

View file

@ -3,6 +3,7 @@ import { realpathSync } from "node:fs"
import path from "path"
import { describe, expect, test } from "bun:test"
import { DateTime, Duration, Effect, Fiber, Layer, Scope } from "effect"
import { Money } from "@opencode-ai/schema/money"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
@ -104,7 +105,7 @@ const executionNode = makeGlobalNode({
sessionID: id,
assistantMessageID,
finish: "stop",
cost: 0,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
})
@ -442,10 +443,7 @@ describe("ShellTool", () => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const settled = yield* settleTool(
registry,
call({ command: idleCommand, timeout: 50, background: true }),
)
const settled = yield* settleTool(registry, call({ command: idleCommand, timeout: 50, background: true }))
const structured = settled.output?.structured as Record<string, unknown> | undefined
const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined
expect(settled.output?.structured).toMatchObject({ truncated: false })

View file

@ -26,7 +26,7 @@ const skillToolNode = makeLocationNode({
const sessionID = SessionV2.ID.make("ses_skill_tool_test")
describe("SkillTool", () => {
it.live("lists available skills, authorizes the selected name, and loads model-facing content", () =>
it.live("lists available skills, authorizes the selected ID, and loads model-facing content", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
@ -42,7 +42,8 @@ describe("SkillTool", () => {
)
const info: SkillV2.Info = {
name: "effect",
id: SkillV2.ID.make("effect"),
name: SkillV2.Name.make("Effect"),
description: "Use Effect",
location: AbsolutePath.make(location),
content: "# Effect\n\nGuidance",
@ -102,7 +103,7 @@ describe("SkillTool", () => {
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-skill", name: "skill", input: { name: "effect" } },
call: { type: "tool-call", id: "call-skill", name: "skill", input: { id: "effect" } },
}),
).toEqual({
type: "text",
@ -113,11 +114,11 @@ describe("SkillTool", () => {
yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-skill-overflow", name: "skill", input: { name: "effect" } },
call: { type: "tool-call", id: "call-skill-overflow", name: "skill", input: { id: "effect" } },
}),
).toMatchObject({
result: { type: "text", value: SkillTool.toModelOutput(info, [reference]) },
output: { structured: { name: "effect" } },
output: { structured: { name: "Effect" } },
})
expect(assertions).toMatchObject([
{ sessionID, action: "skill", resources: ["effect"], save: ["effect"] },
@ -127,7 +128,7 @@ describe("SkillTool", () => {
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-missing-skill", name: "skill", input: { name: "missing" } },
call: { type: "tool-call", id: "call-missing-skill", name: "skill", input: { id: "missing" } },
}),
).toEqual({ type: "error", value: "Unable to load skill missing" })
deny = true
@ -135,12 +136,13 @@ describe("SkillTool", () => {
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-denied-skill", name: "skill", input: { name: "effect" } },
call: { type: "tool-call", id: "call-denied-skill", name: "skill", input: { id: "effect" } },
}),
).toEqual({ type: "error", value: "Unable to load skill effect" })
deny = false
const flat = SkillV2.Info.make({
name: "public",
id: SkillV2.ID.make("public"),
name: SkillV2.Name.make("Public"),
description: "Public guidance",
location: AbsolutePath.make(path.join(tmp.path, "public.md")),
content: "Public",
@ -156,7 +158,7 @@ describe("SkillTool", () => {
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { name: "public" } },
call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { id: "public" } },
}),
).toEqual({ type: "text", value: SkillTool.toModelOutput(flat, []) })
}).pipe(Effect.provide(skillToolLayer))

View file

@ -1,5 +1,6 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Layer, Schema } from "effect"
import { Money } from "@opencode-ai/schema/money"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
@ -70,7 +71,7 @@ const executionNode = makeGlobalNode({
sessionID,
assistantMessageID,
finish: "stop",
cost: 0,
cost: Money.USD.zero,
tokens,
})
})

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
import { Message, Model, Part, Session, SnapshotFileDiff } from "@opencode-ai/sdk/v2"
import { FileDiffInfo, Message, Model, Part, Session } from "@opencode-ai/sdk/v2"
import { iife } from "@opencode-ai/core/util/iife"
import z from "zod"
import { Storage } from "./storage"
@ -30,7 +30,7 @@ export namespace Share {
}),
z.object({
type: z.literal("session_diff"),
data: z.custom<SnapshotFileDiff[]>(),
data: z.custom<FileDiffInfo[]>(),
}),
z.object({
type: z.literal("model"),

View file

@ -1,4 +1,4 @@
import { Message, Model, Part, Session, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2"
import { FileDiffInfo, Message, Model, Part, Session, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
import { SessionTurn } from "@opencode-ai/session-ui/session-turn"
import { SessionReview } from "@opencode-ai/session-ui/session-review"
import { DataProvider } from "@opencode-ai/session-ui/context"
@ -65,7 +65,7 @@ const getData = query(async (shareID) => {
shareID: string
session: Session[]
session_diff: {
[sessionID: string]: SnapshotFileDiff[]
[sessionID: string]: FileDiffInfo[]
}
session_status: {
[sessionID: string]: SessionStatus

View file

@ -64,7 +64,7 @@ export function fromRow(row: SessionRow): Info {
messageID: MessageID.make(row.revert.messageID),
partID: row.revert.partID ? PartID.make(row.revert.partID) : undefined,
snapshot: row.revert.snapshot,
diff: row.revert.diff,
diff: "diff" in row.revert ? row.revert.diff : undefined,
}
: undefined
return {

View file

@ -51,7 +51,7 @@ type State = {
type Data =
| {
type: "session"
data: SDK.Session
data: SDK.SessionV1Info
}
| {
type: "message"

View file

@ -9,7 +9,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { Hash } from "@opencode-ai/core/util/hash"
import { Config } from "@/config/config"
import { Global } from "@opencode-ai/core/global"
import { Info } from "@opencode-ai/schema/file-diff"
import { LegacyInfo } from "@opencode-ai/schema/file-diff"
export const Patch = Schema.Struct({
hash: Schema.String,
@ -17,7 +17,7 @@ export const Patch = Schema.Struct({
})
export type Patch = typeof Patch.Type
export const FileDiff = Info
export const FileDiff = LegacyInfo
export type FileDiff = typeof FileDiff.Type
const prune = "7.days"

View file

@ -53,7 +53,13 @@ function connected(id = "evt_connected") {
return { id, type: "server.connected", data: {} } satisfies RunV2Event
}
function durable(sessionID: string, seq = 0, version = 1) {
function durable(sessionID: string, seq?: number): { aggregateID: string; seq: number; version: 1 }
function durable<const Version extends 1 | 2>(
sessionID: string,
seq: number,
version: Version,
): { aggregateID: string; seq: number; version: Version }
function durable(sessionID: string, seq = 0, version: 1 | 2 = 1) {
return { aggregateID: sessionID, seq, version }
}
@ -1311,17 +1317,10 @@ describe("V2 mini transport", () => {
{
id: "msg_shell",
type: "shell" as const,
shell: {
id: "sh_1",
status: "exited",
command: "ls",
cwd: "/tmp",
shell: "/bin/sh",
file: "/tmp/opencode-shell",
exit: 0,
metadata: {},
time: { started: 0, completed: 1 },
},
shellID: "sh_1",
status: "exited",
command: "ls",
exit: 0,
output: { output: "file.txt", cursor: 8, size: 8, truncated: false },
time: { created: 1, completed: 2 },
},
@ -1378,17 +1377,10 @@ describe("V2 mini transport", () => {
{
id: "msg_failed_shell",
type: "shell" as const,
shell: {
id: "sh_failed",
status: "exited",
command: "false",
cwd: "/tmp",
shell: "/bin/sh",
file: "/tmp/failed",
exit: 7,
metadata: {},
time: { started: 0, completed: 1 },
},
shellID: "sh_failed",
status: "exited",
command: "false",
exit: 7,
output: { output: "failure output", cursor: 14, size: 14, truncated: false },
time: { created: 1, completed: 2 },
},
@ -1551,6 +1543,7 @@ describe("V2 mini transport", () => {
durable: durable("ses_1"),
data: {
sessionID: "ses_1",
id: input.skill ?? "tigerstyle",
name: input.skill ?? "tigerstyle",
text: "skill instructions",
},
@ -1633,6 +1626,7 @@ describe("V2 mini transport", () => {
durable: durable("ses_1"),
data: {
sessionID: "ses_1",
id: "other",
name: "other",
text: "other instructions",
},
@ -1655,6 +1649,7 @@ describe("V2 mini transport", () => {
durable: durable("ses_1"),
data: {
sessionID: "ses_1",
id: "tigerstyle",
name: "tigerstyle",
text: "skill instructions",
},
@ -1722,6 +1717,7 @@ describe("V2 mini transport", () => {
{
id: "msg_skill",
type: "skill" as const,
skill: "tigerstyle",
name: "tigerstyle",
text: "skill instructions",
time: { created: 2 },
@ -1745,6 +1741,7 @@ describe("V2 mini transport", () => {
durable: durable("ses_1"),
data: {
sessionID: "ses_1",
id: "tigerstyle",
name: "tigerstyle",
text: "skill instructions",
},

View file

@ -28,6 +28,7 @@ import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from ".
import { Database } from "@opencode-ai/core/database/database"
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Agent } from "@opencode-ai/schema/agent"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import * as DateTime from "effect/DateTime"
@ -76,7 +77,7 @@ function createTextMessage(sessionID: SessionIDType, text: string) {
id: MessageID.ascending(),
role: "user",
sessionID,
agent: "build",
agent: Agent.ID.make("build"),
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
time: { created: Date.now() },
})
@ -123,7 +124,7 @@ const insertLegacyAssistantMessage = (sessionID: SessionIDType, seq = 1, time =
const message = SessionMessage.Assistant.make({
id: SessionMessage.ID.create(),
type: "assistant",
agent: "build",
agent: Agent.ID.make("build"),
model: {
id: ModelV2.ID.make("model"),
providerID: ProviderV2.ID.make("provider"),
@ -380,7 +381,7 @@ describe("session HttpApi", () => {
yield* insertLegacyAssistantMessage(parent.id)
expect(
(yield* requestJson<{ data: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, {
(yield* requestJson<{ data: SessionMessage.Info[] }>(`/api/session/${parent.id}/message`, {
headers,
})).data,
).toMatchObject([{ type: "assistant" }])
@ -474,7 +475,7 @@ describe("session HttpApi", () => {
})
const messagePage = yield* request(`/api/session/${session.id}/message?limit=1`, { headers })
const messageBody = yield* json<{ data: SessionMessage.Message[]; cursor: { next?: string } }>(messagePage)
const messageBody = yield* json<{ data: SessionMessage.Info[]; cursor: { next?: string } }>(messagePage)
const messageCursor = messageBody.cursor.next
expect(messageCursor).toBeTruthy()
expect(messageBody.data.map((message) => message.id)).toEqual([secondMessage.id])
@ -488,7 +489,7 @@ describe("session HttpApi", () => {
headers,
})
expect(
(yield* json<{ data: SessionMessage.Message[] }>(nextMessagePage)).data.map((message) => message.id),
(yield* json<{ data: SessionMessage.Info[] }>(nextMessagePage)).data.map((message) => message.id),
).toEqual([firstMessage.id])
const legacyMessageCursor = Buffer.from(
@ -498,7 +499,7 @@ describe("session HttpApi", () => {
headers,
})
expect(
(yield* json<{ data: SessionMessage.Message[] }>(legacyMessagePage)).data.map((message) => message.id),
(yield* json<{ data: SessionMessage.Info[] }>(legacyMessagePage)).data.map((message) => message.id),
).toEqual([firstMessage.id])
const messageCursorWithOrder = yield* request(
@ -625,7 +626,7 @@ describe("session HttpApi", () => {
})
expect(wake.status).toBe(200)
const message = yield* pollWithTimeout(
requestJson<{ data: SessionMessage.Message[] }>(`/api/session/${session.id}/message`, { headers }).pipe(
requestJson<{ data: SessionMessage.Info[] }>(`/api/session/${session.id}/message`, { headers }).pipe(
Effect.map(({ data }) => data.find((message) => message.id === wakeID)),
),
"V2 prompt was not promoted after wake",
@ -637,28 +638,25 @@ describe("session HttpApi", () => {
)
it.instance(
"returns v2 public unavailable errors for unfinished session mutations",
"supports current session compact and wait endpoints",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const headers = { "x-opencode-directory": test.directory }
const session = yield* createSession({ title: "v2 unavailable" })
const compact = yield* request(`/api/session/${session.id}/compact`, { method: "POST", headers })
expect(compact.status).toBe(503)
expect(yield* responseJson(compact)).toEqual({
_tag: "ServiceUnavailableError",
message: "Session compact is not available yet",
service: "session.compact",
const compact = yield* request(`/api/session/${session.id}/compact`, {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({}),
})
expect(compact.status).toBe(200)
expect(yield* responseJson(compact)).toMatchObject({
data: { type: "compaction", sessionID: session.id },
})
const wait = yield* request(`/api/session/${session.id}/wait`, { method: "POST", headers })
expect(wait.status).toBe(503)
expect(yield* responseJson(wait)).toEqual({
_tag: "ServiceUnavailableError",
message: "Session wait is not available yet",
service: "session.wait",
})
expect(wait.status).toBe(204)
}),
{ git: true, config: { formatter: false, lsp: false } },
)

View file

@ -1,6 +1,5 @@
import { expect, test } from "bun:test"
import { Effect } from "effect"
import * as DateTime from "effect/DateTime"
import { DateTime, Effect } from "effect"
import { SessionID } from "../../src/session/schema"
import { EventV2 } from "@opencode-ai/core/event"
import { ModelV2 } from "@opencode-ai/core/model"
@ -8,6 +7,9 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Agent } from "@opencode-ai/schema/agent"
import { Money } from "@opencode-ai/schema/money"
import { Snapshot } from "@opencode-ai/schema/snapshot"
function durable(sessionID: SessionID, seq = 0, version = 1) {
return { aggregateID: sessionID, seq: EventV2.Seq.make(seq), version: EventV2.Version.make(version) }
@ -27,13 +29,13 @@ test.skip("step snapshots carry over to assistant messages", () => {
data: {
sessionID,
assistantMessageID,
agent: "build",
agent: Agent.ID.make("build"),
model: {
id: ModelV2.ID.make("model"),
providerID: ProviderV2.ID.make("provider"),
variant: ModelV2.VariantID.make("default"),
},
snapshot: "before",
snapshot: Snapshot.ID.make("before"),
},
} satisfies SessionEvent.Event),
)
@ -50,21 +52,24 @@ test.skip("step snapshots carry over to assistant messages", () => {
sessionID,
assistantMessageID,
finish: "stop",
cost: 0,
cost: Money.USD.zero,
tokens: {
input: 1,
output: 2,
reasoning: 0,
cache: { read: 0, write: 0 },
},
snapshot: "after",
snapshot: Snapshot.ID.make("after"),
},
} satisfies SessionEvent.Event),
)
expect(state.messages[0]?.type).toBe("assistant")
if (state.messages[0]?.type !== "assistant") return
expect(state.messages[0].snapshot).toEqual({ start: "before", end: "after" })
expect(state.messages[0].snapshot).toEqual({
start: Snapshot.ID.make("before"),
end: Snapshot.ID.make("after"),
})
expect(state.messages[0].finish).toBe("stop")
})
@ -82,7 +87,7 @@ test.skip("text ended populates assistant text content", () => {
data: {
sessionID,
assistantMessageID,
agent: "build",
agent: Agent.ID.make("build"),
model: {
id: ModelV2.ID.make("model"),
providerID: ProviderV2.ID.make("provider"),
@ -141,7 +146,7 @@ test.skip("tool completion stores completed timestamp", () => {
data: {
sessionID,
assistantMessageID,
agent: "build",
agent: Agent.ID.make("build"),
model: {
id: ModelV2.ID.make("model"),
providerID: ProviderV2.ID.make("provider"),
@ -213,7 +218,7 @@ test.skip("tool completion stores completed timestamp", () => {
})
})
test("compaction events reduce to compaction message only when completed", () => {
test("compaction events reduce to a compaction message through completion", () => {
const state: SessionMessageUpdater.MemoryState = { messages: [] }
const sessionID = SessionID.make("session")
const id = EventV2.ID.create()
@ -224,15 +229,25 @@ test("compaction events reduce to compaction message only when completed", () =>
id,
created: DateTime.makeUnsafe(0),
type: "session.compaction.started",
durable: durable(sessionID),
durable: durable(sessionID, 0, 2),
data: {
sessionID,
reason: "auto",
recent: "recent context",
},
} satisfies SessionEvent.Event),
)
expect(state.messages).toEqual([])
expect(state.messages).toMatchObject([
{
id: SessionMessage.ID.fromEvent(id),
type: "compaction",
reason: "auto",
recent: "recent context",
status: "running",
summary: "",
},
])
Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
@ -263,7 +278,7 @@ test("compaction events reduce to compaction message only when completed", () =>
id: endedID,
created: DateTime.makeUnsafe(0),
type: "session.compaction.ended",
durable: durable(sessionID, 1),
durable: durable(sessionID, 3),
data: {
sessionID,
reason: "auto",
@ -275,9 +290,10 @@ test("compaction events reduce to compaction message only when completed", () =>
expect(state.messages).toHaveLength(1)
expect(state.messages[0]).toMatchObject({
id: SessionMessage.ID.fromEvent(endedID),
id: SessionMessage.ID.fromEvent(id),
type: "compaction",
reason: "auto",
status: "completed",
summary: "final summary",
recent: "recent context",
time: { created: DateTime.makeUnsafe(0) },

View file

@ -1,13 +1,13 @@
import type { AgentApi } from "@opencode-ai/client/effect/api"
import type { AgentV2Info } from "@opencode-ai/sdk/v2/types"
import type { AgentInfo } from "@opencode-ai/sdk/v2/types"
import type { Effect } from "effect"
import type { Transform } from "./registration.js"
export interface AgentDraft {
list(): readonly AgentV2Info[]
get(id: string): AgentV2Info | undefined
list(): readonly AgentInfo[]
get(id: string): AgentInfo | undefined
default(id: string | undefined): void
update(id: string, update: (agent: AgentV2Info) => void): void
update(id: string, update: (agent: AgentInfo) => void): void
remove(id: string): void
}

View file

@ -1,11 +1,11 @@
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types"
import type { ModelInfo, ProviderV2Info } from "@opencode-ai/sdk/v2/types"
import type { CatalogApi } from "@opencode-ai/client/effect/api"
import type { Effect } from "effect"
import type { Transform } from "./registration.js"
export interface CatalogProviderRecord {
readonly provider: ProviderV2Info
readonly models: ReadonlyMap<string, ModelV2Info>
readonly models: ReadonlyMap<string, ModelInfo>
}
export interface CatalogDraft {
@ -16,8 +16,8 @@ export interface CatalogDraft {
remove(providerID: string): void
}
readonly model: {
get(providerID: string, modelID: string): ModelV2Info | undefined
update(providerID: string, modelID: string, update: (model: ModelV2Info) => void): void
get(providerID: string, modelID: string): ModelInfo | undefined
update(providerID: string, modelID: string, update: (model: ModelInfo) => void): void
remove(providerID: string, modelID: string): void
readonly default: {
get(): { providerID: string; modelID: string } | undefined

View file

@ -1,12 +1,12 @@
import type { CommandV2Info } from "@opencode-ai/sdk/v2/types"
import type { CommandInfo } from "@opencode-ai/sdk/v2/types"
import type { CommandApi } from "@opencode-ai/client/effect/api"
import type { Effect } from "effect"
import type { Transform } from "./registration.js"
export interface CommandDraft {
list(): readonly CommandV2Info[]
get(name: string): CommandV2Info | undefined
update(name: string, update: (command: CommandV2Info) => void): void
list(): readonly CommandInfo[]
get(name: string): CommandInfo | undefined
update(name: string, update: (command: CommandInfo) => void): void
remove(name: string): void
}

View file

@ -1,11 +1,11 @@
import type { SkillV2Source } from "@opencode-ai/sdk/v2/types"
import type { SkillSource } from "@opencode-ai/sdk/v2/types"
import type { SkillApi } from "@opencode-ai/client/effect/api"
import type { Effect } from "effect"
import type { Transform } from "./registration.js"
export interface SkillDraft {
source(source: SkillV2Source): void
list(): readonly SkillV2Source[]
source(source: SkillSource): void
list(): readonly SkillSource[]
}
export interface SkillDomain extends SkillApi<unknown> {

View file

@ -1,4 +1,5 @@
import { Schema } from "effect"
import { Skill } from "@opencode-ai/schema/skill"
export class InvalidRequestError extends Schema.TaggedErrorClass<InvalidRequestError>()(
"InvalidRequestError",
@ -83,7 +84,7 @@ export class MessageNotFoundError extends Schema.TaggedErrorClass<MessageNotFoun
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()(
"SkillNotFoundError",
{
skill: Schema.String,
skill: Skill.ID,
message: Schema.String,
},
{ httpApiStatus: 404 },

View file

@ -27,7 +27,7 @@ export const MessageGroup = HttpApiGroup.make("server.message")
params: { sessionID: Session.ID },
query: SessionMessagesQuery,
success: Schema.Struct({
data: Schema.Array(SessionMessage.Message),
data: Schema.Array(SessionMessage.Info),
cursor: Schema.Struct({
previous: Schema.String.pipe(Schema.optional),
next: Schema.String.pipe(Schema.optional),

View file

@ -23,9 +23,9 @@ import {
UnknownError,
} from "../errors.js"
import { Agent } from "@opencode-ai/schema/agent"
import { Skill } from "@opencode-ai/schema/skill"
import { Model } from "@opencode-ai/schema/model"
import { Location } from "@opencode-ai/schema/location"
import { Revert } from "@opencode-ai/schema/revert"
import { SessionEvent } from "@opencode-ai/schema/session-event"
import { EventLog } from "@opencode-ai/schema/event-log"
@ -316,7 +316,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
id: SessionMessage.ID.pipe(Schema.optional),
command: Schema.String,
arguments: Schema.String.pipe(Schema.optional),
agent: Schema.String.pipe(Schema.optional),
agent: Agent.ID.pipe(Schema.optional),
model: Model.Ref.pipe(Schema.optional),
files: PromptInput.Prompt.fields.files,
agents: PromptInput.Prompt.fields.agents,
@ -341,7 +341,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
params: { sessionID: Session.ID },
payload: Schema.Struct({
id: SessionMessage.ID.pipe(Schema.optional),
skill: Schema.String,
skill: Skill.ID,
resume: Schema.Boolean.pipe(Schema.optional),
}),
success: HttpApiSchema.NoContent,
@ -432,7 +432,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
HttpApiEndpoint.post("session.revert.stage", "/api/session/:sessionID/revert/stage", {
params: { sessionID: Session.ID },
payload: Schema.Struct({ messageID: SessionMessage.ID, files: Schema.Boolean.pipe(Schema.optional) }),
success: Schema.Struct({ data: Revert.State }),
success: Schema.Struct({ data: Session.Revert }),
error: [MessageNotFoundError, SessionNotFoundError, SessionBusyError, UnknownError],
})
.middleware(sessionLocationMiddleware)
@ -467,7 +467,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
.add(
HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", {
params: { sessionID: Session.ID },
success: Schema.Struct({ data: Schema.Array(SessionMessage.Message) }),
success: Schema.Struct({ data: Schema.Array(SessionMessage.Info) }),
error: [SessionNotFoundError, UnknownError],
})
.middleware(sessionLocationMiddleware)
@ -583,7 +583,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
.add(
HttpApiEndpoint.get("session.message", "/api/session/:sessionID/message/:messageID", {
params: { sessionID: Session.ID, messageID: SessionMessage.ID },
success: Schema.Struct({ data: SessionMessage.Message }),
success: Schema.Struct({ data: SessionMessage.Info }),
error: [SessionNotFoundError, MessageNotFoundError],
})
.middleware(sessionLocationMiddleware)

View file

@ -10,9 +10,12 @@ import { PositiveInt, statics } from "./schema.js"
const Updated = ephemeral({ type: "agent.updated", schema: {} })
export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID"))
export const ID = Schema.String.pipe(Schema.brand("Agent.ID"))
export type ID = typeof ID.Type
export const Name = Schema.String.pipe(Schema.brand("Agent.Name"))
export type Name = typeof Name.Type
export const Color = Schema.Union([
Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)),
Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
@ -22,6 +25,7 @@ export type Color = typeof Color.Type
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const Info = Schema.Struct({
id: ID,
name: Name,
model: Model.Ref.pipe(optional),
request: Provider.Request,
system: Schema.String.pipe(optional),
@ -32,12 +36,13 @@ export const Info = Schema.Struct({
steps: PositiveInt.pipe(optional),
permissions: Permission.Ruleset,
})
.annotate({ identifier: "AgentV2.Info" })
.annotate({ identifier: "Agent.Info" })
.pipe(
statics((schema) => ({
empty: (id: ID) =>
schema.make({
id,
name: Name.make(id),
request: { settings: {}, headers: {}, body: {} },
mode: "all",
hidden: false,

View file

@ -4,6 +4,7 @@ import { Schema } from "effect"
import { ephemeral, inventory } from "./event.js"
import { optional } from "./schema.js"
import { Model } from "./model.js"
import { Agent } from "./agent.js"
const Updated = ephemeral({ type: "command.updated", schema: {} })
@ -12,10 +13,10 @@ export const Info = Schema.Struct({
name: Schema.String,
template: Schema.String,
description: Schema.String.pipe(optional),
agent: Schema.String.pipe(optional),
agent: Agent.ID.pipe(optional),
model: Model.Ref.pipe(optional),
subtask: Schema.Boolean.pipe(optional),
}).annotate({ identifier: "CommandV2.Info" })
}).annotate({ identifier: "Command.Info" })
export const Event = {
Updated,

View file

@ -1,6 +1,6 @@
export * as Event from "./event.js"
import { Schema } from "effect"
import { Schema, SchemaTransformation } from "effect"
import { optional } from "./schema.js"
import { ascending } from "./identifier.js"
import { Location } from "./location.js"
@ -72,6 +72,7 @@ export type Payload<D extends Definition = Definition> = D extends DurableDefini
type Input<Type extends string, Fields extends Readonly<Record<PropertyKey, Schema.Codec<unknown, unknown>>>> = {
readonly type: Type
readonly identifier?: string
readonly durable?: {
readonly version: number
readonly aggregate: string
@ -84,16 +85,29 @@ export function durable<
const Fields extends Readonly<Record<PropertyKey, Schema.Codec<unknown, unknown>>>,
>(input: Input<Type, Fields> & { readonly durable: NonNullable<Input<Type, Fields>["durable"]> }) {
const data = Schema.Struct(input.schema)
const durable = Schema.Struct({
aggregateID: DurableEnvelope.fields.aggregateID,
seq: DurableEnvelope.fields.seq,
version: Schema.Literal(input.durable.version).pipe(
Schema.decodeTo(
Schema.toType(Version),
SchemaTransformation.transform({
decode: () => Version.make(input.durable.version),
encode: () => input.durable.version,
}),
),
),
})
return Schema.Struct({
id: ID,
created: DateTimeUtcFromMillis,
metadata: optional(Schema.Record(Schema.String, Schema.Unknown)),
type: Schema.Literal(input.type),
durable: DurableEnvelope,
durable,
location: optional(Location.Ref),
data,
})
.annotate({ identifier: input.type })
.annotate({ identifier: input.identifier ?? input.type })
.pipe(
statics(() => ({
type: input.type,
@ -117,7 +131,7 @@ export function ephemeral<
location: optional(Location.Ref),
data,
})
.annotate({ identifier: input.type })
.annotate({ identifier: input.identifier ?? input.type })
.pipe(
statics(() => ({
type: input.type,

View file

@ -1,13 +1,23 @@
export * as FileDiff from "./file-diff.js"
import { Schema } from "effect"
import { optional } from "./schema.js"
import { NonNegativeInt, optional } from "./schema.js"
export const Info = Schema.Struct({
file: optional(Schema.String),
patch: optional(Schema.String),
file: Schema.String,
patch: Schema.String,
additions: NonNegativeInt,
deletions: NonNegativeInt,
status: Schema.Literals(["added", "deleted", "modified"]),
}).annotate({ identifier: "FileDiff.Info" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
/** V1 snapshot and persisted session diff shape. */
export const LegacyInfo = Schema.Struct({
file: Schema.String.pipe(optional),
patch: Schema.String.pipe(optional),
additions: Schema.Finite,
deletions: Schema.Finite,
status: optional(Schema.Literals(["added", "deleted", "modified"])),
}).annotate({ identifier: "SnapshotFileDiff" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
status: Schema.Literals(["added", "deleted", "modified"]).pipe(optional),
}).annotate({ identifier: "FileDiff.LegacyInfo" })
export interface LegacyInfo extends Schema.Schema.Type<typeof LegacyInfo> {}

Some files were not shown because too many files have changed in this diff Show more