refactor(schema): apply session review decisions (#35793)
This commit is contained in:
parent
d27746e5b3
commit
ed6ad272ec
142 changed files with 4239 additions and 3174 deletions
|
|
@ -8,7 +8,7 @@ import type {
|
||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
Session,
|
Session,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
SnapshotFileDiff,
|
FileDiffInfo,
|
||||||
Todo,
|
Todo,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
import type { State, VcsCache } from "./types"
|
import type { State, VcsCache } from "./types"
|
||||||
|
|
@ -188,7 +188,7 @@ export function applyDirectoryEvent(input: {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "session.diff": {
|
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" }))
|
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import type {
|
||||||
PermissionRequest,
|
PermissionRequest,
|
||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
SnapshotFileDiff,
|
FileDiffInfo,
|
||||||
Todo,
|
Todo,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
||||||
|
|
@ -33,7 +33,7 @@ describe("app session cache", () => {
|
||||||
test("dropSessionCaches clears orphaned parts without message rows", () => {
|
test("dropSessionCaches clears orphaned parts without message rows", () => {
|
||||||
const store: {
|
const store: {
|
||||||
session_status: Record<string, SessionStatus | undefined>
|
session_status: Record<string, SessionStatus | undefined>
|
||||||
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||||
todo: Record<string, Todo[] | undefined>
|
todo: Record<string, Todo[] | undefined>
|
||||||
message: Record<string, Message[] | undefined>
|
message: Record<string, Message[] | undefined>
|
||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
|
|
@ -67,7 +67,7 @@ describe("app session cache", () => {
|
||||||
const m = msg("msg_1", "ses_1")
|
const m = msg("msg_1", "ses_1")
|
||||||
const store: {
|
const store: {
|
||||||
session_status: Record<string, SessionStatus | undefined>
|
session_status: Record<string, SessionStatus | undefined>
|
||||||
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||||
todo: Record<string, Todo[] | undefined>
|
todo: Record<string, Todo[] | undefined>
|
||||||
message: Record<string, Message[] | undefined>
|
message: Record<string, Message[] | undefined>
|
||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import type {
|
||||||
PermissionRequest,
|
PermissionRequest,
|
||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
SnapshotFileDiff,
|
FileDiffInfo,
|
||||||
Todo,
|
Todo,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
|
|
@ -12,7 +12,7 @@ export const SESSION_CACHE_LIMIT = 40
|
||||||
|
|
||||||
type SessionCache = {
|
type SessionCache = {
|
||||||
session_status: Record<string, SessionStatus | undefined>
|
session_status: Record<string, SessionStatus | undefined>
|
||||||
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||||
todo: Record<string, Todo[] | undefined>
|
todo: Record<string, Todo[] | undefined>
|
||||||
message: Record<string, Message[] | undefined>
|
message: Record<string, Message[] | undefined>
|
||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import type {
|
||||||
ReferenceInfo,
|
ReferenceInfo,
|
||||||
Session,
|
Session,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
SnapshotFileDiff,
|
FileDiffInfo,
|
||||||
Todo,
|
Todo,
|
||||||
VcsInfo,
|
VcsInfo,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
@ -51,7 +51,7 @@ export type State = {
|
||||||
}
|
}
|
||||||
session_working(id: string): boolean
|
session_working(id: string): boolean
|
||||||
session_diff: {
|
session_diff: {
|
||||||
[sessionID: string]: SnapshotFileDiff[]
|
[sessionID: string]: FileDiffInfo[]
|
||||||
}
|
}
|
||||||
todo: {
|
todo: {
|
||||||
[sessionID: string]: Todo[]
|
[sessionID: string]: Todo[]
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import type {
|
||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
Session,
|
Session,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
SnapshotFileDiff,
|
FileDiffInfo,
|
||||||
Todo,
|
Todo,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
import { batch } from "solid-js"
|
import { batch } from "solid-js"
|
||||||
|
|
@ -139,7 +139,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
const [data, setData] = createStore({
|
const [data, setData] = createStore({
|
||||||
info: {} as Record<string, Session | undefined>,
|
info: {} as Record<string, Session | undefined>,
|
||||||
session_status: {} as Record<string, SessionStatus>,
|
session_status: {} as Record<string, SessionStatus>,
|
||||||
session_diff: {} as Record<string, SnapshotFileDiff[]>,
|
session_diff: {} as Record<string, FileDiffInfo[]>,
|
||||||
todo: {} as Record<string, Todo[]>,
|
todo: {} as Record<string, Todo[]>,
|
||||||
permission: {} as Record<string, PermissionRequest[]>,
|
permission: {} as Record<string, PermissionRequest[]>,
|
||||||
question: {} as Record<string, QuestionRequest[]>,
|
question: {} as Record<string, QuestionRequest[]>,
|
||||||
|
|
@ -769,7 +769,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "session.diff": {
|
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" }))
|
setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { createEffect, onCleanup, type JSX } from "solid-js"
|
import { createEffect, onCleanup, type JSX } from "solid-js"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
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 { SessionReview } from "@opencode-ai/session-ui/session-review"
|
||||||
import type {
|
import type {
|
||||||
SessionReviewCommentActions,
|
SessionReviewCommentActions,
|
||||||
|
|
@ -14,7 +14,7 @@ import type { LineComment } from "@/context/comments"
|
||||||
|
|
||||||
export type DiffStyle = "unified" | "split"
|
export type DiffStyle = "unified" | "split"
|
||||||
|
|
||||||
type ReviewDiff = SnapshotFileDiff | VcsFileDiff
|
type ReviewDiff = FileDiffInfo | VcsFileDiff
|
||||||
|
|
||||||
export interface SessionReviewTabProps {
|
export interface SessionReviewTabProps {
|
||||||
title?: JSX.Element
|
title?: JSX.Element
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||||
import { Mark } from "@opencode-ai/ui/logo"
|
import { Mark } from "@opencode-ai/ui/logo"
|
||||||
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
||||||
import type { DragEvent } 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 { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
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 { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useSync } from "@/context/sync"
|
|
||||||
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
||||||
import { FileTabContent } from "@/pages/session/file-tabs"
|
import { FileTabContent } from "@/pages/session/file-tabs"
|
||||||
import {
|
import {
|
||||||
|
|
@ -36,15 +35,9 @@ import {
|
||||||
import { setSessionHandoff } from "@/pages/session/handoff"
|
import { setSessionHandoff } from "@/pages/session/handoff"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
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: {
|
export function SessionSidePanel(props: {
|
||||||
canReview: () => boolean
|
canReview: () => boolean
|
||||||
diffs: () => (SnapshotFileDiff | VcsFileDiff)[]
|
diffs: () => (FileDiffInfo | VcsFileDiff)[]
|
||||||
diffsReady: () => boolean
|
diffsReady: () => boolean
|
||||||
empty: () => string
|
empty: () => string
|
||||||
hasReview: () => boolean
|
hasReview: () => boolean
|
||||||
|
|
@ -59,7 +52,6 @@ export function SessionSidePanel(props: {
|
||||||
}) {
|
}) {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const sync = useSync()
|
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
|
|
@ -88,7 +80,7 @@ export function SessionSidePanel(props: {
|
||||||
})
|
})
|
||||||
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
|
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 diffFiles = createMemo(() => diffs().map((d) => d.file))
|
||||||
const kinds = createMemo(() => {
|
const kinds = createMemo(() => {
|
||||||
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
|
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
|
||||||
|
|
|
||||||
|
|
@ -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 type { Kind } from "@/components/file-tree-v2"
|
||||||
import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
|
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) {
|
export function normalizePath(p: string) {
|
||||||
return normalizeFileTreeV2Path(p)
|
return normalizeFileTreeV2Path(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function filterRenderableDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
|
|
||||||
return typeof value.file === "string"
|
|
||||||
}
|
|
||||||
|
|
||||||
export function reviewDiffKinds(diffs: RenderDiff[]) {
|
export function reviewDiffKinds(diffs: RenderDiff[]) {
|
||||||
const merge = (a: Kind | undefined, b: Kind) => {
|
const merge = (a: Kind | undefined, b: Kind) => {
|
||||||
if (!a) return b
|
if (!a) return b
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { createMemo, createSignal, Show, type JSX } from "solid-js"
|
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 {
|
import {
|
||||||
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX,
|
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX,
|
||||||
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
|
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
|
||||||
|
|
@ -21,16 +21,11 @@ import type {
|
||||||
import FileTreeV2 from "@/components/file-tree-v2"
|
import FileTreeV2 from "@/components/file-tree-v2"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import {
|
import { filterReviewFiles, reviewDiffKinds, type RenderDiff } from "@/pages/session/v2/review-diff-kinds"
|
||||||
filterRenderableDiff,
|
|
||||||
filterReviewFiles,
|
|
||||||
reviewDiffKinds,
|
|
||||||
type RenderDiff,
|
|
||||||
} from "@/pages/session/v2/review-diff-kinds"
|
|
||||||
import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
|
import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
|
||||||
import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
|
import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
|
||||||
|
|
||||||
type ReviewDiff = SnapshotFileDiff | VcsFileDiff
|
type ReviewDiff = FileDiffInfo | VcsFileDiff
|
||||||
|
|
||||||
export type ReviewPanelV2Props = {
|
export type ReviewPanelV2Props = {
|
||||||
title?: JSX.Element
|
title?: JSX.Element
|
||||||
|
|
@ -54,7 +49,7 @@ export type ReviewPanelV2Props = {
|
||||||
export function ReviewPanelV2(props: ReviewPanelV2Props) {
|
export function ReviewPanelV2(props: ReviewPanelV2Props) {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
|
||||||
const diffs = createMemo(() => props.diffs().filter(filterRenderableDiff))
|
const diffs = createMemo(() => props.diffs())
|
||||||
const filteredFiles = createMemo(() =>
|
const filteredFiles = createMemo(() =>
|
||||||
filterReviewFiles(
|
filterReviewFiles(
|
||||||
diffs().map((diff) => diff.file),
|
diffs().map((diff) => diff.file),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
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 type { Message } from "@opencode-ai/sdk/v2/client"
|
||||||
import { diffs, message } from "./diffs"
|
import { diffs, message } from "./diffs"
|
||||||
|
|
||||||
|
|
@ -9,7 +9,7 @@ const item = {
|
||||||
additions: 1,
|
additions: 1,
|
||||||
deletions: 1,
|
deletions: 1,
|
||||||
status: "modified",
|
status: "modified",
|
||||||
} satisfies SnapshotFileDiff
|
} satisfies FileDiffInfo
|
||||||
|
|
||||||
describe("diffs", () => {
|
describe("diffs", () => {
|
||||||
test("keeps valid arrays", () => {
|
test("keeps valid arrays", () => {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
type Diff = SnapshotFileDiff | VcsFileDiff
|
type Diff = FileDiffInfo
|
||||||
|
|
||||||
function diff(value: unknown): value is Diff {
|
function diff(value: unknown): value is Diff {
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,8 @@ function defaultCost(model: CurrentModel) {
|
||||||
|
|
||||||
export function runAgent(input: CurrentAgent): RunAgent {
|
export function runAgent(input: CurrentAgent): RunAgent {
|
||||||
return {
|
return {
|
||||||
name: input.id,
|
id: input.id,
|
||||||
|
name: input.name,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
mode: input.mode,
|
mode: input.mode,
|
||||||
hidden: input.hidden,
|
hidden: input.hidden,
|
||||||
|
|
@ -53,7 +54,7 @@ export function runCommand(input: CurrentCommand): RunCommand {
|
||||||
|
|
||||||
export function runSkill(input: CurrentSkill): RunCommand {
|
export function runSkill(input: CurrentSkill): RunCommand {
|
||||||
return {
|
return {
|
||||||
name: input.name,
|
name: input.id,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
source: "skill",
|
source: "skill",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -333,10 +333,10 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
kind: "mention",
|
kind: "mention",
|
||||||
display: "@" + item.name,
|
display: "@" + item.name,
|
||||||
value: item.name,
|
value: item.id,
|
||||||
part: {
|
part: {
|
||||||
type: "agent",
|
type: "agent",
|
||||||
name: item.name,
|
name: item.id,
|
||||||
source: {
|
source: {
|
||||||
start: 0,
|
start: 0,
|
||||||
end: 0,
|
end: 0,
|
||||||
|
|
|
||||||
|
|
@ -281,7 +281,6 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
metadata: {
|
metadata: {
|
||||||
structured: event.data.structured,
|
structured: event.data.structured,
|
||||||
content: event.data.content,
|
content: event.data.content,
|
||||||
outputPaths: event.data.outputPaths,
|
|
||||||
result: event.data.result,
|
result: event.data.result,
|
||||||
providerCall: current.provider,
|
providerCall: current.provider,
|
||||||
providerResult: { executed: event.data.executed, state: event.data.resultState },
|
providerResult: { executed: event.data.executed, state: event.data.resultState },
|
||||||
|
|
|
||||||
|
|
@ -89,12 +89,11 @@ async function execute(input: RunCommandInput, prepared: Prepared, transport: Tr
|
||||||
!explicitModel && !sessionModel
|
!explicitModel && !sessionModel
|
||||||
? await client.model
|
? await client.model
|
||||||
.default({ location: { directory: cwd, workspace } })
|
.default({ location: { directory: cwd, workspace } })
|
||||||
.then((result) =>
|
.then((result) => (result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined))
|
||||||
result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined,
|
|
||||||
)
|
|
||||||
: undefined
|
: undefined
|
||||||
const model = pickRunModel(explicitModel, input.variant, sessionModel, defaultModel)
|
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) {
|
if (model) {
|
||||||
await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model })
|
await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model })
|
||||||
const available = await client.model.list({ location: { directory: cwd, workspace } })
|
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) {
|
if (!session && input.title !== undefined) {
|
||||||
await client.session.rename({
|
await client.session.rename({
|
||||||
sessionID: selected.id,
|
sessionID: selected.id,
|
||||||
title:
|
title: input.title || prepared.message.slice(0, 50) + (prepared.message.length > 50 ? "..." : ""),
|
||||||
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`)
|
warning(`failed to list agents${server ? ` from ${server}` : ""}. Falling back to default agent`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const agent = agents.find((item) => item.name === name)
|
const agent = agents.find((item) => item.id === name)
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
warning(`agent "${name}" not found. Falling back to default agent`)
|
warning(`agent "${name}" not found. Falling back to default agent`)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
// backgrounding is intentionally absent: subagent jobs block the parent
|
// backgrounding is intentionally absent: subagent jobs block the parent
|
||||||
// session, so only whole-session `v2.session.background(parentID)` exists.
|
// session, so only whole-session `v2.session.background(parentID)` exists.
|
||||||
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
|
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 { Locale } from "@opencode-ai/tui/util/locale"
|
||||||
import type { FooterSubagentDetail, FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types"
|
import type { FooterSubagentDetail, FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types"
|
||||||
|
|
||||||
|
|
@ -54,7 +54,7 @@ export function legacyTool(input: {
|
||||||
callID: tool.id,
|
callID: tool.id,
|
||||||
tool: tool.name,
|
tool: tool.name,
|
||||||
}
|
}
|
||||||
if (tool.state.status === "pending") {
|
if (tool.state.status === "streaming") {
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
state: { status: "pending", input: {}, raw: tool.state.input },
|
state: { status: "pending", input: {}, raw: tool.state.input },
|
||||||
|
|
@ -83,7 +83,6 @@ export function legacyTool(input: {
|
||||||
metadata: {
|
metadata: {
|
||||||
structured: tool.state.structured,
|
structured: tool.state.structured,
|
||||||
content: tool.state.content,
|
content: tool.state.content,
|
||||||
outputPaths: tool.state.outputPaths,
|
|
||||||
result: tool.state.result,
|
result: tool.state.result,
|
||||||
providerCall,
|
providerCall,
|
||||||
providerResult,
|
providerResult,
|
||||||
|
|
@ -179,7 +178,7 @@ export type SubagentTrackerInput = {
|
||||||
export type SubagentTracker = {
|
export type SubagentTracker = {
|
||||||
main(event: V2Event): void
|
main(event: V2Event): void
|
||||||
foreign(sessionID: string, 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
|
select(sessionID: string | undefined): void
|
||||||
snapshot(): FooterSubagentState
|
snapshot(): FooterSubagentState
|
||||||
}
|
}
|
||||||
|
|
@ -316,7 +315,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||||
messageID,
|
messageID,
|
||||||
tool: item,
|
tool: item,
|
||||||
})
|
})
|
||||||
if (item.state.status === "pending") return
|
if (item.state.status === "streaming") return
|
||||||
child.callIDs.add(item.id)
|
child.callIDs.add(item.id)
|
||||||
if (item.state.status === "running") {
|
if (item.state.status === "running") {
|
||||||
setFrame(child, `tool:${item.id}`, toolCommit(part, "start"))
|
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"))
|
setFrame(child, `tool:${item.id}`, toolCommit(part, "final"))
|
||||||
}
|
}
|
||||||
|
|
||||||
const rebuild = (child: ChildState, messages: SessionMessage[]) => {
|
const rebuild = (child: ChildState, messages: SessionMessageInfo[]) => {
|
||||||
child.frames = []
|
child.frames = []
|
||||||
child.text.clear()
|
child.text.clear()
|
||||||
child.projectedText.clear()
|
child.projectedText.clear()
|
||||||
|
|
@ -412,7 +411,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||||
for (const [id, prompt] of pendingPrompts) {
|
for (const [id, prompt] of pendingPrompts) {
|
||||||
if (!child.prompts.has(id)) child.prompts.set(id, prompt)
|
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) {
|
for (const [id, tool] of pendingTools) {
|
||||||
if (!child.finishedTools.has(id) && !child.tools.has(id)) child.tools.set(id, tool)
|
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 ?? {},
|
input: current?.input ?? {},
|
||||||
structured: event.data.structured,
|
structured: event.data.structured,
|
||||||
content: event.data.content,
|
content: event.data.content,
|
||||||
outputPaths: event.data.outputPaths,
|
|
||||||
result: event.data.result,
|
result: event.data.result,
|
||||||
},
|
},
|
||||||
time: {
|
time: {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/p
|
||||||
import type {
|
import type {
|
||||||
PermissionRequest,
|
PermissionRequest,
|
||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
SessionMessage,
|
SessionMessageInfo,
|
||||||
SessionMessageAssistantTool,
|
SessionMessageAssistantTool,
|
||||||
} from "@opencode-ai/sdk/v2"
|
} from "@opencode-ai/sdk/v2"
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
|
|
@ -389,7 +389,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||||
messageID,
|
messageID,
|
||||||
tool: item,
|
tool: item,
|
||||||
})
|
})
|
||||||
if (item.state.status === "pending") return
|
if (item.state.status === "streaming") return
|
||||||
if (item.state.status === "running") {
|
if (item.state.status === "running") {
|
||||||
if (state.tools.get(item.id)?.running) return
|
if (state.tools.get(item.id)?.running) return
|
||||||
state.tools.set(item.id, {
|
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") {
|
if (message.type === "user") {
|
||||||
const waiting = state.wait?.messageID === message.id
|
const waiting = state.wait?.messageID === message.id
|
||||||
if (waiting && state.wait) state.wait.promoted = true
|
if (waiting && state.wait) state.wait.promoted = true
|
||||||
|
|
@ -438,34 +438,34 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (message.type === "shell") {
|
if (message.type === "shell") {
|
||||||
state.shellCommands.set(message.shell.id, message.shell.command)
|
state.shellCommands.set(message.shellID, message.command)
|
||||||
if (state.shellWait?.messageID === message.id) state.shellWait.callID = message.shell.id
|
if (state.shellWait?.messageID === message.id) state.shellWait.callID = message.shellID
|
||||||
const completed = message.time.completed !== undefined
|
const completed = message.time.completed !== undefined
|
||||||
if (!render) {
|
if (!render) {
|
||||||
// Suppressed history: mark settled shells rendered so live redelivery
|
// Suppressed history: mark settled shells rendered so live redelivery
|
||||||
// stays silent. A still-running shell stays unmarked and renders in
|
// stays silent. A still-running shell stays unmarked and renders in
|
||||||
// full when its live shell.ended event arrives.
|
// full when its live shell.ended event arrives.
|
||||||
if (completed) {
|
if (completed) {
|
||||||
state.shellStarted.add(message.shell.id)
|
state.shellStarted.add(message.shellID)
|
||||||
state.shellEnded.add(message.shell.id)
|
state.shellEnded.add(message.shellID)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!state.shellStarted.has(message.shell.id)) {
|
if (!state.shellStarted.has(message.shellID)) {
|
||||||
state.shellStarted.add(message.shell.id)
|
state.shellStarted.add(message.shellID)
|
||||||
write([
|
write([
|
||||||
shellCommit(message.shell.id, message.shell.command, {
|
shellCommit(message.shellID, message.command, {
|
||||||
text: "running shell",
|
text: "running shell",
|
||||||
phase: "start",
|
phase: "start",
|
||||||
toolState: "running",
|
toolState: "running",
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
if (completed && message.output && !state.shellEnded.has(message.shell.id)) {
|
if (completed && message.output && !state.shellEnded.has(message.shellID)) {
|
||||||
state.shellEnded.add(message.shell.id)
|
state.shellEnded.add(message.shellID)
|
||||||
write(shellTerminal(message.shell.id, message.shell.command, message.shell, message.output))
|
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
|
return
|
||||||
}
|
}
|
||||||
if (message.type !== "assistant") 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.question.list({ sessionID: input.sessionID }),
|
||||||
input.sdk.session.active(),
|
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)
|
for (const message of projected) renderMessage(message, next.render, next.reuseVisibleWait)
|
||||||
state.permissions = permissions.map(permission)
|
state.permissions = permissions.map(permission)
|
||||||
state.questions = questions.map(question)
|
state.questions = questions.map(question)
|
||||||
|
|
@ -762,7 +762,6 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||||
input: current?.input ?? {},
|
input: current?.input ?? {},
|
||||||
structured: event.data.structured,
|
structured: event.data.structured,
|
||||||
content: event.data.content,
|
content: event.data.content,
|
||||||
outputPaths: event.data.outputPaths,
|
|
||||||
result: event.data.result,
|
result: event.data.result,
|
||||||
},
|
},
|
||||||
time: { created: current?.started ?? event.created, ran: current?.started, completed: event.created },
|
time: { created: current?.started ?? event.created, ran: current?.started, completed: event.created },
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,7 @@ export type FooterQueuedPrompt = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RunAgent = {
|
export type RunAgent = {
|
||||||
|
id: string
|
||||||
name: string
|
name: string
|
||||||
description?: string
|
description?: string
|
||||||
mode: "subagent" | "primary" | "all"
|
mode: "subagent" | "primary" | "all"
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -38,7 +38,7 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () =>
|
||||||
expect(ProjectV2.Directory).toBe(Project.Directory)
|
expect(ProjectV2.Directory).toBe(Project.Directory)
|
||||||
expect(ProjectV2.Directories).toBe(Project.Directories)
|
expect(ProjectV2.Directories).toBe(Project.Directories)
|
||||||
expect(CoreSessionInput.Admitted).toBe(SessionInput.Admitted)
|
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.session"].identifier).toBe("server.session")
|
||||||
expect(Api.groups["server.project"].identifier).toBe("server.project")
|
expect(Api.groups["server.project"].identifier).toBe("server.project")
|
||||||
expect(Object.keys(ClientApi.groups)).toEqual(Object.keys(Api.groups))
|
expect(Object.keys(ClientApi.groups)).toEqual(Object.keys(Api.groups))
|
||||||
|
|
|
||||||
1400
packages/codemode/test/fixtures/opencode-v2-openapi.json
vendored
1400
packages/codemode/test/fixtures/opencode-v2-openapi.json
vendored
File diff suppressed because it is too large
Load diff
|
|
@ -8,6 +8,8 @@ import { State } from "./state"
|
||||||
|
|
||||||
export const ID = Agent.ID
|
export const ID = Agent.ID
|
||||||
export type ID = typeof ID.Type
|
export type ID = typeof ID.Type
|
||||||
|
export const Name = Agent.Name
|
||||||
|
export type Name = Agent.Name
|
||||||
export const defaultID = ID.make("build")
|
export const defaultID = ID.make("build")
|
||||||
|
|
||||||
export const Color = Agent.Color
|
export const Color = Agent.Color
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
export * as ConfigProviderPlugin from "./provider"
|
export * as ConfigProviderPlugin from "./provider"
|
||||||
|
|
||||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||||
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { Effect, Stream } from "effect"
|
import { Effect, Stream } from "effect"
|
||||||
import { Config } from "../../config"
|
import { Config } from "../../config"
|
||||||
import { ModelV2 } from "../../model"
|
import { ModelV2 } from "../../model"
|
||||||
|
|
@ -91,8 +92,8 @@ export const Plugin = define({
|
||||||
input: cost.input,
|
input: cost.input,
|
||||||
output: cost.output,
|
output: cost.output,
|
||||||
cache: {
|
cache: {
|
||||||
read: cost.cache?.read ?? 0,
|
read: cost.cache?.read ?? Money.USDPerMillionTokens.zero,
|
||||||
write: cost.cache?.write ?? 0,
|
write: cost.cache?.write ?? Money.USDPerMillionTokens.zero,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
export * as ConfigProvider from "./provider"
|
export * as ConfigProvider from "./provider"
|
||||||
|
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { ModelV2 } from "../model"
|
import { ModelV2 } from "../model"
|
||||||
|
|
||||||
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
|
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")({
|
class Cache extends Schema.Class<Cache>("ConfigV2.Model.Cost.Cache")({
|
||||||
read: Schema.Finite.pipe(Schema.optional),
|
read: Money.USDPerMillionTokens.pipe(Schema.optional),
|
||||||
write: Schema.Finite.pipe(Schema.optional),
|
write: Money.USDPerMillionTokens.pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
class Cost extends Schema.Class<Cost>("ConfigV2.Model.Cost")({
|
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"),
|
type: Schema.Literal("context"),
|
||||||
size: Schema.Int,
|
size: Schema.Int,
|
||||||
}).pipe(Schema.optional),
|
}).pipe(Schema.optional),
|
||||||
input: Schema.Finite,
|
input: Money.USDPerMillionTokens,
|
||||||
output: Schema.Finite,
|
output: Money.USDPerMillionTokens,
|
||||||
cache: Cache.pipe(Schema.optional),
|
cache: Cache.pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
|
|
|
||||||
1
packages/core/src/database/migration.gen.ts
generated
1
packages/core/src/database/migration.gen.ts
generated
|
|
@ -48,5 +48,6 @@ export const migrations = (
|
||||||
import("./migration/20260705180000_rename_instructions"),
|
import("./migration/20260705180000_rename_instructions"),
|
||||||
import("./migration/20260706223930_add-session-fork"),
|
import("./migration/20260706223930_add-session-fork"),
|
||||||
import("./migration/20260707010146_durable_session_inbox"),
|
import("./migration/20260707010146_durable_session_inbox"),
|
||||||
|
import("./migration/20260707120000_migrate_prelaunch_v2_state"),
|
||||||
])
|
])
|
||||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export * as File from "./file"
|
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
|
export type Diff = typeof Diff.Type
|
||||||
|
|
|
||||||
|
|
@ -606,7 +606,7 @@ const layer = Layer.effect(
|
||||||
file,
|
file,
|
||||||
])).text
|
])).text
|
||||||
return {
|
return {
|
||||||
path: file,
|
file,
|
||||||
status,
|
status,
|
||||||
additions: binary ? 0 : Number(stats[0] ?? 0),
|
additions: binary ? 0 : Number(stats[0] ?? 0),
|
||||||
deletions: binary ? 0 : Number(stats[1] ?? 0),
|
deletions: binary ? 0 : Number(stats[1] ?? 0),
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import path from "path"
|
||||||
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
|
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
|
||||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||||
import { ModelsDev } from "@opencode-ai/schema/models-dev"
|
import { ModelsDev } from "@opencode-ai/schema/models-dev"
|
||||||
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { Global } from "./global"
|
import { Global } from "./global"
|
||||||
import { Flag } from "./flag/flag"
|
import { Flag } from "./flag/flag"
|
||||||
import { Flock } from "./util/flock"
|
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 USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}`
|
||||||
|
|
||||||
const CostTier = Schema.Struct({
|
const CostTier = Schema.Struct({
|
||||||
input: Schema.Finite,
|
input: Money.USDPerMillionTokens,
|
||||||
output: Schema.Finite,
|
output: Money.USDPerMillionTokens,
|
||||||
cache_read: Schema.optional(Schema.Finite),
|
cache_read: Schema.optional(Money.USDPerMillionTokens),
|
||||||
cache_write: Schema.optional(Schema.Finite),
|
cache_write: Schema.optional(Money.USDPerMillionTokens),
|
||||||
tier: Schema.Struct({
|
tier: Schema.Struct({
|
||||||
type: Schema.Literal("context"),
|
type: Schema.Literal("context"),
|
||||||
size: Schema.Finite,
|
size: Schema.Finite,
|
||||||
|
|
@ -29,17 +30,17 @@ const CostTier = Schema.Struct({
|
||||||
})
|
})
|
||||||
|
|
||||||
const Cost = Schema.Struct({
|
const Cost = Schema.Struct({
|
||||||
input: Schema.Finite,
|
input: Money.USDPerMillionTokens,
|
||||||
output: Schema.Finite,
|
output: Money.USDPerMillionTokens,
|
||||||
cache_read: Schema.optional(Schema.Finite),
|
cache_read: Schema.optional(Money.USDPerMillionTokens),
|
||||||
cache_write: Schema.optional(Schema.Finite),
|
cache_write: Schema.optional(Money.USDPerMillionTokens),
|
||||||
tiers: Schema.optional(Schema.Array(CostTier)),
|
tiers: Schema.optional(Schema.Array(CostTier)),
|
||||||
context_over_200k: Schema.optional(
|
context_over_200k: Schema.optional(
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
input: Schema.Finite,
|
input: Money.USDPerMillionTokens,
|
||||||
output: Schema.Finite,
|
output: Money.USDPerMillionTokens,
|
||||||
cache_read: Schema.optional(Schema.Finite),
|
cache_read: Schema.optional(Money.USDPerMillionTokens),
|
||||||
cache_write: Schema.optional(Schema.Finite),
|
cache_write: Schema.optional(Money.USDPerMillionTokens),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,7 @@ export const Plugin = define({
|
||||||
|
|
||||||
yield* ctx.agent.transform((draft) => {
|
yield* ctx.agent.transform((draft) => {
|
||||||
draft.update(AgentV2.defaultID, (item) => {
|
draft.update(AgentV2.defaultID, (item) => {
|
||||||
|
item.name = AgentV2.Name.make("Build")
|
||||||
item.description = "The default agent. Executes tools based on configured permissions."
|
item.description = "The default agent. Executes tools based on configured permissions."
|
||||||
item.mode = "primary"
|
item.mode = "primary"
|
||||||
item.permissions.push(
|
item.permissions.push(
|
||||||
|
|
@ -136,6 +137,7 @@ export const Plugin = define({
|
||||||
})
|
})
|
||||||
|
|
||||||
draft.update(AgentV2.ID.make("plan"), (item) => {
|
draft.update(AgentV2.ID.make("plan"), (item) => {
|
||||||
|
item.name = AgentV2.Name.make("Plan")
|
||||||
item.description = "Plan mode. Disallows all edit tools."
|
item.description = "Plan mode. Disallows all edit tools."
|
||||||
item.mode = "primary"
|
item.mode = "primary"
|
||||||
item.permissions.push(
|
item.permissions.push(
|
||||||
|
|
@ -155,6 +157,7 @@ export const Plugin = define({
|
||||||
})
|
})
|
||||||
|
|
||||||
draft.update(AgentV2.ID.make("general"), (item) => {
|
draft.update(AgentV2.ID.make("general"), (item) => {
|
||||||
|
item.name = AgentV2.Name.make("General")
|
||||||
item.description =
|
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."
|
"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"
|
item.mode = "subagent"
|
||||||
|
|
@ -167,6 +170,7 @@ export const Plugin = define({
|
||||||
})
|
})
|
||||||
|
|
||||||
draft.update(AgentV2.ID.make("explore"), (item) => {
|
draft.update(AgentV2.ID.make("explore"), (item) => {
|
||||||
|
item.name = AgentV2.Name.make("Explore")
|
||||||
item.description =
|
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.'
|
'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
|
item.system = PROMPT_EXPLORE
|
||||||
|
|
@ -189,6 +193,7 @@ export const Plugin = define({
|
||||||
})
|
})
|
||||||
|
|
||||||
draft.update(AgentV2.ID.make("compaction"), (item) => {
|
draft.update(AgentV2.ID.make("compaction"), (item) => {
|
||||||
|
item.name = AgentV2.Name.make("Compaction")
|
||||||
item.mode = "primary"
|
item.mode = "primary"
|
||||||
item.hidden = true
|
item.hidden = true
|
||||||
item.system = PROMPT_COMPACTION
|
item.system = PROMPT_COMPACTION
|
||||||
|
|
@ -196,6 +201,7 @@ export const Plugin = define({
|
||||||
})
|
})
|
||||||
|
|
||||||
draft.update(AgentV2.ID.make("title"), (item) => {
|
draft.update(AgentV2.ID.make("title"), (item) => {
|
||||||
|
item.name = AgentV2.Name.make("Title")
|
||||||
item.mode = "primary"
|
item.mode = "primary"
|
||||||
item.hidden = true
|
item.hidden = true
|
||||||
item.system = PROMPT_TITLE
|
item.system = PROMPT_TITLE
|
||||||
|
|
@ -203,6 +209,7 @@ export const Plugin = define({
|
||||||
})
|
})
|
||||||
|
|
||||||
draft.update(AgentV2.ID.make("summary"), (item) => {
|
draft.update(AgentV2.ID.make("summary"), (item) => {
|
||||||
|
item.name = AgentV2.Name.make("Summary")
|
||||||
item.mode = "primary"
|
item.mode = "primary"
|
||||||
item.hidden = true
|
item.hidden = true
|
||||||
item.system = PROMPT_SUMMARY
|
item.system = PROMPT_SUMMARY
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
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 { Effect, Stream } from "effect"
|
||||||
import { EventV2 } from "../event"
|
import { EventV2 } from "../event"
|
||||||
import { ModelV2 } from "../model"
|
import { ModelV2 } from "../model"
|
||||||
|
|
@ -11,13 +12,13 @@ function released(date: string) {
|
||||||
return Number.isFinite(time) ? time : 0
|
return Number.isFinite(time) ? time : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function cost(input: ModelsDev.Model["cost"]): ModelV2Info["cost"] {
|
function cost(input: ModelsDev.Model["cost"]): ModelInfo["cost"] {
|
||||||
const base = {
|
const base = {
|
||||||
input: input?.input ?? 0,
|
input: input?.input ?? Money.USDPerMillionTokens.zero,
|
||||||
output: input?.output ?? 0,
|
output: input?.output ?? Money.USDPerMillionTokens.zero,
|
||||||
cache: {
|
cache: {
|
||||||
read: input?.cache_read ?? 0,
|
read: input?.cache_read ?? Money.USDPerMillionTokens.zero,
|
||||||
write: input?.cache_write ?? 0,
|
write: input?.cache_write ?? Money.USDPerMillionTokens.zero,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
|
|
@ -27,8 +28,8 @@ function cost(input: ModelsDev.Model["cost"]): ModelV2Info["cost"] {
|
||||||
input: item.input,
|
input: item.input,
|
||||||
output: item.output,
|
output: item.output,
|
||||||
cache: {
|
cache: {
|
||||||
read: item.cache_read ?? 0,
|
read: item.cache_read ?? Money.USDPerMillionTokens.zero,
|
||||||
write: item.cache_write ?? 0,
|
write: item.cache_write ?? Money.USDPerMillionTokens.zero,
|
||||||
},
|
},
|
||||||
})) ?? []),
|
})) ?? []),
|
||||||
...(input?.context_over_200k
|
...(input?.context_over_200k
|
||||||
|
|
@ -41,8 +42,8 @@ function cost(input: ModelsDev.Model["cost"]): ModelV2Info["cost"] {
|
||||||
input: input.context_over_200k.input,
|
input: input.context_over_200k.input,
|
||||||
output: input.context_over_200k.output,
|
output: input.context_over_200k.output,
|
||||||
cache: {
|
cache: {
|
||||||
read: input.context_over_200k.cache_read ?? 0,
|
read: input.context_over_200k.cache_read ?? Money.USDPerMillionTokens.zero,
|
||||||
write: input.context_over_200k.cache_write ?? 0,
|
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
|
if (!override) return base
|
||||||
const next = cost(override)
|
const next = cost(override)
|
||||||
const [baseDefault, ...baseTiers] = base
|
const [baseDefault, ...baseTiers] = base
|
||||||
const [nextDefault, ...nextTiers] = next
|
const [nextDefault, ...nextTiers] = next
|
||||||
const tierKey = (item: ModelV2Info["cost"][number]) => `${item.tier?.type ?? "base"}:${item.tier?.size ?? 0}`
|
const tierKey = (item: ModelInfo["cost"][number]) => `${item.tier?.type ?? "base"}:${item.tier?.size ?? 0}`
|
||||||
const merge = (left: ModelV2Info["cost"][number], right: ModelV2Info["cost"][number]) => ({
|
const merge = (left: ModelInfo["cost"][number], right: ModelInfo["cost"][number]) => ({
|
||||||
...left,
|
...left,
|
||||||
...right,
|
...right,
|
||||||
tier: right.tier ?? left.tier,
|
tier: right.tier ?? left.tier,
|
||||||
|
|
@ -67,12 +68,25 @@ function mergeCost(base: ModelV2Info["cost"], override: ModelsDev.Model["cost"]
|
||||||
const current = tiers.get(tierKey(item))
|
const current = tiers.get(tierKey(item))
|
||||||
tiers.set(tierKey(item), current ? merge(current, item) : 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"]
|
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 npm = model.provider?.npm ?? provider.npm
|
||||||
const options = model.reasoning_options ?? []
|
const options = model.reasoning_options ?? []
|
||||||
const effort = options.find((option) => option.type === "effort")
|
const effort = options.find((option) => option.type === "effort")
|
||||||
|
|
@ -117,7 +131,7 @@ function settingsForEffort(npm: string | undefined, effort: string): ProviderV2.
|
||||||
function budgetVariants(
|
function budgetVariants(
|
||||||
npm: string | undefined,
|
npm: string | undefined,
|
||||||
option: Extract<NonNullable<ModelsDev.Model["reasoning_options"]>[number], { type: "budget_tokens" }>,
|
option: Extract<NonNullable<ModelsDev.Model["reasoning_options"]>[number], { type: "budget_tokens" }>,
|
||||||
): NonNullable<ModelV2Info["variants"]> {
|
): NonNullable<ModelInfo["variants"]> {
|
||||||
const max = option.max
|
const max = option.max
|
||||||
const high =
|
const high =
|
||||||
option.max === undefined
|
option.max === undefined
|
||||||
|
|
@ -146,7 +160,7 @@ function modeName(model: ModelsDev.Model, mode: string) {
|
||||||
return `${model.name} ${mode.charAt(0).toUpperCase()}${mode.slice(1)}`
|
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 variants = model.variants ?? []
|
||||||
const existing = new Map(variants.map((variant) => [variant.id, variant]))
|
const existing = new Map(variants.map((variant) => [variant.id, variant]))
|
||||||
const nextIDs = new Set(next.map((variant) => variant.id))
|
const nextIDs = new Set(next.map((variant) => variant.id))
|
||||||
|
|
@ -157,13 +171,13 @@ function mergeVariants(model: ModelV2Info, next: NonNullable<ModelV2Info["varian
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyModel(
|
function applyModel(
|
||||||
draft: ModelV2Info,
|
draft: ModelInfo,
|
||||||
model: ModelsDev.Model,
|
model: ModelsDev.Model,
|
||||||
input: {
|
input: {
|
||||||
readonly name?: string
|
readonly name?: string
|
||||||
readonly cost?: ModelV2Info["cost"]
|
readonly cost?: ModelInfo["cost"]
|
||||||
readonly request?: NonNullable<NonNullable<ModelsDev.Model["experimental"]>["modes"]>[string]["provider"]
|
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
|
draft.name = input.name ?? model.name
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { Integration } from "../../integration"
|
||||||
import { ModelV2 } from "../../model"
|
import { ModelV2 } from "../../model"
|
||||||
import { ProviderV2 } from "../../provider"
|
import { ProviderV2 } from "../../provider"
|
||||||
import { ConfigProviderV1 } from "../../v1/config/provider"
|
import { ConfigProviderV1 } from "../../v1/config/provider"
|
||||||
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { ConfigProviderOptionsV1 } from "../../v1/config/provider-options"
|
import { ConfigProviderOptionsV1 } from "../../v1/config/provider-options"
|
||||||
import { ConfigV1 } from "../../v1/config/config"
|
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"]>) {
|
function remoteCost(input: NonNullable<(typeof ConfigProviderV1.Model.Type)["cost"]>) {
|
||||||
const base = {
|
const base = {
|
||||||
input: input.input,
|
input: Money.USDPerMillionTokens.make(input.input),
|
||||||
output: input.output,
|
output: Money.USDPerMillionTokens.make(input.output),
|
||||||
cache: { read: input.cache_read ?? 0, write: input.cache_write ?? 0 },
|
cache: {
|
||||||
|
read: Money.USDPerMillionTokens.make(input.cache_read ?? 0),
|
||||||
|
write: Money.USDPerMillionTokens.make(input.cache_write ?? 0),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if (!input.context_over_200k) return [base]
|
if (!input.context_over_200k) return [base]
|
||||||
return [
|
return [
|
||||||
base,
|
base,
|
||||||
{
|
{
|
||||||
tier: { type: "context" as const, size: 200_000 },
|
tier: { type: "context" as const, size: 200_000 },
|
||||||
input: input.context_over_200k.input,
|
input: Money.USDPerMillionTokens.make(input.context_over_200k.input),
|
||||||
output: input.context_over_200k.output,
|
output: Money.USDPerMillionTokens.make(input.context_over_200k.output),
|
||||||
cache: {
|
cache: {
|
||||||
read: input.context_over_200k.cache_read ?? 0,
|
read: Money.USDPerMillionTokens.make(input.context_over_200k.cache_read ?? 0),
|
||||||
write: input.context_over_200k.cache_write ?? 0,
|
write: Money.USDPerMillionTokens.make(input.context_over_200k.cache_write ?? 0),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,8 @@ export const Plugin = define({
|
||||||
SkillV2.EmbeddedSource.make({
|
SkillV2.EmbeddedSource.make({
|
||||||
type: "embedded",
|
type: "embedded",
|
||||||
skill: SkillV2.Info.make({
|
skill: SkillV2.Info.make({
|
||||||
name: "opencode",
|
id: SkillV2.ID.make("opencode"),
|
||||||
|
name: SkillV2.Name.make("OpenCode"),
|
||||||
description: OpencodeDescription,
|
description: OpencodeDescription,
|
||||||
location: AbsolutePath.make("/builtin/opencode.md"),
|
location: AbsolutePath.make("/builtin/opencode.md"),
|
||||||
content: OpencodeContent,
|
content: OpencodeContent,
|
||||||
|
|
@ -44,7 +45,8 @@ export const Plugin = define({
|
||||||
SkillV2.EmbeddedSource.make({
|
SkillV2.EmbeddedSource.make({
|
||||||
type: "embedded",
|
type: "embedded",
|
||||||
skill: SkillV2.Info.make({
|
skill: SkillV2.Info.make({
|
||||||
name: "report",
|
id: SkillV2.ID.make("report"),
|
||||||
|
name: SkillV2.Name.make("Report"),
|
||||||
description: REPORT_DESCRIPTION,
|
description: REPORT_DESCRIPTION,
|
||||||
slash: true,
|
slash: true,
|
||||||
location: AbsolutePath.make("/builtin/report.md"),
|
location: AbsolutePath.make("/builtin/report.md"),
|
||||||
|
|
@ -103,15 +105,22 @@ const configuredPlugins = Effect.fn("SkillPlugin.configuredPlugins")(function* (
|
||||||
})
|
})
|
||||||
|
|
||||||
function terminal() {
|
function terminal() {
|
||||||
return [
|
return (
|
||||||
process.env.TERM_PROGRAM ? `TERM_PROGRAM=${process.env.TERM_PROGRAM}` : undefined,
|
[
|
||||||
process.env.TERM ? `TERM=${process.env.TERM}` : undefined,
|
process.env.TERM_PROGRAM ? `TERM_PROGRAM=${process.env.TERM_PROGRAM}` : undefined,
|
||||||
process.env.COLORTERM ? `COLORTERM=${process.env.COLORTERM}` : 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"
|
.filter((item): item is string => item !== undefined)
|
||||||
|
.join(", ") || "Unavailable: terminal environment variables are not set"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function shell() {
|
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"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
export * as SessionV2 from "./session"
|
export * as SessionV2 from "./session"
|
||||||
export * from "./session/schema"
|
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 { ListAnchor } from "@opencode-ai/schema/session"
|
||||||
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
|
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
|
||||||
import { ProjectV2 } from "./project"
|
import { ProjectV2 } from "./project"
|
||||||
|
|
@ -19,6 +19,7 @@ import { SessionSchema } from "./session/schema"
|
||||||
import { AbsolutePath, PositiveInt, RelativePath } from "./schema"
|
import { AbsolutePath, PositiveInt, RelativePath } from "./schema"
|
||||||
import { AgentV2 } from "./agent"
|
import { AgentV2 } from "./agent"
|
||||||
import { SessionV1 } from "./v1/session"
|
import { SessionV1 } from "./v1/session"
|
||||||
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { InstallationVersion } from "./installation/version"
|
import { InstallationVersion } from "./installation/version"
|
||||||
import { Slug } from "./util/slug"
|
import { Slug } from "./util/slug"
|
||||||
import { ProjectTable } from "./project/sql"
|
import { ProjectTable } from "./project/sql"
|
||||||
|
|
@ -34,7 +35,7 @@ import { SessionEvent } from "./session/event"
|
||||||
import { SessionInput } from "./session/input"
|
import { SessionInput } from "./session/input"
|
||||||
import { Snapshot } from "./snapshot"
|
import { Snapshot } from "./snapshot"
|
||||||
import { SessionRevert } from "./session/revert"
|
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 { FSUtil } from "./fs-util"
|
||||||
import { Mime } from "./mime"
|
import { Mime } from "./mime"
|
||||||
import type { EventLog } from "@opencode-ai/schema/event-log"
|
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 { KeyedMutex } from "./effect/keyed-mutex"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
export const RevertState = Revert.State
|
export const RevertState = Session.Revert
|
||||||
export type RevertState = Revert.State
|
export type RevertState = Session.Revert
|
||||||
|
|
||||||
// get project -> project.locations
|
// get project -> project.locations
|
||||||
//
|
//
|
||||||
|
|
@ -136,7 +137,7 @@ export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.Bus
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
}) {}
|
}) {}
|
||||||
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
|
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
|
||||||
skill: Schema.String,
|
skill: SkillV2.ID,
|
||||||
}) {}
|
}) {}
|
||||||
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
|
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
|
||||||
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
|
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
|
||||||
|
|
@ -170,14 +171,14 @@ export interface Interface {
|
||||||
id: SessionMessage.ID
|
id: SessionMessage.ID
|
||||||
direction: "previous" | "next"
|
direction: "previous" | "next"
|
||||||
}
|
}
|
||||||
}) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
|
}) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
|
||||||
readonly message: (input: {
|
readonly message: (input: {
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
messageID: SessionMessage.ID
|
messageID: SessionMessage.ID
|
||||||
}) => Effect.Effect<SessionMessage.Message | undefined>
|
}) => Effect.Effect<SessionMessage.Info | undefined>
|
||||||
readonly context: (
|
readonly context: (
|
||||||
sessionID: SessionSchema.ID,
|
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
|
* Durable, ordered, gap-free session log read. Replays public durable
|
||||||
* session events after the exclusive `after` cursor, emits a `Synced`
|
* session events after the exclusive `after` cursor, emits a `Synced`
|
||||||
|
|
@ -191,7 +192,10 @@ export interface Interface {
|
||||||
after?: number
|
after?: number
|
||||||
follow?: boolean
|
follow?: boolean
|
||||||
}) => Stream.Stream<SessionEvent.DurableEvent | EventLog.Synced, NotFoundError>
|
}) => 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: {
|
readonly switchModel: (input: {
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
model: ModelV2.Ref
|
model: ModelV2.Ref
|
||||||
|
|
@ -209,7 +213,7 @@ export interface Interface {
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
command: string
|
command: string
|
||||||
arguments?: string
|
arguments?: string
|
||||||
agent?: string
|
agent?: AgentV2.ID
|
||||||
model?: ModelV2.Ref
|
model?: ModelV2.Ref
|
||||||
files?: PromptInput.Prompt["files"]
|
files?: PromptInput.Prompt["files"]
|
||||||
agents?: PromptInput.Prompt["agents"]
|
agents?: PromptInput.Prompt["agents"]
|
||||||
|
|
@ -227,7 +231,7 @@ export interface Interface {
|
||||||
readonly skill: (input: {
|
readonly skill: (input: {
|
||||||
id?: SessionMessage.ID
|
id?: SessionMessage.ID
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
skill: string
|
skill: SkillV2.ID
|
||||||
resume?: boolean
|
resume?: boolean
|
||||||
}) => Effect.Effect<void, NotFoundError | SkillNotFoundError>
|
}) => Effect.Effect<void, NotFoundError | SkillNotFoundError>
|
||||||
readonly compact: (
|
readonly compact: (
|
||||||
|
|
@ -250,7 +254,7 @@ export interface Interface {
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
messageID: SessionMessage.ID
|
messageID: SessionMessage.ID
|
||||||
files?: boolean
|
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 clear: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError | Snapshot.Error>
|
||||||
readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError>
|
readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError>
|
||||||
}
|
}
|
||||||
|
|
@ -273,7 +277,7 @@ const layer = Layer.effect(
|
||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
const activeShells = new Set<SessionSchema.ID>()
|
const activeShells = new Set<SessionSchema.ID>()
|
||||||
const shellLocks = KeyedMutex.makeUnsafe<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 isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||||
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||||
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
|
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||||
|
|
@ -322,7 +326,7 @@ const layer = Layer.effect(
|
||||||
variant: input.model.variant,
|
variant: input.model.variant,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
cost: 0,
|
cost: Money.USD.zero,
|
||||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
time: { created: now, updated: now },
|
time: { created: now, updated: now },
|
||||||
})
|
})
|
||||||
|
|
@ -529,7 +533,7 @@ const layer = Layer.effect(
|
||||||
})
|
})
|
||||||
const model = command.model ?? commandAgent?.model ?? input.model
|
const model = command.model ?? commandAgent?.model ?? input.model
|
||||||
if (agent !== undefined && session.agent !== AgentV2.ID.make(agent))
|
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 })
|
if (model !== undefined) yield* result.switchModel({ sessionID: input.sessionID, model })
|
||||||
|
|
||||||
return yield* result.prompt({
|
return yield* result.prompt({
|
||||||
|
|
@ -591,12 +595,13 @@ const layer = Layer.effect(
|
||||||
skill: Effect.fn("V2Session.skill")(function* (input) {
|
skill: Effect.fn("V2Session.skill")(function* (input) {
|
||||||
const session = yield* result.get(input.sessionID)
|
const session = yield* result.get(input.sessionID)
|
||||||
const skills = yield* SkillV2.Service.pipe(Effect.provide(locations.get(session.location)))
|
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 })
|
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
|
||||||
yield* events.publish(
|
yield* events.publish(
|
||||||
SessionEvent.Skill.Activated,
|
SessionEvent.Skill.Activated,
|
||||||
{
|
{
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
|
id: skill.id,
|
||||||
name: skill.name,
|
name: skill.name,
|
||||||
text: skill.content,
|
text: skill.content,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -64,19 +64,21 @@ type Dependencies = {
|
||||||
|
|
||||||
export type AutoInput = {
|
export type AutoInput = {
|
||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
readonly messages: readonly SessionMessage.Message[]
|
readonly messages: readonly SessionMessage.Info[]
|
||||||
readonly request: LLMRequest
|
readonly request: LLMRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
type CompactInput = {
|
type CompactInput = {
|
||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
readonly messages: readonly SessionMessage.Message[]
|
readonly messages: readonly SessionMessage.Info[]
|
||||||
readonly model: Model
|
readonly model: Model
|
||||||
|
readonly inputID?: SessionMessage.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ManualInput = {
|
export type ManualInput = {
|
||||||
readonly session: SessionSchema.Info
|
readonly session: SessionSchema.Info
|
||||||
readonly messages: readonly SessionMessage.Message[]
|
readonly messages: readonly SessionMessage.Info[]
|
||||||
|
readonly inputID: SessionMessage.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
|
|
@ -99,7 +101,7 @@ export const serializeToolContent = (content: SessionMessage.ToolStateCompleted[
|
||||||
)
|
)
|
||||||
.join("\n")
|
.join("\n")
|
||||||
|
|
||||||
const serialize = (message: SessionMessage.Message) => {
|
const serialize = (message: SessionMessage.Info) => {
|
||||||
if (message.type === "user") {
|
if (message.type === "user") {
|
||||||
const files =
|
const files =
|
||||||
message.files?.map(
|
message.files?.map(
|
||||||
|
|
@ -128,7 +130,7 @@ const serialize = (message: SessionMessage.Message) => {
|
||||||
if (message.type === "system") return `[System update]: ${message.text}`
|
if (message.type === "system") return `[System update]: ${message.text}`
|
||||||
if (message.type === "synthetic") return `[Synthetic context]: ${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 === "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 ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,7 +149,7 @@ const settings = (documents: readonly Config.Entry[]) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const select = (
|
const select = (
|
||||||
messages: readonly SessionMessage.Message[],
|
messages: readonly SessionMessage.Info[],
|
||||||
tokens: number,
|
tokens: number,
|
||||||
): { readonly head: string; readonly recent: string } | undefined => {
|
): { readonly head: string; readonly recent: string } | undefined => {
|
||||||
const conversation = messages
|
const conversation = messages
|
||||||
|
|
@ -198,6 +200,7 @@ const make = (dependencies: Dependencies) => {
|
||||||
readonly context: readonly string[]
|
readonly context: readonly string[]
|
||||||
readonly recent: string
|
readonly recent: string
|
||||||
readonly output?: number
|
readonly output?: number
|
||||||
|
readonly inputID?: SessionMessage.ID
|
||||||
}) {
|
}) {
|
||||||
const context = input.model.route.defaults.limits?.context
|
const context = input.model.route.defaults.limits?.context
|
||||||
if (context === undefined || context <= 0) return false
|
if (context === undefined || context <= 0) return false
|
||||||
|
|
@ -208,6 +211,8 @@ const make = (dependencies: Dependencies) => {
|
||||||
yield* dependencies.events.publish(SessionEvent.Compaction.Started, {
|
yield* dependencies.events.publish(SessionEvent.Compaction.Started, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
reason: input.reason,
|
reason: input.reason,
|
||||||
|
recent: input.recent,
|
||||||
|
inputID: input.inputID,
|
||||||
})
|
})
|
||||||
|
|
||||||
const chunks: string[] = []
|
const chunks: string[] = []
|
||||||
|
|
@ -235,9 +240,27 @@ const make = (dependencies: Dependencies) => {
|
||||||
}),
|
}),
|
||||||
Effect.as(true),
|
Effect.as(true),
|
||||||
Effect.catchTag("LLM.Error", () => Effect.succeed(false)),
|
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("")
|
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, {
|
yield* dependencies.events.publish(SessionEvent.Compaction.Ended, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
reason: input.reason,
|
reason: input.reason,
|
||||||
|
|
@ -284,6 +307,7 @@ const make = (dependencies: Dependencies) => {
|
||||||
),
|
),
|
||||||
recent: forcedShortContext ? "" : selected.recent,
|
recent: forcedShortContext ? "" : selected.recent,
|
||||||
output: input.output,
|
output: input.output,
|
||||||
|
inputID: input.inputID,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: CompactInput) {
|
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: CompactInput) {
|
||||||
|
|
@ -327,6 +351,7 @@ export const layer = Layer.effect(
|
||||||
sessionID: input.session.id,
|
sessionID: input.session.id,
|
||||||
messages: input.messages,
|
messages: input.messages,
|
||||||
model: resolved.model,
|
model: resolved.model,
|
||||||
|
inputID: input.inputID,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { InstructionCheckpointTable, SessionMessageTable } from "./sql"
|
||||||
|
|
||||||
type DatabaseService = Database.Interface["db"]
|
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) {
|
export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||||
return yield* db
|
return yield* db
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { DateTime } from "effect"
|
import { DateTime, Schema } from "effect"
|
||||||
import { AgentV2 } from "../agent"
|
import { AgentV2 } from "../agent"
|
||||||
import { Location } from "../location"
|
import { Location } from "../location"
|
||||||
import { ModelV2 } from "../model"
|
import { ModelV2 } from "../model"
|
||||||
|
|
@ -9,7 +9,10 @@ import { WorkspaceV2 } from "../workspace"
|
||||||
import { SessionSchema } from "./schema"
|
import { SessionSchema } from "./schema"
|
||||||
import { SessionTable } from "./sql"
|
import { SessionTable } from "./sql"
|
||||||
import { SessionMessage } from "./message"
|
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 {
|
export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.Info {
|
||||||
return SessionSchema.Info.make({
|
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"),
|
variant: ModelV2.VariantID.make(row.model.variant ?? "default"),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
cost: row.cost,
|
cost: Money.USD.make(row.cost),
|
||||||
tokens: {
|
tokens: {
|
||||||
input: row.tokens_input,
|
input: row.tokens_input,
|
||||||
output: row.tokens_output,
|
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,
|
workspaceID: row.workspace_id ? WorkspaceV2.ID.make(row.workspace_id) : undefined,
|
||||||
}),
|
}),
|
||||||
subpath: row.path ? RelativePath.make(row.path) : 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: {
|
time: {
|
||||||
created: DateTime.makeUnsafe(row.time_created),
|
created: DateTime.makeUnsafe(row.time_created),
|
||||||
updated: DateTime.makeUnsafe(row.time_updated),
|
updated: DateTime.makeUnsafe(row.time_updated),
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ export * as SessionInput from "./input"
|
||||||
|
|
||||||
import { and, asc, eq, isNull } from "drizzle-orm"
|
import { and, asc, eq, isNull } from "drizzle-orm"
|
||||||
import { DateTime, Effect, Schema } from "effect"
|
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 { Database } from "../database/database"
|
||||||
import type { EventV2 } from "../event"
|
import type { EventV2 } from "../event"
|
||||||
import { KeyedMutex } from "../effect/keyed-mutex"
|
import { KeyedMutex } from "../effect/keyed-mutex"
|
||||||
|
|
@ -14,7 +14,7 @@ import { SessionInputTable, SessionMessageTable } from "./sql"
|
||||||
|
|
||||||
type DatabaseService = Database.Interface["db"]
|
type DatabaseService = Database.Interface["db"]
|
||||||
|
|
||||||
export { Admitted, Compaction, Delivery, Entry, PromptEntry }
|
export { Admitted, Compaction, Delivery, Info, PromptEntry }
|
||||||
|
|
||||||
const decodePrompt = Schema.decodeUnknownSync(Prompt)
|
const decodePrompt = Schema.decodeUnknownSync(Prompt)
|
||||||
const encodePrompt = Schema.encodeSync(Prompt)
|
const encodePrompt = Schema.encodeSync(Prompt)
|
||||||
|
|
@ -24,7 +24,7 @@ export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict
|
||||||
id: SessionMessage.ID,
|
id: SessionMessage.ID,
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
const fromRow = (row: typeof SessionInputTable.$inferSelect): Entry => {
|
const fromRow = (row: typeof SessionInputTable.$inferSelect): Info => {
|
||||||
const base = {
|
const base = {
|
||||||
admittedSeq: row.admitted_seq,
|
admittedSeq: row.admitted_seq,
|
||||||
id: SessionMessage.ID.make(row.id),
|
id: SessionMessage.ID.make(row.id),
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { SessionEvent } from "./event"
|
||||||
import { SessionMessage } from "./message"
|
import { SessionMessage } from "./message"
|
||||||
|
|
||||||
export type MemoryState = {
|
export type MemoryState = {
|
||||||
messages: SessionMessage.Message[]
|
messages: SessionMessage.Info[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Adapter {
|
export interface Adapter {
|
||||||
|
|
@ -14,13 +14,13 @@ export interface Adapter {
|
||||||
messageID: SessionMessage.ID,
|
messageID: SessionMessage.ID,
|
||||||
) => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
|
) => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
|
||||||
readonly getShell: (
|
readonly getShell: (
|
||||||
shellID: SessionMessage.Shell["shell"]["id"],
|
shellID: SessionMessage.Shell["shellID"],
|
||||||
) => Effect.Effect<SessionMessage.Shell | undefined, never, never>
|
) => Effect.Effect<SessionMessage.Shell | undefined, never, never>
|
||||||
readonly getCompaction: () => Effect.Effect<SessionMessage.Compaction | undefined, never, never>
|
readonly getCompaction: () => Effect.Effect<SessionMessage.Compaction | undefined, never, never>
|
||||||
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void, never, never>
|
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void, never, never>
|
||||||
readonly updateShell: (shell: SessionMessage.Shell) => 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 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 {
|
export function memory(state: MemoryState): Adapter {
|
||||||
|
|
@ -29,9 +29,7 @@ export function memory(state: MemoryState): Adapter {
|
||||||
const shellIndex = (messageID: SessionMessage.ID) =>
|
const shellIndex = (messageID: SessionMessage.ID) =>
|
||||||
state.messages.findLastIndex((message) => message.id === messageID)
|
state.messages.findLastIndex((message) => message.id === messageID)
|
||||||
const compactionIndex = () =>
|
const compactionIndex = () =>
|
||||||
state.messages.findLastIndex(
|
state.messages.findLastIndex((message) => message.type === "compaction" && message.status === "running")
|
||||||
(message) => message.type === "compaction" && (message.status === "queued" || message.status === "running"),
|
|
||||||
)
|
|
||||||
// A newer step supersedes stale incomplete rows; never resume an older assistant projection.
|
// A newer step supersedes stale incomplete rows; never resume an older assistant projection.
|
||||||
const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant")
|
const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant")
|
||||||
|
|
||||||
|
|
@ -64,7 +62,7 @@ export function memory(state: MemoryState): Adapter {
|
||||||
getShell(shellID) {
|
getShell(shellID) {
|
||||||
return Effect.sync(() => {
|
return Effect.sync(() => {
|
||||||
return state.messages.find((message): message is SessionMessage.Shell => {
|
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),
|
id: SessionMessage.ID.fromEvent(event.id),
|
||||||
type: "system",
|
type: "system",
|
||||||
text: event.data.text,
|
text: event.data.text,
|
||||||
|
metadata: event.metadata,
|
||||||
time: { created: event.created },
|
time: { created: event.created },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
"session.synthetic": (event) => {
|
"session.synthetic": (event) => {
|
||||||
return adapter.appendMessage(
|
return adapter.appendMessage(
|
||||||
SessionMessage.Synthetic.make({
|
SessionMessage.Synthetic.make({
|
||||||
sessionID: event.data.sessionID,
|
|
||||||
text: event.data.text,
|
text: event.data.text,
|
||||||
description: event.data.description,
|
description: event.data.description,
|
||||||
metadata: event.data.metadata,
|
metadata: event.data.metadata,
|
||||||
|
|
@ -207,8 +205,10 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||||
SessionMessage.Skill.make({
|
SessionMessage.Skill.make({
|
||||||
id: SessionMessage.ID.fromEvent(event.id),
|
id: SessionMessage.ID.fromEvent(event.id),
|
||||||
type: "skill",
|
type: "skill",
|
||||||
|
skill: event.data.id,
|
||||||
name: event.data.name,
|
name: event.data.name,
|
||||||
text: event.data.text,
|
text: event.data.text,
|
||||||
|
metadata: event.metadata,
|
||||||
time: { created: event.created },
|
time: { created: event.created },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -219,7 +219,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||||
id: SessionMessage.ID.fromEvent(event.id),
|
id: SessionMessage.ID.fromEvent(event.id),
|
||||||
type: "shell",
|
type: "shell",
|
||||||
metadata: event.metadata,
|
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 },
|
time: { created: event.created },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -230,7 +232,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||||
if (currentShell) {
|
if (currentShell) {
|
||||||
yield* adapter.updateShell(
|
yield* adapter.updateShell(
|
||||||
produce(currentShell, (draft) => {
|
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.output = event.data.output
|
||||||
draft.time.completed = event.created
|
draft.time.completed = event.created
|
||||||
}),
|
}),
|
||||||
|
|
@ -270,6 +273,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
agent: event.data.agent,
|
agent: event.data.agent,
|
||||||
model: event.data.model,
|
model: event.data.model,
|
||||||
|
metadata: event.metadata,
|
||||||
time: { created: event.created },
|
time: { created: event.created },
|
||||||
content: [],
|
content: [],
|
||||||
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
|
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,
|
id: event.data.callID,
|
||||||
name: event.data.name,
|
name: event.data.name,
|
||||||
time: { created: event.created },
|
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) => {
|
"session.tool.input.ended": (event) => {
|
||||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||||
const match = latestTool(draft, event.data.callID)
|
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) => {
|
"session.tool.called": (event) => {
|
||||||
|
|
@ -382,7 +386,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||||
input: match.state.input,
|
input: match.state.input,
|
||||||
structured: event.data.structured,
|
structured: event.data.structured,
|
||||||
content: [...event.data.content],
|
content: [...event.data.content],
|
||||||
outputPaths: event.data.outputPaths ? [...event.data.outputPaths] : [],
|
|
||||||
result: event.data.result,
|
result: event.data.result,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -392,7 +395,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||||
"session.tool.failed": (event) => {
|
"session.tool.failed": (event) => {
|
||||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||||
const match = latestTool(draft, event.data.callID)
|
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.executed = event.data.executed || match.executed === true
|
||||||
match.providerResultState = event.data.resultState
|
match.providerResultState = event.data.resultState
|
||||||
match.time.completed = event.created
|
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(
|
adapter.appendMessage(
|
||||||
SessionMessage.Compaction.make({
|
SessionMessage.CompactionRunning.make({
|
||||||
id: event.data.inputID,
|
id: event.data.inputID ?? SessionMessage.ID.fromEvent(event.id),
|
||||||
type: "compaction",
|
type: "compaction",
|
||||||
status: "queued",
|
status: "running",
|
||||||
metadata: event.metadata,
|
metadata: event.metadata,
|
||||||
reason: "manual",
|
reason: event.data.reason,
|
||||||
summary: "",
|
summary: "",
|
||||||
recent: "",
|
recent: event.data.recent ?? "",
|
||||||
time: { created: event.created },
|
time: { created: event.created },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
"session.compaction.started": (event) =>
|
"session.compaction.delta": (event) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (event.data.reason !== "manual") return
|
|
||||||
const current = yield* adapter.getCompaction()
|
const current = yield* adapter.getCompaction()
|
||||||
if (!current) return
|
if (current?.status !== "running") return
|
||||||
yield* adapter.updateCompaction({ ...current, status: "running" })
|
yield* adapter.updateCompaction({ ...current, summary: current.summary + event.data.text })
|
||||||
}),
|
}),
|
||||||
"session.compaction.delta": () => Effect.void,
|
|
||||||
"session.compaction.ended": (event) => {
|
"session.compaction.ended": (event) => {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const current = event.data.reason === "manual" ? yield* adapter.getCompaction() : undefined
|
const current = yield* adapter.getCompaction()
|
||||||
if (current) {
|
if (current?.status === "running") {
|
||||||
yield* adapter.updateCompaction({
|
yield* adapter.updateCompaction({
|
||||||
...current,
|
...current,
|
||||||
status: "completed",
|
status: "completed",
|
||||||
|
|
@ -496,11 +498,20 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
"session.compaction.failed": () =>
|
"session.compaction.failed": (event) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const current = yield* adapter.getCompaction()
|
const current = yield* adapter.getCompaction()
|
||||||
if (!current) return
|
const failed = SessionMessage.CompactionFailed.make({
|
||||||
yield* adapter.updateCompaction({ ...current, status: "failed" })
|
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.staged": () => Effect.void,
|
||||||
"session.revert.cleared": () => Effect.void,
|
"session.revert.cleared": () => Effect.void,
|
||||||
|
|
|
||||||
|
|
@ -24,15 +24,14 @@ import {
|
||||||
} from "./sql"
|
} from "./sql"
|
||||||
import type { DeepMutable } from "../schema"
|
import type { DeepMutable } from "../schema"
|
||||||
import { Slug } from "../util/slug"
|
import { Slug } from "../util/slug"
|
||||||
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
|
|
||||||
type DatabaseService = Database.Interface["db"]
|
type DatabaseService = Database.Interface["db"]
|
||||||
type MessageEvent = Exclude<
|
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
|
||||||
SessionEvent.DurableEvent,
|
type MessageEvent = Exclude<CurrentDurableEvent, typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type>
|
||||||
typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type
|
|
||||||
>
|
|
||||||
|
|
||||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
|
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||||
const encodeMessage = Schema.encodeSync(SessionMessage.Message)
|
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
|
||||||
|
|
||||||
export class SessionAlreadyProjected extends Error {}
|
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_reasoning: (info.tokens ?? { reasoning: 0 }).reasoning,
|
||||||
tokens_cache_read: (info.tokens ?? { cache: { read: 0 } }).cache.read,
|
tokens_cache_read: (info.tokens ?? { cache: { read: 0 } }).cache.read,
|
||||||
tokens_cache_write: (info.tokens ?? { cache: { write: 0 } }).cache.write,
|
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,
|
permission: info.permission ? [...info.permission] : undefined,
|
||||||
time_created: info.time.created,
|
time_created: info.time.created,
|
||||||
time_updated: info.time.updated,
|
time_updated: info.time.updated,
|
||||||
|
|
@ -151,7 +157,7 @@ const publishSessionUsage = Effect.fn("SessionProjector.publishUsage")(function*
|
||||||
if (!row) return
|
if (!row) return
|
||||||
yield* events.publish(SessionEvent.UsageUpdated, {
|
yield* events.publish(SessionEvent.UsageUpdated, {
|
||||||
sessionID,
|
sessionID,
|
||||||
cost: row.cost,
|
cost: Money.USD.make(row.cost),
|
||||||
tokens: {
|
tokens: {
|
||||||
input: row.input,
|
input: row.input,
|
||||||
output: row.output,
|
output: row.output,
|
||||||
|
|
@ -257,7 +263,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||||
eq(SessionMessageTable.session_id, event.data.parentID),
|
eq(SessionMessageTable.session_id, event.data.parentID),
|
||||||
gt(SessionMessageTable.seq, cursor),
|
gt(SessionMessageTable.seq, cursor),
|
||||||
lt(SessionMessageTable.seq, copiedSeq + 1),
|
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))
|
.orderBy(asc(SessionMessageTable.seq))
|
||||||
|
|
@ -280,7 +286,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||||
seq: row.seq,
|
seq: row.seq,
|
||||||
time_created: row.time_created,
|
time_created: row.time_created,
|
||||||
time_updated: row.time_updated,
|
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* () {
|
return Effect.gen(function* () {
|
||||||
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
|
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||||
decodeMessage({ ...row.data, id: row.id, type: row.type })
|
decodeMessage({ ...row.data, id: row.id, type: row.type })
|
||||||
const updateMessage = (message: SessionMessage.Message) => {
|
const updateMessage = (message: SessionMessage.Info) => {
|
||||||
if (event.durable === undefined)
|
if (event.durable === undefined)
|
||||||
return Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
return Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
||||||
const encoded = encodeMessage(message)
|
const encoded = encodeMessage(message)
|
||||||
|
|
@ -353,7 +359,7 @@ function run(db: DatabaseService, event: MessageEvent) {
|
||||||
.run()
|
.run()
|
||||||
.pipe(Effect.orDie)
|
.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 = {
|
const adapter: SessionMessageUpdater.Adapter = {
|
||||||
getModel() {
|
getModel() {
|
||||||
return db
|
return db
|
||||||
|
|
@ -412,7 +418,7 @@ function run(db: DatabaseService, event: MessageEvent) {
|
||||||
and(
|
and(
|
||||||
eq(SessionMessageTable.session_id, event.data.sessionID),
|
eq(SessionMessageTable.session_id, event.data.sessionID),
|
||||||
eq(SessionMessageTable.type, "shell"),
|
eq(SessionMessageTable.type, "shell"),
|
||||||
sql`json_extract(${SessionMessageTable.data}, '$.shell.id') = ${shellID}`,
|
sql`json_extract(${SessionMessageTable.data}, '$.shellID') = ${shellID}`,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.orderBy(desc(SessionMessageTable.seq))
|
.orderBy(desc(SessionMessageTable.seq))
|
||||||
|
|
@ -433,7 +439,7 @@ function run(db: DatabaseService, event: MessageEvent) {
|
||||||
and(
|
and(
|
||||||
eq(SessionMessageTable.session_id, event.data.sessionID),
|
eq(SessionMessageTable.session_id, event.data.sessionID),
|
||||||
eq(SessionMessageTable.type, "compaction"),
|
eq(SessionMessageTable.type, "compaction"),
|
||||||
sql`json_extract(${SessionMessageTable.data}, '$.status') in ('queued', 'running')`,
|
sql`json_extract(${SessionMessageTable.data}, '$.status') = 'running'`,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.orderBy(desc(SessionMessageTable.seq))
|
.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"))
|
if (event.durable === undefined) return Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
||||||
const encoded = encodeMessage(message)
|
const encoded = encodeMessage(message)
|
||||||
const { id, type, ...data } = encoded
|
const { id, type, ...data } = encoded
|
||||||
|
|
@ -661,14 +667,12 @@ const layer = Layer.effectDiscard(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (event.durable === undefined)
|
if (event.durable === undefined)
|
||||||
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
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,
|
admittedSeq: event.durable.seq,
|
||||||
id: event.data.inputID,
|
id: event.data.inputID,
|
||||||
sessionID: event.data.sessionID,
|
sessionID: event.data.sessionID,
|
||||||
timeCreated: event.created,
|
timeCreated: event.created,
|
||||||
})
|
})
|
||||||
if (admitted.id !== event.data.inputID) return
|
|
||||||
yield* run(db, event)
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* events.project(SessionEvent.Execution.Succeeded, (event) => 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.Execution.Interrupted, (event) => run(db, event))
|
||||||
yield* events.project(SessionEvent.InstructionsUpdated, (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.Synthetic, (event) => run(db, event))
|
||||||
yield* events.project(SessionEvent.Skill.Activated, (event) =>
|
yield* events.project(SessionEvent.Skill.Activated, (event) => run(db, 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.Shell.Started, (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.Shell.Ended, (event) => run(db, event))
|
||||||
yield* events.project(SessionEvent.Step.Started, (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)
|
yield* run(db, event)
|
||||||
if (event.durable === undefined)
|
if (event.durable === undefined)
|
||||||
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
||||||
yield* SessionInput.settleCompaction(db, {
|
if (event.data.reason === "manual")
|
||||||
sessionID: event.data.sessionID,
|
yield* SessionInput.settleCompaction(db, {
|
||||||
handledSeq: event.durable.seq,
|
sessionID: event.data.sessionID,
|
||||||
})
|
handledSeq: event.durable.seq,
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* events.project(SessionEvent.RevertEvent.Staged, (event) =>
|
yield* events.project(SessionEvent.RevertEvent.Staged, (event) =>
|
||||||
db
|
Effect.gen(function* () {
|
||||||
.update(SessionTable)
|
const revert = event.data.revert
|
||||||
.set({
|
yield* db
|
||||||
revert: { ...event.data.revert, files: event.data.revert.files ? [...event.data.revert.files] : undefined },
|
.update(SessionTable)
|
||||||
time_updated: DateTime.toEpochMillis(event.created),
|
.set({
|
||||||
})
|
revert: { ...revert, files: revert.files ? [...revert.files] : undefined },
|
||||||
.where(eq(SessionTable.id, event.data.sessionID))
|
time_updated: DateTime.toEpochMillis(event.created),
|
||||||
.run()
|
})
|
||||||
.pipe(Effect.orDie, Effect.asVoid),
|
.where(eq(SessionTable.id, event.data.sessionID))
|
||||||
|
.run()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
yield* events.project(SessionEvent.RevertEvent.Cleared, (event) =>
|
yield* events.project(SessionEvent.RevertEvent.Cleared, (event) =>
|
||||||
db
|
db
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
export * as SessionRevert from "./revert"
|
export * as SessionRevert from "./revert"
|
||||||
|
|
||||||
import { and, asc, eq, gt } from "drizzle-orm"
|
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 { Database } from "../database/database"
|
||||||
import { EventV2 } from "../event"
|
import { EventV2 } from "../event"
|
||||||
import { RelativePath } from "../schema"
|
import { RelativePath } from "../schema"
|
||||||
|
|
@ -46,7 +46,7 @@ const plan = Effect.fn("SessionRevert.plan")(function* (input: BoundaryInput) {
|
||||||
.orderBy(asc(SessionMessageTable.seq))
|
.orderBy(asc(SessionMessageTable.seq))
|
||||||
.all()
|
.all()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
|
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||||
const files = new Map<RelativePath, Snapshot.ID>()
|
const files = new Map<RelativePath, Snapshot.ID>()
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const message = yield* decode({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie)
|
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 next = yield* plan({ sessionID: input.session.id, messageID: input.messageID })
|
||||||
const restore = new Map<RelativePath, Snapshot.ID>()
|
const restore = new Map<RelativePath, Snapshot.ID>()
|
||||||
if (original) {
|
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 (input.files !== false) for (const [file, tree] of next) restore.set(file, tree)
|
||||||
if (restore.size) yield* snapshot.restore({ files: restore })
|
if (restore.size) yield* snapshot.restore({ files: restore })
|
||||||
|
|
@ -81,10 +81,6 @@ export const stage = Effect.fn("SessionRevert.stage")(function* (input: {
|
||||||
const revert = {
|
const revert = {
|
||||||
messageID: input.messageID,
|
messageID: input.messageID,
|
||||||
snapshot: original,
|
snapshot: original,
|
||||||
diff: files
|
|
||||||
.map((file) => file.patch)
|
|
||||||
.join("")
|
|
||||||
.trim(),
|
|
||||||
files,
|
files,
|
||||||
} satisfies SessionSchema.Info["revert"]
|
} satisfies SessionSchema.Info["revert"]
|
||||||
yield* events.publish(SessionEvent.RevertEvent.Staged, {
|
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
|
const original = session.revert.snapshot ? Snapshot.ID.make(session.revert.snapshot) : undefined
|
||||||
if (original)
|
if (original)
|
||||||
yield* snapshot.restore({
|
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
|
const events = yield* EventV2.Service
|
||||||
yield* events.publish(SessionEvent.RevertEvent.Cleared, {
|
yield* events.publish(SessionEvent.RevertEvent.Cleared, {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
type ProviderErrorEvent,
|
type ProviderErrorEvent,
|
||||||
} from "@opencode-ai/llm"
|
} from "@opencode-ai/llm"
|
||||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
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 { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
||||||
import { AgentV2 } from "../../agent"
|
import { AgentV2 } from "../../agent"
|
||||||
import { Config } from "../../config"
|
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)
|
.filter((cost) => cost.tier?.type === "context" && context > cost.tier.size)
|
||||||
.toSorted((a, b) => (b.tier?.size ?? 0) - (a.tier?.size ?? 0))[0]
|
.toSorted((a, b) => (b.tier?.size ?? 0) - (a.tier?.size ?? 0))[0]
|
||||||
const cost = tier ?? costs.find((cost) => cost.tier === undefined)
|
const cost = tier ?? costs.find((cost) => cost.tier === undefined)
|
||||||
if (!cost) return 0
|
if (!cost) return Money.USD.zero
|
||||||
return (
|
return Money.USD.make(
|
||||||
(tokens.input * cost.input +
|
(tokens.input * cost.input +
|
||||||
(tokens.output + tokens.reasoning) * cost.output +
|
(tokens.output + tokens.reasoning) * cost.output +
|
||||||
tokens.cache.read * cost.cache.read +
|
tokens.cache.read * cost.cache.read +
|
||||||
tokens.cache.write * cost.cache.write) /
|
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)) {
|
for (const message of yield* store.context(sessionID)) {
|
||||||
if (message.type !== "assistant") continue
|
if (message.type !== "assistant") continue
|
||||||
for (const tool of message.content) {
|
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, {
|
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||||
sessionID,
|
sessionID,
|
||||||
assistantMessageID: message.id,
|
assistantMessageID: message.id,
|
||||||
|
|
@ -300,8 +301,7 @@ const layer = Layer.effect(
|
||||||
// Durable publishes are serialized so tool fibers and step settlement never interleave
|
// Durable publishes are serialized so tool fibers and step settlement never interleave
|
||||||
// mid-event.
|
// mid-event.
|
||||||
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
|
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
|
||||||
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = [], error?: SessionError.Error) =>
|
const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
|
||||||
serialized(publisher.publish(event, outputPaths, error))
|
|
||||||
let overflowFailure: ProviderErrorEvent | undefined
|
let overflowFailure: ProviderErrorEvent | undefined
|
||||||
const providerStream = llm.stream(hookedRequest).pipe(
|
const providerStream = llm.stream(hookedRequest).pipe(
|
||||||
Stream.runForEach((event) =>
|
Stream.runForEach((event) =>
|
||||||
|
|
@ -359,7 +359,6 @@ const layer = Layer.effect(
|
||||||
result: settlement.result,
|
result: settlement.result,
|
||||||
output: settlement.output,
|
output: settlement.output,
|
||||||
}),
|
}),
|
||||||
settlement.outputPaths ?? [],
|
|
||||||
settlement.error,
|
settlement.error,
|
||||||
).pipe(
|
).pipe(
|
||||||
Effect.andThen(
|
Effect.andThen(
|
||||||
|
|
@ -599,12 +598,30 @@ const layer = Layer.effect(
|
||||||
return yield* compaction.compactManual({
|
return yield* compaction.compactManual({
|
||||||
session,
|
session,
|
||||||
messages: yield* store.context(sessionID),
|
messages: yield* store.context(sessionID),
|
||||||
|
inputID: pending.id,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
).pipe(Effect.exit)
|
).pipe(Effect.exit)
|
||||||
if (Exit.isSuccess(compacted) && compacted.value) return true
|
if (Exit.isSuccess(compacted) && compacted.value) return true
|
||||||
yield* events.publish(SessionEvent.Compaction.Failed, { sessionID })
|
if (Exit.isFailure(compacted)) {
|
||||||
if (Exit.isFailure(compacted)) 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: 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
|
return true
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,16 @@ import { SessionEvent } from "../event"
|
||||||
import { SessionMessage } from "../message"
|
import { SessionMessage } from "../message"
|
||||||
import { SessionSchema } from "../schema"
|
import { SessionSchema } from "../schema"
|
||||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
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 = {
|
type Input = {
|
||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
readonly agent: string
|
readonly agent: AgentV2.ID
|
||||||
readonly model: ModelV2.Ref
|
readonly model: ModelV2.Ref
|
||||||
readonly provider: string
|
readonly provider: string
|
||||||
readonly snapshot?: string
|
readonly snapshot?: Snapshot.ID
|
||||||
readonly assistantMessageID?: SessionMessage.ID
|
readonly assistantMessageID?: SessionMessage.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -226,7 +229,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||||
})
|
})
|
||||||
|
|
||||||
const publishStepFailure = Effect.fnUntraced(function* (usage?: {
|
const publishStepFailure = Effect.fnUntraced(function* (usage?: {
|
||||||
readonly cost: number
|
readonly cost: Money.USD
|
||||||
readonly tokens: ReturnType<typeof tokens>
|
readonly tokens: ReturnType<typeof tokens>
|
||||||
}) {
|
}) {
|
||||||
if (stepFailed || stepFailure === undefined) return
|
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}`))
|
return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(new Error(`Unknown tool call: ${callID}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (
|
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent, error?: SessionError.Error) {
|
||||||
event: LLMEvent,
|
|
||||||
outputPaths: ReadonlyArray<string> = [],
|
|
||||||
error?: SessionError.Error,
|
|
||||||
) {
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "step-start":
|
case "step-start":
|
||||||
yield* startAssistant()
|
yield* startAssistant()
|
||||||
|
|
@ -395,7 +394,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||||
assistantMessageID: tool.assistantMessageID,
|
assistantMessageID: tool.assistantMessageID,
|
||||||
callID: event.id,
|
callID: event.id,
|
||||||
...result,
|
...result,
|
||||||
outputPaths,
|
|
||||||
...(executed ? { result: event.result } : {}),
|
...(executed ? { result: event.result } : {}),
|
||||||
executed,
|
executed,
|
||||||
resultState,
|
resultState,
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ const providerMetadata = (
|
||||||
): ProviderMetadata | undefined => (state === undefined ? undefined : { [provider]: state })
|
): ProviderMetadata | undefined => (state === undefined ? undefined : { [provider]: state })
|
||||||
|
|
||||||
const toolInput = (tool: SessionMessage.AssistantTool) =>
|
const toolInput = (tool: SessionMessage.AssistantTool) =>
|
||||||
tool.state.status === "pending"
|
tool.state.status === "streaming"
|
||||||
? Option.getOrElse(decodeToolInput(tool.state.input), () => tool.state.input)
|
? Option.getOrElse(decodeToolInput(tool.state.input), () => 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) {
|
switch (message.type) {
|
||||||
case "agent-switched":
|
case "agent-switched":
|
||||||
case "model-switched":
|
case "model-switched":
|
||||||
|
|
@ -202,7 +202,7 @@ function toLLMMessage(message: SessionMessage.Message, model: ModelV2.Ref): Mess
|
||||||
Message.make({
|
Message.make({
|
||||||
id: message.id,
|
id: message.id,
|
||||||
role: "user",
|
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,
|
metadata: message.metadata,
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
|
|
@ -232,5 +232,5 @@ ${message.recent}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Translate projected V2 Session history into canonical @opencode-ai/llm context. */
|
/** 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))
|
messages.flatMap((message) => toLLMMessage(message, model))
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { ProjectTable } from "../project/sql"
|
||||||
import type { SessionMessage } from "./message"
|
import type { SessionMessage } from "./message"
|
||||||
import type { Prompt } from "@opencode-ai/schema/prompt"
|
import type { Prompt } from "@opencode-ai/schema/prompt"
|
||||||
import type { SessionInput } from "./input"
|
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 { PermissionV1 } from "../v1/permission"
|
||||||
import { ProjectV2 } from "../project"
|
import { ProjectV2 } from "../project"
|
||||||
import type { SessionSchema } from "./schema"
|
import type { SessionSchema } from "./schema"
|
||||||
|
|
@ -13,10 +13,11 @@ import type { MessageID, PartID, SessionV1 } from "../v1/session"
|
||||||
import { WorkspaceV2 } from "../workspace"
|
import { WorkspaceV2 } from "../workspace"
|
||||||
import { Timestamps } from "../database/schema.sql"
|
import { Timestamps } from "../database/schema.sql"
|
||||||
import type { Instructions } from "../instructions/index"
|
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"
|
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 V1MessageData = Omit<SessionV1.Info, "id" | "sessionID">
|
||||||
type V1PartData = Omit<SessionV1.Part, "id" | "sessionID" | "messageID">
|
type V1PartData = Omit<SessionV1.Part, "id" | "sessionID" | "messageID">
|
||||||
|
|
||||||
|
|
@ -41,7 +42,7 @@ export const SessionTable = sqliteTable(
|
||||||
summary_additions: integer(),
|
summary_additions: integer(),
|
||||||
summary_deletions: integer(),
|
summary_deletions: integer(),
|
||||||
summary_files: 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>>(),
|
metadata: text({ mode: "json" }).$type<Record<string, unknown>>(),
|
||||||
cost: real().notNull().default(0),
|
cost: real().notNull().default(0),
|
||||||
tokens_input: integer().notNull().default(0),
|
tokens_input: integer().notNull().default(0),
|
||||||
|
|
@ -49,7 +50,7 @@ export const SessionTable = sqliteTable(
|
||||||
tokens_reasoning: integer().notNull().default(0),
|
tokens_reasoning: integer().notNull().default(0),
|
||||||
tokens_cache_read: integer().notNull().default(0),
|
tokens_cache_read: integer().notNull().default(0),
|
||||||
tokens_cache_write: 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>(),
|
permission: text({ mode: "json" }).$type<PermissionV1.Ruleset>(),
|
||||||
agent: text(),
|
agent: text(),
|
||||||
model: text({ mode: "json" }).$type<{
|
model: text({ mode: "json" }).$type<{
|
||||||
|
|
@ -148,7 +149,7 @@ export const SessionInputTable = sqliteTable(
|
||||||
.$type<SessionSchema.ID>()
|
.$type<SessionSchema.ID>()
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
.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>(),
|
prompt: text({ mode: "json" }).$type<Prompt>(),
|
||||||
delivery: text().$type<SessionInput.Delivery>(),
|
delivery: text().$type<SessionInput.Delivery>(),
|
||||||
admitted_seq: integer().notNull(),
|
admitted_seq: integer().notNull(),
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,10 @@ import { fromRow } from "./info"
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly get: (sessionID: Session.ID) => Effect.Effect<Session.Info | undefined>
|
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: (
|
readonly message: (
|
||||||
messageID: SessionMessage.ID,
|
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") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionStore") {}
|
||||||
|
|
@ -25,7 +25,7 @@ const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
|
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
get: Effect.fn("SessionStore.get")(function* (sessionID) {
|
get: Effect.fn("SessionStore.get")(function* (sessionID) {
|
||||||
|
|
|
||||||
|
|
@ -28,11 +28,15 @@ export type Source = typeof Source.Type
|
||||||
|
|
||||||
export const Info = Skill.Info
|
export const Info = Skill.Info
|
||||||
export type 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 Event = Skill.Event
|
||||||
|
|
||||||
export const available = (skills: ReadonlyArray<Info>, agent: AgentV2.Info) =>
|
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({
|
const Frontmatter = Schema.Struct({
|
||||||
name: Schema.String.pipe(Schema.optional),
|
name: Schema.String.pipe(Schema.optional),
|
||||||
|
|
@ -96,7 +100,7 @@ const layer = Layer.effect(
|
||||||
source: Source.key(source),
|
source: Source.key(source),
|
||||||
type: source.type,
|
type: source.type,
|
||||||
directories: [],
|
directories: [],
|
||||||
skills: [source.skill.name],
|
skills: [source.skill.id],
|
||||||
})
|
})
|
||||||
return { skills: [source.skill], directories: [] }
|
return { skills: [source.skill], directories: [] }
|
||||||
}
|
}
|
||||||
|
|
@ -112,15 +116,13 @@ const layer = Layer.effect(
|
||||||
if (!markdown) continue
|
if (!markdown) continue
|
||||||
const frontmatter = decodeFrontmatter(markdown.data).valueOrUndefined
|
const frontmatter = decodeFrontmatter(markdown.data).valueOrUndefined
|
||||||
if (!frontmatter) continue
|
if (!frontmatter) continue
|
||||||
const name =
|
const id =
|
||||||
frontmatter.name !== undefined
|
path.dirname(filepath) === directory
|
||||||
? frontmatter.name
|
? path.basename(filepath, ".md")
|
||||||
: path.dirname(filepath) === directory
|
: path.basename(path.dirname(filepath))
|
||||||
? path.basename(filepath, ".md")
|
|
||||||
: undefined
|
|
||||||
if (!name) continue
|
|
||||||
skills.push({
|
skills.push({
|
||||||
name,
|
id: ID.make(id),
|
||||||
|
name: Name.make(frontmatter.name ?? id),
|
||||||
description: frontmatter.description,
|
description: frontmatter.description,
|
||||||
slash: metadataBoolean(frontmatter.metadata, "opencode/slash") ?? frontmatter.slash,
|
slash: metadataBoolean(frontmatter.metadata, "opencode/slash") ?? frontmatter.slash,
|
||||||
autoinvoke: metadataBoolean(frontmatter.metadata, "opencode/autoinvoke"),
|
autoinvoke: metadataBoolean(frontmatter.metadata, "opencode/autoinvoke"),
|
||||||
|
|
@ -133,7 +135,7 @@ const layer = Layer.effect(
|
||||||
source: Source.key(source),
|
source: Source.key(source),
|
||||||
type: source.type,
|
type: source.type,
|
||||||
directories,
|
directories,
|
||||||
skills: skills.map((skill) => skill.name),
|
skills: skills.map((skill) => skill.id),
|
||||||
})
|
})
|
||||||
return { skills, directories }
|
return { skills, directories }
|
||||||
})
|
})
|
||||||
|
|
@ -148,7 +150,7 @@ const layer = Layer.effect(
|
||||||
yield* Effect.logInfo("skill cache invalidated", {
|
yield* Effect.logInfo("skill cache invalidated", {
|
||||||
file,
|
file,
|
||||||
sources: invalidated.map(([key]) => key),
|
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)
|
yield* events.publish(Event.Updated, {}).pipe(Effect.asVoid)
|
||||||
})
|
})
|
||||||
|
|
@ -159,12 +161,12 @@ const layer = Layer.effect(
|
||||||
)
|
)
|
||||||
|
|
||||||
const list = Effect.fn("SkillV2.list")(function* () {
|
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) {
|
for (const source of state.get().sources) {
|
||||||
const key = Source.key(source)
|
const key = Source.key(source)
|
||||||
const loaded = cache.get(key) ?? (yield* load(source))
|
const loaded = cache.get(key) ?? (yield* load(source))
|
||||||
cache.set(key, loaded)
|
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())
|
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],
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@ import { SkillV2 } from "../skill"
|
||||||
import { Instructions } from "../instructions/index"
|
import { Instructions } from "../instructions/index"
|
||||||
|
|
||||||
const Summary = Schema.Struct({
|
const Summary = Schema.Struct({
|
||||||
name: Schema.String,
|
id: SkillV2.ID,
|
||||||
|
name: SkillV2.Name,
|
||||||
description: Schema.String,
|
description: Schema.String,
|
||||||
})
|
})
|
||||||
type Summary = typeof Summary.Type
|
type Summary = typeof Summary.Type
|
||||||
|
|
@ -16,6 +17,7 @@ type Summary = typeof Summary.Type
|
||||||
const entries = (skills: ReadonlyArray<Summary>) =>
|
const entries = (skills: ReadonlyArray<Summary>) =>
|
||||||
skills.flatMap((skill) => [
|
skills.flatMap((skill) => [
|
||||||
" <skill>",
|
" <skill>",
|
||||||
|
` <id>${skill.id}</id>`,
|
||||||
` <name>${skill.name}</name>`,
|
` <name>${skill.name}</name>`,
|
||||||
` <description>${skill.description}</description>`,
|
` <description>${skill.description}</description>`,
|
||||||
" </skill>",
|
" </skill>",
|
||||||
|
|
@ -34,8 +36,8 @@ const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary
|
||||||
const diff = Instructions.diffByKey(
|
const diff = Instructions.diffByKey(
|
||||||
previous,
|
previous,
|
||||||
current,
|
current,
|
||||||
(skill) => skill.name,
|
(skill) => skill.id,
|
||||||
(before, after) => before.description !== after.description,
|
(before, after) => before.name !== after.name || before.description !== after.description,
|
||||||
)
|
)
|
||||||
// Additions and removals render as small deltas; anything else restates the full list.
|
// 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))
|
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
|
...(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")
|
].join("\n")
|
||||||
}
|
}
|
||||||
|
|
@ -77,9 +79,9 @@ const layer = Layer.effect(
|
||||||
.flatMap((skill) =>
|
.flatMap((skill) =>
|
||||||
skill.description === undefined || skill.autoinvoke === false
|
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({
|
return Instructions.make({
|
||||||
key: Instructions.Key.make("core/skill-guidance"),
|
key: Instructions.Key.make("core/skill-guidance"),
|
||||||
codec: Schema.toCodecJson(Schema.Array(Summary)),
|
codec: Schema.toCodecJson(Schema.Array(Summary)),
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,10 @@ import { Git } from "./git"
|
||||||
import { Global } from "./global"
|
import { Global } from "./global"
|
||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { AbsolutePath, RelativePath } from "./schema"
|
import { AbsolutePath, RelativePath } from "./schema"
|
||||||
|
import { ID } from "@opencode-ai/schema/snapshot"
|
||||||
import { Hash } from "./util/hash"
|
import { Hash } from "./util/hash"
|
||||||
|
|
||||||
export const ID = Schema.String.pipe(Schema.brand("Snapshot.ID"))
|
export { ID }
|
||||||
export type ID = typeof ID.Type
|
|
||||||
|
|
||||||
export class Error extends Schema.TaggedErrorClass<Error>()("Snapshot.Error", {
|
export class Error extends Schema.TaggedErrorClass<Error>()("Snapshot.Error", {
|
||||||
operation: Schema.Literals(["capture", "files", "diff", "preview", "restore"]),
|
operation: Schema.Literals(["capture", "files", "diff", "preview", "restore"]),
|
||||||
|
|
@ -253,12 +253,3 @@ function failure(operation: Error["operation"], cause: unknown) {
|
||||||
cause,
|
cause,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Legacy persisted session diff shape. */
|
|
||||||
export type LegacyFileDiff = {
|
|
||||||
file?: string
|
|
||||||
patch?: string
|
|
||||||
additions: number
|
|
||||||
deletions: number
|
|
||||||
status?: "added" | "deleted" | "modified"
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,11 @@ export const name = "skill"
|
||||||
const FILE_LIMIT = 10
|
const FILE_LIMIT = 10
|
||||||
|
|
||||||
export const Input = Schema.Struct({
|
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({
|
export const Output = Schema.Struct({
|
||||||
name: Schema.String,
|
name: SkillV2.Name,
|
||||||
directory: Schema.String,
|
directory: Schema.String,
|
||||||
output: 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.",
|
"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")
|
].join("\n")
|
||||||
|
|
||||||
export const toModelOutput = (skill: SkillV2.Info, files: ReadonlyArray<string>) => {
|
export const toModelOutput = (skill: SkillV2.Info, files: ReadonlyArray<string>) => {
|
||||||
|
|
@ -70,13 +70,13 @@ export const Plugin = {
|
||||||
execute: (input, context) =>
|
execute: (input, context) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const current = yield* skills.list()
|
const current = yield* skills.list()
|
||||||
const skill = current.find((skill) => skill.name === input.name)
|
const skill = current.find((skill) => skill.id === input.id)
|
||||||
if (!skill) return yield* unableToLoad(input.name)
|
if (!skill) return yield* unableToLoad(input.id)
|
||||||
return yield* Effect.gen(function* () {
|
return yield* Effect.gen(function* () {
|
||||||
yield* permission.assert({
|
yield* permission.assert({
|
||||||
action: name,
|
action: name,
|
||||||
resources: [skill.name],
|
resources: [skill.id],
|
||||||
save: [skill.name],
|
save: [skill.id],
|
||||||
sessionID: context.sessionID,
|
sessionID: context.sessionID,
|
||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||||
|
|
@ -94,7 +94,7 @@ export const Plugin = {
|
||||||
directory,
|
directory,
|
||||||
output: toModelOutput(skill, files),
|
output: toModelOutput(skill, files),
|
||||||
}
|
}
|
||||||
}).pipe(Effect.mapError((error) => unableToLoad(input.name, error)))
|
}).pipe(Effect.mapError((error) => unableToLoad(input.id, error)))
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { Effect, Fiber, Layer, Stream } from "effect"
|
import { Effect, Fiber, Layer, Stream } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { Integration } from "@opencode-ai/core/integration"
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
|
|
@ -298,13 +299,31 @@ describe("CatalogV2", () => {
|
||||||
catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => {
|
catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => {
|
||||||
model.capabilities.input = ["text"]
|
model.capabilities.input = ["text"]
|
||||||
model.capabilities.output = ["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()
|
model.time.released = Date.now()
|
||||||
})
|
})
|
||||||
catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => {
|
catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => {
|
||||||
model.capabilities.input = ["text"]
|
model.capabilities.input = ["text"]
|
||||||
model.capabilities.output = ["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()
|
model.time.released = Date.now()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { describe, expect } from "bun:test"
|
||||||
import { Effect, PubSub, Schema, Stream } from "effect"
|
import { Effect, PubSub, Schema, Stream } from "effect"
|
||||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||||
import { CommandV2 } from "@opencode-ai/core/command"
|
import { CommandV2 } from "@opencode-ai/core/command"
|
||||||
|
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||||
import { Config } from "@opencode-ai/core/config"
|
import { Config } from "@opencode-ai/core/config"
|
||||||
import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
|
import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
|
|
@ -87,7 +88,7 @@ Review files`,
|
||||||
name: "review",
|
name: "review",
|
||||||
template: "Review files",
|
template: "Review files",
|
||||||
description: "File review",
|
description: "File review",
|
||||||
agent: "reviewer",
|
agent: AgentV2.ID.make("reviewer"),
|
||||||
model: {
|
model: {
|
||||||
providerID: ProviderV2.ID.make("anthropic"),
|
providerID: ProviderV2.ID.make("anthropic"),
|
||||||
id: ModelV2.ID.make("claude"),
|
id: ModelV2.ID.make("claude"),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { Config } from "@opencode-ai/core/config"
|
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.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
|
||||||
expect(model.enabled).toBe(false)
|
expect(model.enabled).toBe(false)
|
||||||
expect(model.limit).toEqual({ context: 100, output: 75 })
|
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.settings).toEqual({ baseURL: "https://example.test", retained: true })
|
||||||
expect(model.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
expect(model.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||||
expect(model.variants?.map((variant) => variant.id)).toEqual([
|
expect(model.variants?.map((variant) => variant.id)).toEqual([
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { fileURLToPath } from "url"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { SqliteClient } from "@effect/sql-sqlite-bun"
|
import { SqliteClient } from "@effect/sql-sqlite-bun"
|
||||||
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
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 { eq, inArray, sql } from "drizzle-orm"
|
||||||
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
|
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
|
||||||
import { migrations } from "@opencode-ai/core/database/migration.gen"
|
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 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 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 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 renameInstructionsMigration from "@opencode-ai/core/database/migration/20260705180000_rename_instructions"
|
||||||
import addSessionForkMigration from "@opencode-ai/core/database/migration/20260706223930_add-session-fork"
|
import addSessionForkMigration from "@opencode-ai/core/database/migration/20260706223930_add-session-fork"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
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 { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { SessionSchema } from "@opencode-ai/core/session/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 { SessionTable } from "@opencode-ai/core/session/sql"
|
||||||
import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata"
|
import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata"
|
||||||
import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
|
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()
|
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
|
||||||
|
|
||||||
describe("DatabaseMigration", () => {
|
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 () => {
|
test("resets incompatible V2 Session event history", async () => {
|
||||||
await run(
|
await run(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,7 @@ describe("Git trees", () => {
|
||||||
RelativePath.make("scope/tracked.txt"),
|
RelativePath.make("scope/tracked.txt"),
|
||||||
])
|
])
|
||||||
const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 1 })
|
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/added.txt"), "added"],
|
||||||
[RelativePath.make("scope/tracked.txt"), "modified"],
|
[RelativePath.make("scope/tracked.txt"), "modified"],
|
||||||
])
|
])
|
||||||
|
|
@ -154,7 +154,7 @@ describe("Git trees", () => {
|
||||||
const files = new Map([[RelativePath.make("scope/tracked.txt"), before]])
|
const files = new Map([[RelativePath.make("scope/tracked.txt"), before]])
|
||||||
const preview = yield* git.tree.preview({ repository, current: after, files, context: 1 })
|
const preview = yield* git.tree.preview({ repository, current: after, files, context: 1 })
|
||||||
expect(preview).toHaveLength(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 })
|
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", "tracked.txt"))).toBe("one\n")
|
||||||
expect(yield* read(path.join(root.path, "scope", "added.txt"))).toBe("added\n")
|
expect(yield* read(path.join(root.path, "scope", "added.txt"))).toBe("added\n")
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import path from "path"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Config } from "@opencode-ai/schema/config"
|
import { Config } from "@opencode-ai/schema/config"
|
||||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
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 { Context, DateTime, Effect, Equal, Hash, RcMap, Schema, Stream } from "effect"
|
||||||
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
|
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
|
||||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||||
|
|
@ -356,7 +357,7 @@ describe("LocationServiceMap", () => {
|
||||||
id: ModelV2.ID.make("chat"),
|
id: ModelV2.ID.make("chat"),
|
||||||
providerID: ProviderV2.ID.make("unavailable"),
|
providerID: ProviderV2.ID.make("unavailable"),
|
||||||
},
|
},
|
||||||
cost: 0,
|
cost: Money.USD.zero,
|
||||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||||
location,
|
location,
|
||||||
|
|
@ -405,7 +406,7 @@ describe("LocationServiceMap", () => {
|
||||||
providerID: ProviderV2.ID.make("aliased"),
|
providerID: ProviderV2.ID.make("aliased"),
|
||||||
variant: ModelV2.VariantID.make("high"),
|
variant: ModelV2.VariantID.make("high"),
|
||||||
},
|
},
|
||||||
cost: 0,
|
cost: Money.USD.zero,
|
||||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||||
location,
|
location,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { Integration } from "@opencode-ai/core/integration"
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
|
|
@ -51,23 +52,31 @@ describe("ModelsDevPlugin", () => {
|
||||||
temperature: true,
|
temperature: true,
|
||||||
tool_call: true,
|
tool_call: true,
|
||||||
cost: {
|
cost: {
|
||||||
input: 2.5,
|
input: Money.USDPerMillionTokens.make(2.5),
|
||||||
output: 15,
|
output: Money.USDPerMillionTokens.make(15),
|
||||||
tiers: [
|
tiers: [
|
||||||
{
|
{
|
||||||
tier: { type: "context", size: 272_000 },
|
tier: { type: "context", size: 272_000 },
|
||||||
input: 3,
|
input: Money.USDPerMillionTokens.make(3),
|
||||||
output: 18,
|
output: Money.USDPerMillionTokens.make(18),
|
||||||
cache_read: 0.25,
|
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 },
|
limit: { context: 1_050_000, input: 922_000, output: 128_000 },
|
||||||
experimental: {
|
experimental: {
|
||||||
modes: {
|
modes: {
|
||||||
fast: {
|
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: {
|
provider: {
|
||||||
headers: { "x-mode": "fast" },
|
headers: { "x-mode": "fast" },
|
||||||
body: { service_tier: "priority" },
|
body: { service_tier: "priority" },
|
||||||
|
|
@ -107,18 +116,31 @@ describe("ModelsDevPlugin", () => {
|
||||||
variants: [],
|
variants: [],
|
||||||
})
|
})
|
||||||
expect(fast?.cost).toEqual([
|
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 },
|
tier: { type: "context", size: 272_000 },
|
||||||
input: 3,
|
input: Money.USDPerMillionTokens.make(3),
|
||||||
output: 18,
|
output: Money.USDPerMillionTokens.make(18),
|
||||||
cache: { read: 0.25, write: 0 },
|
cache: {
|
||||||
|
read: Money.USDPerMillionTokens.make(0.25),
|
||||||
|
write: Money.USDPerMillionTokens.zero,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tier: { type: "context", size: 200_000 },
|
tier: { type: "context", size: 200_000 },
|
||||||
input: 5,
|
input: Money.USDPerMillionTokens.make(5),
|
||||||
output: 22.5,
|
output: Money.USDPerMillionTokens.make(22.5),
|
||||||
cache: { read: 0.5, write: 0 },
|
cache: {
|
||||||
|
read: Money.USDPerMillionTokens.make(0.5),
|
||||||
|
write: Money.USDPerMillionTokens.zero,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||||
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
|
|
@ -185,7 +186,16 @@ describe("OpenAIPlugin", () => {
|
||||||
draft.package = item.package
|
draft.package = item.package
|
||||||
})
|
})
|
||||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5"), (model) => {
|
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-5.5-pro"), () => {})
|
||||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-4.1"), () => {})
|
catalog.model.update(item.id, ModelV2.ID.make("gpt-4.1"), () => {})
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { Credential } from "@opencode-ai/core/credential"
|
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", () => {
|
describe("OpencodePlugin", () => {
|
||||||
it.effect("registers account and service account methods", () =>
|
it.effect("registers account and service account methods", () =>
|
||||||
|
|
|
||||||
|
|
@ -37,17 +37,19 @@ describe("SkillPlugin.Plugin", () => {
|
||||||
Effect.provide(NodeFileSystem.layer),
|
Effect.provide(NodeFileSystem.layer),
|
||||||
)
|
)
|
||||||
const skills = yield* skill.list()
|
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(skills).toContainEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
name: "opencode",
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
description: expect.stringContaining("any question about OpenCode itself"),
|
description: expect.stringContaining("any question about OpenCode itself"),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
expect(skills).toContainEqual(
|
expect(skills).toContainEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
name: "report",
|
id: "report",
|
||||||
|
name: "Report",
|
||||||
description: expect.stringContaining("opencode issue"),
|
description: expect.stringContaining("opencode issue"),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { SessionV2 } from "@opencode-ai/core/session"
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
|
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
|
||||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
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 { SessionMessage } from "@opencode-ai/core/session/message"
|
||||||
import { Prompt } from "@opencode-ai/schema/prompt"
|
import { Prompt } from "@opencode-ai/schema/prompt"
|
||||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||||
|
|
@ -104,13 +105,10 @@ describe("SessionV2.compact", () => {
|
||||||
|
|
||||||
expect(second.id).toBe(first.id)
|
expect(second.id).toBe(first.id)
|
||||||
expect(requests).toHaveLength(0)
|
expect(requests).toHaveLength(0)
|
||||||
expect((yield* session.context(created.id)).find((message) => message.id === first.id)).toMatchObject({
|
expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, created.id)).toMatchObject({
|
||||||
type: "compaction",
|
id: first.id,
|
||||||
status: "queued",
|
|
||||||
reason: "manual",
|
|
||||||
summary: "",
|
|
||||||
recent: "",
|
|
||||||
})
|
})
|
||||||
|
expect((yield* session.context(created.id)).find((message) => message.id === first.id)).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,13 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||||
.subscribe(SessionEvent.Compaction.Delta)
|
.subscribe(SessionEvent.Compaction.Delta)
|
||||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||||
yield* Effect.yieldNow
|
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(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual(["manual summary"])
|
||||||
|
|
||||||
expect(requests).toHaveLength(1)
|
expect(requests).toHaveLength(1)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { DateTime, Effect, Layer, Stream } from "effect"
|
import { DateTime, Effect, Layer, Stream } from "effect"
|
||||||
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||||
import { asc, eq } from "drizzle-orm"
|
import { asc, eq } from "drizzle-orm"
|
||||||
import { Database } from "@opencode-ai/core/database/database"
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
|
|
@ -210,7 +211,7 @@ describe("SessionV2.create", () => {
|
||||||
expect(forked.parentID).toBeUndefined()
|
expect(forked.parentID).toBeUndefined()
|
||||||
expect(forkContext).toMatchObject([
|
expect(forkContext).toMatchObject([
|
||||||
{ type: "user", text: "First" },
|
{ 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(forkContext.map((message) => message.id)).not.toEqual(parentContext.map((message) => message.id))
|
||||||
expect(history).toHaveLength(1)
|
expect(history).toHaveLength(1)
|
||||||
|
|
@ -273,14 +274,14 @@ describe("SessionV2.create", () => {
|
||||||
yield* events.publish(SessionEvent.Step.Started, {
|
yield* events.publish(SessionEvent.Step.Started, {
|
||||||
sessionID: parent.id,
|
sessionID: parent.id,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
agent: "build",
|
agent: AgentV2.ID.make("build"),
|
||||||
model,
|
model,
|
||||||
})
|
})
|
||||||
yield* events.publish(SessionEvent.Step.Ended, {
|
yield* events.publish(SessionEvent.Step.Ended, {
|
||||||
sessionID: parent.id,
|
sessionID: parent.id,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
finish: "stop",
|
finish: "stop",
|
||||||
cost: 0.75,
|
cost: Money.USD.make(0.75),
|
||||||
tokens: { input: 6, output: 3, reasoning: 1, cache: { read: 2, write: 1 } },
|
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 messages = yield* session.messages({ sessionID: created.id, order: "asc" })
|
||||||
const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell")
|
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?.output).toContain("hello")
|
||||||
expect(shell?.output?.truncated).toBe(false)
|
expect(shell?.output?.truncated).toBe(false)
|
||||||
expect(shell?.time.completed).toBeDefined()
|
expect(shell?.time.completed).toBeDefined()
|
||||||
|
|
@ -553,8 +554,8 @@ describe("SessionV2.create", () => {
|
||||||
|
|
||||||
const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
|
const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
|
||||||
const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell")
|
const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell")
|
||||||
expect(shell).toMatchObject({ type: "shell", shell: { command: "false", status: "exited" } })
|
expect(shell).toMatchObject({ type: "shell", command: "false", status: "exited" })
|
||||||
expect(shell?.shell.exit).not.toBe(0)
|
expect(shell?.exit).not.toBe(0)
|
||||||
expect(shell?.time.completed).toBeDefined()
|
expect(shell?.time.completed).toBeDefined()
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
@ -565,7 +566,7 @@ describe("SessionV2.create", () => {
|
||||||
const session = yield* SessionV2.Service
|
const session = yield* SessionV2.Service
|
||||||
const created = yield* session.create({ location })
|
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(yield* session.get(created.id)).toMatchObject({ agent: "plan" })
|
||||||
expect(
|
expect(
|
||||||
|
|
@ -580,7 +581,7 @@ describe("SessionV2.create", () => {
|
||||||
const missing = SessionV2.ID.make("ses_missing_agent_switch")
|
const missing = SessionV2.ID.make("ses_missing_agent_switch")
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
yield* session.switchAgent({ sessionID: missing, agent: "plan" }).pipe(
|
yield* session.switchAgent({ sessionID: missing, agent: AgentV2.ID.make("plan") }).pipe(
|
||||||
Effect.flip,
|
Effect.flip,
|
||||||
Effect.map((error) => error._tag),
|
Effect.map((error) => error._tag),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -303,7 +303,6 @@ describe("SessionInstructions", () => {
|
||||||
const synthetic = SessionMessage.Synthetic.make({
|
const synthetic = SessionMessage.Synthetic.make({
|
||||||
id: SessionMessage.ID.make("msg_synthetic"),
|
id: SessionMessage.ID.make("msg_synthetic"),
|
||||||
type: "synthetic",
|
type: "synthetic",
|
||||||
sessionID: SessionV2.ID.make("ses_test"),
|
|
||||||
text: "Instructions from: /repo/sub/AGENTS.md\ncontent",
|
text: "Instructions from: /repo/sub/AGENTS.md\ncontent",
|
||||||
description: "Loaded /repo/sub/AGENTS.md",
|
description: "Loaded /repo/sub/AGENTS.md",
|
||||||
metadata: { instruction: { paths: ["/repo/sub/AGENTS.md"] } },
|
metadata: { instruction: { paths: ["/repo/sub/AGENTS.md"] } },
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Fiber, Layer, Schema, Stream } from "effect"
|
import { Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||||
import { Database } from "@opencode-ai/core/database/database"
|
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 { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
|
|
@ -87,11 +88,11 @@ describe("SessionV2.log", () => {
|
||||||
const session = yield* SessionV2.Service
|
const session = yield* SessionV2.Service
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const created = yield* session.create({ location })
|
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.
|
// Not in the durable manifest, so reads must skip it without failing.
|
||||||
yield* events.publish(GapEvent, { sessionID: created.id, value: "filtered" })
|
yield* events.publish(GapEvent, { sessionID: created.id, value: "filtered" })
|
||||||
yield* session.switchAgent({ sessionID: created.id, agent: "two" })
|
yield* session.switchAgent({ sessionID: created.id, agent: AgentV2.ID.make("two") })
|
||||||
yield* session.switchAgent({ sessionID: created.id, agent: "three" })
|
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 })))
|
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id, after: 1 })))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { DateTime, Effect, Fiber, Option, Schema, Stream } from "effect"
|
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 { Database } from "@opencode-ai/core/database/database"
|
||||||
|
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
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 { SessionEvent } from "@opencode-ai/core/session/event"
|
||||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||||
import { Prompt } from "@opencode-ai/schema/prompt"
|
import { Prompt } from "@opencode-ai/schema/prompt"
|
||||||
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater"
|
import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater"
|
||||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
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 { SessionInput } from "@opencode-ai/core/session/input"
|
||||||
import { Shell } from "@opencode-ai/schema/shell"
|
import { Shell } from "@opencode-ai/schema/shell"
|
||||||
import {
|
import {
|
||||||
|
|
@ -35,7 +38,8 @@ const sessionID = SessionV2.ID.make("ses_projector_test")
|
||||||
const created = DateTime.makeUnsafe(0)
|
const created = DateTime.makeUnsafe(0)
|
||||||
const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }
|
const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }
|
||||||
const previousModel = { ...model, variant: ModelV2.VariantID.make("medium") }
|
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 = (
|
const assistantRow = (
|
||||||
id: SessionMessage.ID,
|
id: SessionMessage.ID,
|
||||||
|
|
@ -48,12 +52,108 @@ const assistantRow = (
|
||||||
type,
|
type,
|
||||||
...data
|
...data
|
||||||
} = encodeMessage(
|
} = 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 }
|
return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(time.created), data }
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("SessionProjector", () => {
|
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", () =>
|
it.effect("projects staged, cleared, and committed reverts", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const db = (yield* Database.Service).db
|
const db = (yield* Database.Service).db
|
||||||
|
|
@ -89,7 +189,7 @@ describe("SessionProjector", () => {
|
||||||
1,
|
1,
|
||||||
{ created },
|
{ created },
|
||||||
{
|
{
|
||||||
cost: 0.5,
|
cost: Money.USD.make(0.5),
|
||||||
tokens: { input: 4, output: 1, reasoning: 1, cache: { read: 1, write: 0 } },
|
tokens: { input: 4, output: 1, reasoning: 1, cache: { read: 1, write: 0 } },
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -98,7 +198,7 @@ describe("SessionProjector", () => {
|
||||||
2,
|
2,
|
||||||
{ created },
|
{ created },
|
||||||
{
|
{
|
||||||
cost: 0.75,
|
cost: Money.USD.make(0.75),
|
||||||
tokens: { input: 6, output: 3, reasoning: 1, cache: { read: 2, write: 1 } },
|
tokens: { input: 6, output: 3, reasoning: 1, cache: { read: 2, write: 1 } },
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -111,7 +211,7 @@ describe("SessionProjector", () => {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
yield* events.publish(SessionEvent.RevertEvent.Staged, {
|
yield* events.publish(SessionEvent.RevertEvent.Staged, {
|
||||||
sessionID,
|
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({
|
expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toMatchObject({
|
||||||
messageID: boundary,
|
messageID: boundary,
|
||||||
|
|
@ -132,7 +232,7 @@ describe("SessionProjector", () => {
|
||||||
(yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id),
|
(yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id),
|
||||||
).toEqual([earlier])
|
).toEqual([earlier])
|
||||||
expect(yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()).toMatchObject({
|
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_input: 10,
|
||||||
tokens_output: 4,
|
tokens_output: 4,
|
||||||
tokens_reasoning: 2,
|
tokens_reasoning: 2,
|
||||||
|
|
@ -285,7 +385,7 @@ describe("SessionProjector", () => {
|
||||||
|
|
||||||
yield* events.publish(SessionEvent.AgentSelected, {
|
yield* events.publish(SessionEvent.AgentSelected, {
|
||||||
sessionID,
|
sessionID,
|
||||||
agent: "build",
|
agent: build,
|
||||||
})
|
})
|
||||||
yield* events.publish(SessionEvent.ModelSelected, {
|
yield* events.publish(SessionEvent.ModelSelected, {
|
||||||
sessionID,
|
sessionID,
|
||||||
|
|
@ -327,6 +427,7 @@ describe("SessionProjector", () => {
|
||||||
yield* events.publish(SessionEvent.Compaction.Started, {
|
yield* events.publish(SessionEvent.Compaction.Started, {
|
||||||
sessionID,
|
sessionID,
|
||||||
reason: "manual",
|
reason: "manual",
|
||||||
|
recent: "recent context",
|
||||||
})
|
})
|
||||||
yield* events.publish(SessionEvent.Compaction.Delta, {
|
yield* events.publish(SessionEvent.Compaction.Delta, {
|
||||||
sessionID,
|
sessionID,
|
||||||
|
|
@ -336,18 +437,18 @@ describe("SessionProjector", () => {
|
||||||
yield* db
|
yield* db
|
||||||
.select({ id: EventTable.id })
|
.select({ id: EventTable.id })
|
||||||
.from(EventTable)
|
.from(EventTable)
|
||||||
.where(eq(EventTable.type, SessionEvent.Compaction.Delta.type))
|
.where(sql`${EventTable.type} like 'session.compaction.delta.%'`)
|
||||||
.all()
|
.all()
|
||||||
.pipe(Effect.orDie),
|
.pipe(Effect.orDie),
|
||||||
).toEqual([])
|
).toHaveLength(0)
|
||||||
expect(
|
expect(
|
||||||
yield* db
|
yield* db
|
||||||
.select({ id: SessionMessageTable.id })
|
.select({ data: SessionMessageTable.data })
|
||||||
.from(SessionMessageTable)
|
.from(SessionMessageTable)
|
||||||
.where(eq(SessionMessageTable.type, "compaction"))
|
.where(eq(SessionMessageTable.type, "compaction"))
|
||||||
.all()
|
.all()
|
||||||
.pipe(Effect.orDie),
|
.pipe(Effect.orDie),
|
||||||
).toEqual([])
|
).toEqual([{ data: expect.objectContaining({ status: "running", summary: "", recent: "recent context" }) }])
|
||||||
yield* events.publish(SessionEvent.Compaction.Ended, {
|
yield* events.publish(SessionEvent.Compaction.Ended, {
|
||||||
sessionID,
|
sessionID,
|
||||||
reason: "manual",
|
reason: "manual",
|
||||||
|
|
@ -363,7 +464,7 @@ describe("SessionProjector", () => {
|
||||||
.all()
|
.all()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
const messages = rows.map((row) =>
|
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([
|
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 === "model-switched")).toMatchObject({ previous: previousModel })
|
||||||
expect(messages.find((message) => message.type === "shell")).toMatchObject({
|
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 },
|
output: { output: "/project", truncated: false },
|
||||||
time: { completed: DateTime.makeUnsafe(0) },
|
time: { completed: DateTime.makeUnsafe(0) },
|
||||||
})
|
})
|
||||||
|
|
@ -419,11 +522,7 @@ describe("SessionProjector", () => {
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const id = SessionMessage.ID.make("msg_creator_collision")
|
const id = SessionMessage.ID.make("msg_creator_collision")
|
||||||
const {
|
const { id: _, type, ...data } = encodeMessage({ id, type: "synthetic", text: "existing", time: { created } })
|
||||||
id: _,
|
|
||||||
type,
|
|
||||||
...data
|
|
||||||
} = encodeMessage({ id, sessionID, type: "synthetic", text: "existing", time: { created } })
|
|
||||||
yield* db
|
yield* db
|
||||||
.insert(SessionMessageTable)
|
.insert(SessionMessageTable)
|
||||||
.values({ id, session_id: sessionID, type, seq: 0, time_created: 0, data })
|
.values({ id, session_id: sessionID, type, seq: 0, time_created: 0, data })
|
||||||
|
|
@ -433,7 +532,7 @@ describe("SessionProjector", () => {
|
||||||
.publish(SessionEvent.Step.Started, {
|
.publish(SessionEvent.Step.Started, {
|
||||||
sessionID,
|
sessionID,
|
||||||
assistantMessageID: id,
|
assistantMessageID: id,
|
||||||
agent: "build",
|
agent: build,
|
||||||
model,
|
model,
|
||||||
})
|
})
|
||||||
.pipe(Effect.exit)
|
.pipe(Effect.exit)
|
||||||
|
|
@ -450,7 +549,7 @@ describe("SessionProjector", () => {
|
||||||
const stale = SessionMessage.Assistant.make({
|
const stale = SessionMessage.Assistant.make({
|
||||||
id: SessionMessage.ID.make("msg_assistant_stale"),
|
id: SessionMessage.ID.make("msg_assistant_stale"),
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
agent: "build",
|
agent: build,
|
||||||
model,
|
model,
|
||||||
content: [],
|
content: [],
|
||||||
time: { created },
|
time: { created },
|
||||||
|
|
@ -458,7 +557,7 @@ describe("SessionProjector", () => {
|
||||||
const completed = SessionMessage.Assistant.make({
|
const completed = SessionMessage.Assistant.make({
|
||||||
id: SessionMessage.ID.make("msg_assistant_completed"),
|
id: SessionMessage.ID.make("msg_assistant_completed"),
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
agent: "build",
|
agent: build,
|
||||||
model,
|
model,
|
||||||
content: [],
|
content: [],
|
||||||
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
|
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
|
||||||
|
|
@ -493,7 +592,7 @@ describe("SessionProjector", () => {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const first = SessionMessage.ID.make("msg_retry_first")
|
const first = SessionMessage.ID.make("msg_retry_first")
|
||||||
const second = SessionMessage.ID.make("msg_retry_second")
|
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, {
|
yield* events.publish(SessionEvent.RetryScheduled, {
|
||||||
sessionID,
|
sessionID,
|
||||||
assistantMessageID: first,
|
assistantMessageID: first,
|
||||||
|
|
@ -503,7 +602,7 @@ describe("SessionProjector", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
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
|
const firstRow = yield* db
|
||||||
.select()
|
.select()
|
||||||
.from(SessionMessageTable)
|
.from(SessionMessageTable)
|
||||||
|
|
@ -515,7 +614,7 @@ describe("SessionProjector", () => {
|
||||||
retry: { attempt: 2, at: DateTime.makeUnsafe(2_000), error: { type: "provider.transport" } },
|
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, {
|
yield* events.publish(SessionEvent.RetryScheduled, {
|
||||||
sessionID,
|
sessionID,
|
||||||
assistantMessageID: second,
|
assistantMessageID: second,
|
||||||
|
|
@ -574,7 +673,7 @@ describe("SessionProjector", () => {
|
||||||
sessionID,
|
sessionID,
|
||||||
assistantMessageID: SessionMessage.ID.make("msg_assistant_2"),
|
assistantMessageID: SessionMessage.ID.make("msg_assistant_2"),
|
||||||
finish: "stop",
|
finish: "stop",
|
||||||
cost: 1.25,
|
cost: Money.USD.make(1.25),
|
||||||
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
|
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -586,13 +685,13 @@ describe("SessionProjector", () => {
|
||||||
.all()
|
.all()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
const messages = rows.map((row) =>
|
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[0]).not.toHaveProperty("time.completed")
|
||||||
expect(messages[1]).toMatchObject({
|
expect(messages[1]).toMatchObject({
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
finish: "stop",
|
finish: "stop",
|
||||||
cost: 1.25,
|
cost: Money.USD.make(1.25),
|
||||||
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
|
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
|
||||||
time: { completed: DateTime.makeUnsafe(0) },
|
time: { completed: DateTime.makeUnsafe(0) },
|
||||||
})
|
})
|
||||||
|
|
@ -608,7 +707,7 @@ describe("SessionProjector", () => {
|
||||||
})
|
})
|
||||||
expect(Option.getOrThrow(yield* Fiber.join(usageUpdated)).data).toEqual({
|
expect(Option.getOrThrow(yield* Fiber.join(usageUpdated)).data).toEqual({
|
||||||
sessionID,
|
sessionID,
|
||||||
cost: 1.25,
|
cost: Money.USD.make(1.25),
|
||||||
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
|
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|
@ -661,13 +760,13 @@ describe("SessionProjector", () => {
|
||||||
.all()
|
.all()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
const messages = rows.map((row) =>
|
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([
|
expect(messages).toEqual([
|
||||||
SessionMessage.Assistant.make({
|
SessionMessage.Assistant.make({
|
||||||
id: SessionMessage.ID.make("msg_assistant_completed"),
|
id: SessionMessage.ID.make("msg_assistant_completed"),
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
agent: "build",
|
agent: build,
|
||||||
model,
|
model,
|
||||||
content: [SessionMessage.AssistantText.make({ type: "text", text: "" })],
|
content: [SessionMessage.AssistantText.make({ type: "text", text: "" })],
|
||||||
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
|
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
|
||||||
|
|
@ -675,7 +774,7 @@ describe("SessionProjector", () => {
|
||||||
SessionMessage.Assistant.make({
|
SessionMessage.Assistant.make({
|
||||||
id: SessionMessage.ID.make("msg_assistant_stale"),
|
id: SessionMessage.ID.make("msg_assistant_stale"),
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
agent: "build",
|
agent: build,
|
||||||
model,
|
model,
|
||||||
content: [],
|
content: [],
|
||||||
time: { created },
|
time: { created },
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import path from "path"
|
||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
import { Database } from "@opencode-ai/core/database/database"
|
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 { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
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 assistantRow = (id: SessionMessage.ID, seq: number) => {
|
||||||
const {
|
const {
|
||||||
id: _,
|
id: _,
|
||||||
|
|
@ -115,7 +116,7 @@ const assistantRow = (id: SessionMessage.ID, seq: number) => {
|
||||||
SessionMessage.Assistant.make({
|
SessionMessage.Assistant.make({
|
||||||
id,
|
id,
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
agent: "build",
|
agent: AgentV2.ID.make("build"),
|
||||||
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||||
content: [],
|
content: [],
|
||||||
time: { created: DateTime.makeUnsafe(0) },
|
time: { created: DateTime.makeUnsafe(0) },
|
||||||
|
|
@ -677,7 +678,6 @@ describe("SessionV2.prompt", () => {
|
||||||
...data
|
...data
|
||||||
} = encodeMessage({
|
} = encodeMessage({
|
||||||
id: messageID,
|
id: messageID,
|
||||||
sessionID,
|
|
||||||
type: "synthetic",
|
type: "synthetic",
|
||||||
text: "Existing history",
|
text: "Existing history",
|
||||||
time: { created: DateTime.makeUnsafe(0) },
|
time: { created: DateTime.makeUnsafe(0) },
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,14 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||||
import { AgentAttachment, Base64, FileAttachment } from "@opencode-ai/schema/prompt"
|
import { AgentAttachment, Base64, FileAttachment } from "@opencode-ai/schema/prompt"
|
||||||
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
|
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 { Shell } from "@opencode-ai/schema/shell"
|
||||||
import { DateTime } from "effect"
|
import { DateTime } from "effect"
|
||||||
|
|
||||||
const created = DateTime.makeUnsafe(0)
|
const created = DateTime.makeUnsafe(0)
|
||||||
const id = (value: string) => SessionMessage.ID.make(`msg_${value}`)
|
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 model = ModelV2.Ref.make({ id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") })
|
||||||
|
const build = AgentV2.defaultID
|
||||||
|
|
||||||
describe("toLLMMessages", () => {
|
describe("toLLMMessages", () => {
|
||||||
test("omits empty assistant turns", () => {
|
test("omits empty assistant turns", () => {
|
||||||
|
|
@ -19,7 +20,7 @@ describe("toLLMMessages", () => {
|
||||||
SessionMessage.Assistant.make({
|
SessionMessage.Assistant.make({
|
||||||
id: id(value),
|
id: id(value),
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
agent: "build",
|
agent: build,
|
||||||
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||||
content,
|
content,
|
||||||
time: { created, completed: created },
|
time: { created, completed: created },
|
||||||
|
|
@ -56,7 +57,7 @@ describe("toLLMMessages", () => {
|
||||||
SessionMessage.AgentSelected.make({
|
SessionMessage.AgentSelected.make({
|
||||||
id: id("agent"),
|
id: id("agent"),
|
||||||
type: "agent-switched",
|
type: "agent-switched",
|
||||||
agent: "build",
|
agent: build,
|
||||||
time: { created },
|
time: { created },
|
||||||
}),
|
}),
|
||||||
SessionMessage.ModelSelected.make({
|
SessionMessage.ModelSelected.make({
|
||||||
|
|
@ -82,24 +83,16 @@ describe("toLLMMessages", () => {
|
||||||
SessionMessage.Synthetic.make({
|
SessionMessage.Synthetic.make({
|
||||||
id: id("synthetic"),
|
id: id("synthetic"),
|
||||||
type: "synthetic",
|
type: "synthetic",
|
||||||
sessionID: SessionV2.ID.make("ses_translate"),
|
|
||||||
text: "Synthetic context",
|
text: "Synthetic context",
|
||||||
time: { created },
|
time: { created },
|
||||||
}),
|
}),
|
||||||
SessionMessage.Shell.make({
|
SessionMessage.Shell.make({
|
||||||
id: id("shell"),
|
id: id("shell"),
|
||||||
type: "shell",
|
type: "shell",
|
||||||
shell: Shell.Info.make({
|
shellID: Shell.ID.make("sh_test"),
|
||||||
id: Shell.ID.make("sh_test"),
|
status: "exited",
|
||||||
status: "exited",
|
command: "pwd",
|
||||||
command: "pwd",
|
exit: 0,
|
||||||
cwd: "/project",
|
|
||||||
shell: "/bin/sh",
|
|
||||||
file: "/tmp/sh_test.out",
|
|
||||||
exit: 0,
|
|
||||||
metadata: {},
|
|
||||||
time: { started: 0, completed: 0 },
|
|
||||||
}),
|
|
||||||
output: { output: "/project", cursor: 8, size: 8, truncated: false },
|
output: { output: "/project", cursor: 8, size: 8, truncated: false },
|
||||||
time: { created, completed: created },
|
time: { created, completed: created },
|
||||||
}),
|
}),
|
||||||
|
|
@ -282,7 +275,7 @@ Recent work
|
||||||
SessionMessage.Assistant.make({
|
SessionMessage.Assistant.make({
|
||||||
id: id("assistant"),
|
id: id("assistant"),
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
agent: "build",
|
agent: build,
|
||||||
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||||
content: [
|
content: [
|
||||||
SessionMessage.AssistantText.make({ type: "text", text: "Checking" }),
|
SessionMessage.AssistantText.make({ type: "text", text: "Checking" }),
|
||||||
|
|
@ -295,7 +288,7 @@ Recent work
|
||||||
type: "tool",
|
type: "tool",
|
||||||
id: "pending",
|
id: "pending",
|
||||||
name: "read",
|
name: "read",
|
||||||
state: SessionMessage.ToolStatePending.make({ status: "pending", input: '{"path":"README.md"}' }),
|
state: SessionMessage.ToolStateStreaming.make({ status: "streaming", input: '{"path":"README.md"}' }),
|
||||||
time: { created },
|
time: { created },
|
||||||
}),
|
}),
|
||||||
SessionMessage.AssistantTool.make({
|
SessionMessage.AssistantTool.make({
|
||||||
|
|
@ -437,7 +430,7 @@ Recent work
|
||||||
SessionMessage.Assistant.make({
|
SessionMessage.Assistant.make({
|
||||||
id: id("assistant-openai-reasoning"),
|
id: id("assistant-openai-reasoning"),
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
agent: "build",
|
agent: build,
|
||||||
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||||
content: [
|
content: [
|
||||||
SessionMessage.AssistantReasoning.make({
|
SessionMessage.AssistantReasoning.make({
|
||||||
|
|
@ -467,7 +460,7 @@ Recent work
|
||||||
SessionMessage.Assistant.make({
|
SessionMessage.Assistant.make({
|
||||||
id: id("assistant-failed"),
|
id: id("assistant-failed"),
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
agent: "build",
|
agent: build,
|
||||||
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||||
content: [
|
content: [
|
||||||
SessionMessage.AssistantReasoning.make({
|
SessionMessage.AssistantReasoning.make({
|
||||||
|
|
@ -536,7 +529,7 @@ Recent work
|
||||||
SessionMessage.Assistant.make({
|
SessionMessage.Assistant.make({
|
||||||
id: id("assistant-old-model"),
|
id: id("assistant-old-model"),
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
agent: "build",
|
agent: build,
|
||||||
model: { id: ModelV2.ID.make("old-model"), providerID: ProviderV2.ID.make("provider") },
|
model: { id: ModelV2.ID.make("old-model"), providerID: ProviderV2.ID.make("provider") },
|
||||||
content: [
|
content: [
|
||||||
SessionMessage.AssistantReasoning.make({
|
SessionMessage.AssistantReasoning.make({
|
||||||
|
|
@ -631,7 +624,7 @@ Recent work
|
||||||
SessionMessage.Assistant.make({
|
SessionMessage.Assistant.make({
|
||||||
id: id("assistant-alias"),
|
id: id("assistant-alias"),
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
agent: "build",
|
agent: build,
|
||||||
model: { id: ModelV2.ID.make("fast"), providerID: ProviderV2.ID.make("provider") },
|
model: { id: ModelV2.ID.make("fast"), providerID: ProviderV2.ID.make("provider") },
|
||||||
content: [
|
content: [
|
||||||
SessionMessage.AssistantReasoning.make({
|
SessionMessage.AssistantReasoning.make({
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { describe, expect } from "bun:test"
|
||||||
import { LLM, Model } from "@opencode-ai/llm"
|
import { LLM, Model } from "@opencode-ai/llm"
|
||||||
import { LLMClient } from "@opencode-ai/llm/route"
|
import { LLMClient } from "@opencode-ai/llm/route"
|
||||||
import { DateTime, Effect } from "effect"
|
import { DateTime, Effect } from "effect"
|
||||||
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { Headers } from "effect/unstable/http"
|
import { Headers } from "effect/unstable/http"
|
||||||
import { Credential } from "@opencode-ai/core/credential"
|
import { Credential } from "@opencode-ai/core/credential"
|
||||||
import { Integration } from "@opencode-ai/core/integration"
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
|
|
@ -131,7 +132,7 @@ describe("SessionRunnerModel", () => {
|
||||||
providerID: catalog.providerID,
|
providerID: catalog.providerID,
|
||||||
variant: ModelV2.VariantID.make("high"),
|
variant: ModelV2.VariantID.make("high"),
|
||||||
},
|
},
|
||||||
cost: 0,
|
cost: Money.USD.zero,
|
||||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||||
location: { directory: AbsolutePath.make("/project") },
|
location: { directory: AbsolutePath.make("/project") },
|
||||||
|
|
@ -170,7 +171,7 @@ describe("SessionRunnerModel", () => {
|
||||||
projectID: ProjectV2.ID.global,
|
projectID: ProjectV2.ID.global,
|
||||||
title: "test",
|
title: "test",
|
||||||
model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") },
|
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 } },
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||||
location: { directory: AbsolutePath.make("/project") },
|
location: { directory: AbsolutePath.make("/project") },
|
||||||
|
|
@ -200,7 +201,7 @@ describe("SessionRunnerModel", () => {
|
||||||
providerID: catalog.providerID,
|
providerID: catalog.providerID,
|
||||||
variant: ModelV2.VariantID.make("unknown"),
|
variant: ModelV2.VariantID.make("unknown"),
|
||||||
},
|
},
|
||||||
cost: 0,
|
cost: Money.USD.zero,
|
||||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||||
location: { directory: AbsolutePath.make("/project") },
|
location: { directory: AbsolutePath.make("/project") },
|
||||||
|
|
@ -236,7 +237,7 @@ describe("SessionRunnerModel", () => {
|
||||||
projectID: ProjectV2.ID.global,
|
projectID: ProjectV2.ID.global,
|
||||||
title: "test",
|
title: "test",
|
||||||
model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") },
|
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 } },
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||||
location: { directory: AbsolutePath.make("/project") },
|
location: { directory: AbsolutePath.make("/project") },
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { Effect, Schema, Stream } from "effect"
|
import { Effect, Schema, Stream } from "effect"
|
||||||
import { LLMEvent } from "@opencode-ai/llm"
|
import { LLMEvent } from "@opencode-ai/llm"
|
||||||
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
|
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||||
import { SessionV2 } from "@opencode-ai/core/session"
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
|
|
@ -40,7 +42,7 @@ const capture = () => {
|
||||||
published,
|
published,
|
||||||
publisher: createLLMEventPublisher(events, {
|
publisher: createLLMEventPublisher(events, {
|
||||||
sessionID,
|
sessionID,
|
||||||
agent: "build",
|
agent: AgentV2.ID.make("build"),
|
||||||
model: {
|
model: {
|
||||||
id: ModelV2.ID.make("model"),
|
id: ModelV2.ID.make("model"),
|
||||||
providerID: ProviderV2.ID.make("provider"),
|
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")
|
if (!settlement) throw new Error("Expected content-filter settlement")
|
||||||
await Effect.runPromise(
|
await Effect.runPromise(
|
||||||
publisher.publishStepFailure({
|
publisher.publishStepFailure({
|
||||||
cost: 1.25,
|
cost: Money.USD.make(1.25),
|
||||||
tokens: settlement.tokens,
|
tokens: settlement.tokens,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||||
import { SessionInput } from "@opencode-ai/core/session/input"
|
import { SessionInput } from "@opencode-ai/core/session/input"
|
||||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||||
import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||||
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||||
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
|
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
|
||||||
|
|
@ -135,8 +136,23 @@ test("calculates step cost using the matching context tier", () => {
|
||||||
expect(
|
expect(
|
||||||
SessionRunnerLLM.calculateCost(
|
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 } },
|
{ 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", () => {
|
test("does not apply an ineligible tier without base pricing", () => {
|
||||||
expect(
|
expect(
|
||||||
SessionRunnerLLM.calculateCost(
|
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 } },
|
{ input: 80, output: 10, reasoning: 2, cache: { read: 20, write: 0 } },
|
||||||
),
|
),
|
||||||
).toBe(0)
|
).toBe(Money.USD.zero)
|
||||||
})
|
})
|
||||||
|
|
||||||
const authorizations: Tool.Context[] = []
|
const authorizations: Tool.Context[] = []
|
||||||
|
|
@ -485,7 +511,7 @@ const recordedStepSettlementEvents = (id: SessionV2.ID, assistantMessageID: Sess
|
||||||
const hostedCall = (id: string, query: string) =>
|
const hostedCall = (id: string, query: string) =>
|
||||||
LLMEvent.toolCall({ id, name: "web_search", input: { query }, providerExecuted: true })
|
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")
|
const assistant = messages.find((message) => message.type === "assistant")
|
||||||
if (!assistant) throw new Error("Assistant message missing")
|
if (!assistant) throw new Error("Assistant message missing")
|
||||||
return assistant
|
return assistant
|
||||||
|
|
@ -581,7 +607,7 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string
|
||||||
LLMEvent.toolInputStart({ id, name: "echo" }),
|
LLMEvent.toolInputStart({ id, name: "echo" }),
|
||||||
...chunks.map((text) => LLMEvent.toolInputDelta({ id, name: "echo", text })),
|
...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 {
|
return {
|
||||||
delta: SessionEvent.Tool.Input.Delta,
|
delta: SessionEvent.Tool.Input.Delta,
|
||||||
partialEvents,
|
partialEvents,
|
||||||
|
|
@ -1056,7 +1082,7 @@ describe("SessionRunnerLLM", () => {
|
||||||
skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills")
|
skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills")
|
||||||
yield* events.publish(SessionEvent.AgentSelected, {
|
yield* events.publish(SessionEvent.AgentSelected, {
|
||||||
sessionID,
|
sessionID,
|
||||||
agent: "reviewer",
|
agent: AgentV2.ID.make("reviewer"),
|
||||||
})
|
})
|
||||||
yield* admit(session, "Second")
|
yield* admit(session, "Second")
|
||||||
yield* session.resume(sessionID)
|
yield* session.resume(sessionID)
|
||||||
|
|
@ -1082,7 +1108,7 @@ describe("SessionRunnerLLM", () => {
|
||||||
return events
|
return events
|
||||||
.publish(SessionEvent.AgentSelected, {
|
.publish(SessionEvent.AgentSelected, {
|
||||||
sessionID,
|
sessionID,
|
||||||
agent: "reviewer",
|
agent: AgentV2.ID.make("reviewer"),
|
||||||
})
|
})
|
||||||
.pipe(Effect.asVoid)
|
.pipe(Effect.asVoid)
|
||||||
})
|
})
|
||||||
|
|
@ -1265,6 +1291,7 @@ describe("SessionRunnerLLM", () => {
|
||||||
yield* events.publish(SessionEvent.Compaction.Started, {
|
yield* events.publish(SessionEvent.Compaction.Started, {
|
||||||
sessionID,
|
sessionID,
|
||||||
reason: "manual",
|
reason: "manual",
|
||||||
|
recent: "",
|
||||||
})
|
})
|
||||||
yield* events.publish(SessionEvent.Compaction.Ended, {
|
yield* events.publish(SessionEvent.Compaction.Ended, {
|
||||||
sessionID,
|
sessionID,
|
||||||
|
|
@ -1308,10 +1335,7 @@ describe("SessionRunnerLLM", () => {
|
||||||
expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, sessionID)).toMatchObject({
|
expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, sessionID)).toMatchObject({
|
||||||
id: first.id,
|
id: first.id,
|
||||||
})
|
})
|
||||||
expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toMatchObject({
|
expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toBeUndefined()
|
||||||
type: "compaction",
|
|
||||||
status: "queued",
|
|
||||||
})
|
|
||||||
|
|
||||||
yield* admit(session, "Steer after compaction")
|
yield* admit(session, "Steer after compaction")
|
||||||
yield* session.prompt({
|
yield* session.prompt({
|
||||||
|
|
@ -1370,6 +1394,57 @@ describe("SessionRunnerLLM", () => {
|
||||||
type: "compaction",
|
type: "compaction",
|
||||||
status: "failed",
|
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)
|
expect(requests).toHaveLength(2)
|
||||||
const context = yield* session.context(sessionID)
|
const context = yield* session.context(sessionID)
|
||||||
expect(context.some((message) => message.type === "compaction")).toBe(false)
|
expect(context).toContainEqual(expect.objectContaining({ type: "compaction", status: "failed", reason: "auto" }))
|
||||||
expect(context.slice(-2)).toMatchObject([
|
expect(context.slice(-3)).toMatchObject([
|
||||||
{ type: "user", text: "Continue" },
|
{ type: "user", text: "Continue" },
|
||||||
|
{ type: "compaction", status: "failed", reason: "auto" },
|
||||||
{ type: "assistant", finish: "error", error: { message: "prompt too long" } },
|
{ type: "assistant", finish: "error", error: { message: "prompt too long" } },
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
|
|
@ -1540,7 +1616,9 @@ describe("SessionRunnerLLM", () => {
|
||||||
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
|
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
|
||||||
streamGate = undefined
|
streamGate = undefined
|
||||||
expect(requests).toHaveLength(2)
|
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, {
|
yield* events.publish(SessionEvent.Compaction.Started, {
|
||||||
sessionID,
|
sessionID,
|
||||||
reason: "manual",
|
reason: "manual",
|
||||||
|
recent: "",
|
||||||
})
|
})
|
||||||
yield* events.publish(SessionEvent.Compaction.Ended, {
|
yield* events.publish(SessionEvent.Compaction.Ended, {
|
||||||
sessionID,
|
sessionID,
|
||||||
|
|
@ -2299,7 +2378,7 @@ describe("SessionRunnerLLM", () => {
|
||||||
yield* events.publish(SessionEvent.Step.Started, {
|
yield* events.publish(SessionEvent.Step.Started, {
|
||||||
sessionID,
|
sessionID,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
agent: "build",
|
agent: AgentV2.ID.make("build"),
|
||||||
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
|
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
|
||||||
})
|
})
|
||||||
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
||||||
|
|
@ -2356,7 +2435,7 @@ describe("SessionRunnerLLM", () => {
|
||||||
yield* events.publish(SessionEvent.Step.Started, {
|
yield* events.publish(SessionEvent.Step.Started, {
|
||||||
sessionID,
|
sessionID,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
agent: "build",
|
agent: AgentV2.ID.make("build"),
|
||||||
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
|
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
|
||||||
})
|
})
|
||||||
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
||||||
|
|
@ -2407,7 +2486,7 @@ describe("SessionRunnerLLM", () => {
|
||||||
yield* events.publish(SessionEvent.Step.Started, {
|
yield* events.publish(SessionEvent.Step.Started, {
|
||||||
sessionID,
|
sessionID,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
agent: "build",
|
agent: AgentV2.ID.make("build"),
|
||||||
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
|
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
|
||||||
})
|
})
|
||||||
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,8 @@ const skills = Layer.mock(SkillV2.Service, {
|
||||||
list: () =>
|
list: () =>
|
||||||
Effect.succeed([
|
Effect.succeed([
|
||||||
SkillV2.Info.make({
|
SkillV2.Info.make({
|
||||||
name: "effect",
|
id: SkillV2.ID.make("effect"),
|
||||||
|
name: SkillV2.Name.make("Effect"),
|
||||||
description: "Effect guidance",
|
description: "Effect guidance",
|
||||||
location: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")),
|
location: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")),
|
||||||
content: "Use Effect",
|
content: "Use Effect",
|
||||||
|
|
@ -60,10 +61,10 @@ describe("SessionV2.skill", () => {
|
||||||
const session = yield* sessions.create({ location })
|
const session = yield* sessions.create({ location })
|
||||||
const id = SessionMessage.ID.make("msg_caller_skill")
|
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(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" }),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { DateTime, Effect, Schema } from "effect"
|
||||||
import { Database } from "@opencode-ai/core/database/database"
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
|
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { Project } from "@opencode-ai/core/project"
|
import { Project } from "@opencode-ai/core/project"
|
||||||
|
|
@ -51,7 +52,7 @@ describe("Tool.Progress", () => {
|
||||||
yield* service.publish(SessionEvent.Step.Started, {
|
yield* service.publish(SessionEvent.Step.Started, {
|
||||||
sessionID,
|
sessionID,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
agent: "build",
|
agent: AgentV2.ID.make("build"),
|
||||||
model,
|
model,
|
||||||
})
|
})
|
||||||
const readAssistant = Effect.gen(function* () {
|
const readAssistant = Effect.gen(function* () {
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||||
|
|
||||||
const schemas = [
|
const schemas = [
|
||||||
[AgentV2.ID, Agent.ID],
|
[AgentV2.ID, Agent.ID],
|
||||||
|
[AgentV2.Name, Agent.Name],
|
||||||
[AgentV2.Color, Agent.Color],
|
[AgentV2.Color, Agent.Color],
|
||||||
[AgentV2.Info, Agent.Info],
|
[AgentV2.Info, Agent.Info],
|
||||||
[coreCommand.Info, Command.Info],
|
[coreCommand.Info, Command.Info],
|
||||||
|
|
@ -145,7 +146,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||||
[coreSessionMessage.Synthetic, SessionMessage.Synthetic],
|
[coreSessionMessage.Synthetic, SessionMessage.Synthetic],
|
||||||
[coreSessionMessage.System, SessionMessage.System],
|
[coreSessionMessage.System, SessionMessage.System],
|
||||||
[coreSessionMessage.Shell, SessionMessage.Shell],
|
[coreSessionMessage.Shell, SessionMessage.Shell],
|
||||||
[coreSessionMessage.ToolStatePending, SessionMessage.ToolStatePending],
|
[coreSessionMessage.ToolStateStreaming, SessionMessage.ToolStateStreaming],
|
||||||
[coreSessionMessage.ToolStateRunning, SessionMessage.ToolStateRunning],
|
[coreSessionMessage.ToolStateRunning, SessionMessage.ToolStateRunning],
|
||||||
[coreSessionMessage.ToolStateCompleted, SessionMessage.ToolStateCompleted],
|
[coreSessionMessage.ToolStateCompleted, SessionMessage.ToolStateCompleted],
|
||||||
[coreSessionMessage.ToolStateError, SessionMessage.ToolStateError],
|
[coreSessionMessage.ToolStateError, SessionMessage.ToolStateError],
|
||||||
|
|
@ -156,7 +157,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||||
[coreSessionMessage.AssistantContent, SessionMessage.AssistantContent],
|
[coreSessionMessage.AssistantContent, SessionMessage.AssistantContent],
|
||||||
[coreSessionMessage.Assistant, SessionMessage.Assistant],
|
[coreSessionMessage.Assistant, SessionMessage.Assistant],
|
||||||
[coreSessionMessage.Compaction, SessionMessage.Compaction],
|
[coreSessionMessage.Compaction, SessionMessage.Compaction],
|
||||||
[coreSessionMessage.Message, SessionMessage.Message],
|
[coreSessionMessage.Info, SessionMessage.Info],
|
||||||
[coreSessionTodo.Info, SessionTodo.Info],
|
[coreSessionTodo.Info, SessionTodo.Info],
|
||||||
[coreSessionTodo.Event, SessionTodo.Event],
|
[coreSessionTodo.Event, SessionTodo.Event],
|
||||||
[coreSkill.DirectorySource, Skill.DirectorySource],
|
[coreSkill.DirectorySource, Skill.DirectorySource],
|
||||||
|
|
|
||||||
|
|
@ -88,13 +88,15 @@ describe("SkillV2", () => {
|
||||||
])
|
])
|
||||||
expect(yield* skill.list()).toEqual([
|
expect(yield* skill.list()).toEqual([
|
||||||
SkillV2.Info.make({
|
SkillV2.Info.make({
|
||||||
name: "foo",
|
id: SkillV2.ID.make("foo"),
|
||||||
|
name: SkillV2.Name.make("foo"),
|
||||||
slash: true,
|
slash: true,
|
||||||
location: AbsolutePath.make(path.join(first, "foo.md")),
|
location: AbsolutePath.make(path.join(first, "foo.md")),
|
||||||
content: "# foo",
|
content: "# foo",
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "review",
|
id: SkillV2.ID.make("review"),
|
||||||
|
name: SkillV2.Name.make("review"),
|
||||||
description: "Second",
|
description: "Second",
|
||||||
location: AbsolutePath.make(path.join(second, "review", "SKILL.md")),
|
location: AbsolutePath.make(path.join(second, "review", "SKILL.md")),
|
||||||
content: "# review",
|
content: "# review",
|
||||||
|
|
@ -129,8 +131,8 @@ describe("SkillV2", () => {
|
||||||
const skill = yield* SkillV2.Service
|
const skill = yield* SkillV2.Service
|
||||||
yield* skill.transform((editor) => editor.source({ type: "url", url: "https://example.test/skills/" }))
|
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([SkillV2.Name.make("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(pulls).toBe(1)
|
expect(pulls).toBe(1)
|
||||||
expect(SkillV2.available(yield* skill.list(), (yield* agents.get(AgentV2.ID.make("reviewer")))!)).toEqual([])
|
expect(SkillV2.available(yield* skill.list(), (yield* agents.get(AgentV2.ID.make("reviewer")))!)).toEqual([])
|
||||||
}),
|
}),
|
||||||
|
|
@ -165,7 +167,8 @@ metadata:
|
||||||
|
|
||||||
expect(yield* skill.list()).toEqual([
|
expect(yield* skill.list()).toEqual([
|
||||||
{
|
{
|
||||||
name: "manual",
|
id: SkillV2.ID.make("manual"),
|
||||||
|
name: SkillV2.Name.make("manual"),
|
||||||
description: "Manual only",
|
description: "Manual only",
|
||||||
slash: true,
|
slash: true,
|
||||||
autoinvoke: false,
|
autoinvoke: false,
|
||||||
|
|
|
||||||
|
|
@ -11,24 +11,28 @@ import { it } from "../lib/effect"
|
||||||
|
|
||||||
const build = AgentV2.ID.make("build")
|
const build = AgentV2.ID.make("build")
|
||||||
const effect = SkillV2.Info.make({
|
const effect = SkillV2.Info.make({
|
||||||
name: "effect",
|
id: SkillV2.ID.make("effect"),
|
||||||
|
name: SkillV2.Name.make("Effect"),
|
||||||
description: "Build applications with Effect",
|
description: "Build applications with Effect",
|
||||||
location: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")),
|
location: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")),
|
||||||
content: "Effect guidance",
|
content: "Effect guidance",
|
||||||
})
|
})
|
||||||
const hidden = SkillV2.Info.make({
|
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")),
|
location: AbsolutePath.make(path.resolve("/skills/hidden/SKILL.md")),
|
||||||
content: "Undescribed guidance",
|
content: "Undescribed guidance",
|
||||||
})
|
})
|
||||||
const denied = SkillV2.Info.make({
|
const denied = SkillV2.Info.make({
|
||||||
name: "denied",
|
id: SkillV2.ID.make("denied"),
|
||||||
|
name: SkillV2.Name.make("Denied"),
|
||||||
description: "Must not be advertised",
|
description: "Must not be advertised",
|
||||||
location: AbsolutePath.make(path.resolve("/skills/denied/SKILL.md")),
|
location: AbsolutePath.make(path.resolve("/skills/denied/SKILL.md")),
|
||||||
content: "Denied guidance",
|
content: "Denied guidance",
|
||||||
})
|
})
|
||||||
const manual = SkillV2.Info.make({
|
const manual = SkillV2.Info.make({
|
||||||
name: "manual",
|
id: SkillV2.ID.make("manual"),
|
||||||
|
name: SkillV2.Name.make("Manual"),
|
||||||
description: "Load only when explicitly selected",
|
description: "Load only when explicitly selected",
|
||||||
autoinvoke: false,
|
autoinvoke: false,
|
||||||
location: AbsolutePath.make(path.resolve("/skills/manual/SKILL.md")),
|
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.",
|
"Use the skill tool to load a skill when a task matches its description.",
|
||||||
"<available_skills>",
|
"<available_skills>",
|
||||||
" <skill>",
|
" <skill>",
|
||||||
" <name>effect</name>",
|
" <id>effect</id>",
|
||||||
|
" <name>Effect</name>",
|
||||||
" <description>Build applications with Effect</description>",
|
" <description>Build applications with Effect</description>",
|
||||||
" </skill>",
|
" </skill>",
|
||||||
"</available_skills>",
|
"</available_skills>",
|
||||||
|
|
@ -74,7 +79,7 @@ describe("SkillGuidance", () => {
|
||||||
.pipe(Effect.flatMap((context) => Instructions.reconcile(context, initialized.applied))),
|
.pipe(Effect.flatMap((context) => Instructions.reconcile(context, initialized.applied))),
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
_tag: "Updated",
|
_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)))
|
}).pipe(Effect.provide(layer(() => skills)))
|
||||||
})
|
})
|
||||||
|
|
@ -82,7 +87,8 @@ describe("SkillGuidance", () => {
|
||||||
it.effect("announces added and removed skills as deltas without restating the list", () => {
|
it.effect("announces added and removed skills as deltas without restating the list", () => {
|
||||||
const agent = AgentV2.Info.make(AgentV2.Info.empty(build))
|
const agent = AgentV2.Info.make(AgentV2.Info.empty(build))
|
||||||
const debugging = SkillV2.Info.make({
|
const debugging = SkillV2.Info.make({
|
||||||
name: "debugging",
|
id: SkillV2.ID.make("debugging"),
|
||||||
|
name: SkillV2.Name.make("Debugging"),
|
||||||
description: "Diagnose hard bugs",
|
description: "Diagnose hard bugs",
|
||||||
location: AbsolutePath.make(path.resolve("/skills/debugging/SKILL.md")),
|
location: AbsolutePath.make(path.resolve("/skills/debugging/SKILL.md")),
|
||||||
content: "Debugging guidance",
|
content: "Debugging guidance",
|
||||||
|
|
@ -103,7 +109,8 @@ describe("SkillGuidance", () => {
|
||||||
text: [
|
text: [
|
||||||
"New skills are available in addition to those previously listed:",
|
"New skills are available in addition to those previously listed:",
|
||||||
" <skill>",
|
" <skill>",
|
||||||
" <name>debugging</name>",
|
" <id>debugging</id>",
|
||||||
|
" <name>Debugging</name>",
|
||||||
" <description>Diagnose hard bugs</description>",
|
" <description>Diagnose hard bugs</description>",
|
||||||
" </skill>",
|
" </skill>",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
|
|
@ -117,7 +124,7 @@ describe("SkillGuidance", () => {
|
||||||
)
|
)
|
||||||
expect(removed).toMatchObject({
|
expect(removed).toMatchObject({
|
||||||
_tag: "Updated",
|
_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)))
|
}).pipe(Effect.provide(layer(() => skills)))
|
||||||
})
|
})
|
||||||
|
|
@ -192,7 +199,7 @@ describe("SkillGuidance", () => {
|
||||||
const guidance = yield* SkillGuidance.Service
|
const guidance = yield* SkillGuidance.Service
|
||||||
expect(
|
expect(
|
||||||
(yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(Instructions.initialize))).text,
|
(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])))
|
}).pipe(Effect.provide(layer(() => [effect])))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ describe("Snapshot", () => {
|
||||||
const plan = new Map([[RelativePath.make("scope/tracked.txt"), before]])
|
const plan = new Map([[RelativePath.make("scope/tracked.txt"), before]])
|
||||||
const preview = yield* snapshot.preview({ files: plan, context: 1 })
|
const preview = yield* snapshot.preview({ files: plan, context: 1 })
|
||||||
expect(preview).toHaveLength(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 })
|
yield* snapshot.restore({ files: plan })
|
||||||
expect(yield* read(path.join(location, "tracked.txt"))).toBe("one\n")
|
expect(yield* read(path.join(location, "tracked.txt"))).toBe("one\n")
|
||||||
expect(yield* read(path.join(location, "added.txt"))).toBe("added\n")
|
expect(yield* read(path.join(location, "added.txt"))).toBe("added\n")
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { realpathSync } from "node:fs"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { DateTime, Duration, Effect, Fiber, Layer, Scope } from "effect"
|
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 { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
|
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
|
||||||
|
|
@ -104,7 +105,7 @@ const executionNode = makeGlobalNode({
|
||||||
sessionID: id,
|
sessionID: id,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
finish: "stop",
|
finish: "stop",
|
||||||
cost: 0,
|
cost: Money.USD.zero,
|
||||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -442,10 +443,7 @@ describe("ShellTool", () => {
|
||||||
reset()
|
reset()
|
||||||
return withSession(tmp.path, (registry) =>
|
return withSession(tmp.path, (registry) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const settled = yield* settleTool(
|
const settled = yield* settleTool(registry, call({ command: idleCommand, timeout: 50, background: true }))
|
||||||
registry,
|
|
||||||
call({ command: idleCommand, timeout: 50, background: true }),
|
|
||||||
)
|
|
||||||
const structured = settled.output?.structured as Record<string, unknown> | undefined
|
const structured = settled.output?.structured as Record<string, unknown> | undefined
|
||||||
const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined
|
const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined
|
||||||
expect(settled.output?.structured).toMatchObject({ truncated: false })
|
expect(settled.output?.structured).toMatchObject({ truncated: false })
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ const skillToolNode = makeLocationNode({
|
||||||
const sessionID = SessionV2.ID.make("ses_skill_tool_test")
|
const sessionID = SessionV2.ID.make("ses_skill_tool_test")
|
||||||
|
|
||||||
describe("SkillTool", () => {
|
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.acquireRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
|
|
@ -42,7 +42,8 @@ describe("SkillTool", () => {
|
||||||
)
|
)
|
||||||
|
|
||||||
const info: SkillV2.Info = {
|
const info: SkillV2.Info = {
|
||||||
name: "effect",
|
id: SkillV2.ID.make("effect"),
|
||||||
|
name: SkillV2.Name.make("Effect"),
|
||||||
description: "Use Effect",
|
description: "Use Effect",
|
||||||
location: AbsolutePath.make(location),
|
location: AbsolutePath.make(location),
|
||||||
content: "# Effect\n\nGuidance",
|
content: "# Effect\n\nGuidance",
|
||||||
|
|
@ -102,7 +103,7 @@ describe("SkillTool", () => {
|
||||||
yield* executeTool(registry, {
|
yield* executeTool(registry, {
|
||||||
sessionID,
|
sessionID,
|
||||||
...toolIdentity,
|
...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({
|
).toEqual({
|
||||||
type: "text",
|
type: "text",
|
||||||
|
|
@ -113,11 +114,11 @@ describe("SkillTool", () => {
|
||||||
yield* settleTool(registry, {
|
yield* settleTool(registry, {
|
||||||
sessionID,
|
sessionID,
|
||||||
...toolIdentity,
|
...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({
|
).toMatchObject({
|
||||||
result: { type: "text", value: SkillTool.toModelOutput(info, [reference]) },
|
result: { type: "text", value: SkillTool.toModelOutput(info, [reference]) },
|
||||||
output: { structured: { name: "effect" } },
|
output: { structured: { name: "Effect" } },
|
||||||
})
|
})
|
||||||
expect(assertions).toMatchObject([
|
expect(assertions).toMatchObject([
|
||||||
{ sessionID, action: "skill", resources: ["effect"], save: ["effect"] },
|
{ sessionID, action: "skill", resources: ["effect"], save: ["effect"] },
|
||||||
|
|
@ -127,7 +128,7 @@ describe("SkillTool", () => {
|
||||||
yield* executeTool(registry, {
|
yield* executeTool(registry, {
|
||||||
sessionID,
|
sessionID,
|
||||||
...toolIdentity,
|
...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" })
|
).toEqual({ type: "error", value: "Unable to load skill missing" })
|
||||||
deny = true
|
deny = true
|
||||||
|
|
@ -135,12 +136,13 @@ describe("SkillTool", () => {
|
||||||
yield* executeTool(registry, {
|
yield* executeTool(registry, {
|
||||||
sessionID,
|
sessionID,
|
||||||
...toolIdentity,
|
...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" })
|
).toEqual({ type: "error", value: "Unable to load skill effect" })
|
||||||
deny = false
|
deny = false
|
||||||
const flat = SkillV2.Info.make({
|
const flat = SkillV2.Info.make({
|
||||||
name: "public",
|
id: SkillV2.ID.make("public"),
|
||||||
|
name: SkillV2.Name.make("Public"),
|
||||||
description: "Public guidance",
|
description: "Public guidance",
|
||||||
location: AbsolutePath.make(path.join(tmp.path, "public.md")),
|
location: AbsolutePath.make(path.join(tmp.path, "public.md")),
|
||||||
content: "Public",
|
content: "Public",
|
||||||
|
|
@ -156,7 +158,7 @@ describe("SkillTool", () => {
|
||||||
yield* executeTool(registry, {
|
yield* executeTool(registry, {
|
||||||
sessionID,
|
sessionID,
|
||||||
...toolIdentity,
|
...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, []) })
|
).toEqual({ type: "text", value: SkillTool.toModelOutput(flat, []) })
|
||||||
}).pipe(Effect.provide(skillToolLayer))
|
}).pipe(Effect.provide(skillToolLayer))
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { DateTime, Effect, Layer, Schema } from "effect"
|
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 { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
|
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
|
||||||
|
|
@ -70,7 +71,7 @@ const executionNode = makeGlobalNode({
|
||||||
sessionID,
|
sessionID,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
finish: "stop",
|
finish: "stop",
|
||||||
cost: 0,
|
cost: Money.USD.zero,
|
||||||
tokens,
|
tokens,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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 { iife } from "@opencode-ai/core/util/iife"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Storage } from "./storage"
|
import { Storage } from "./storage"
|
||||||
|
|
@ -30,7 +30,7 @@ export namespace Share {
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
type: z.literal("session_diff"),
|
type: z.literal("session_diff"),
|
||||||
data: z.custom<SnapshotFileDiff[]>(),
|
data: z.custom<FileDiffInfo[]>(),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
type: z.literal("model"),
|
type: z.literal("model"),
|
||||||
|
|
|
||||||
|
|
@ -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 { SessionTurn } from "@opencode-ai/session-ui/session-turn"
|
||||||
import { SessionReview } from "@opencode-ai/session-ui/session-review"
|
import { SessionReview } from "@opencode-ai/session-ui/session-review"
|
||||||
import { DataProvider } from "@opencode-ai/session-ui/context"
|
import { DataProvider } from "@opencode-ai/session-ui/context"
|
||||||
|
|
@ -65,7 +65,7 @@ const getData = query(async (shareID) => {
|
||||||
shareID: string
|
shareID: string
|
||||||
session: Session[]
|
session: Session[]
|
||||||
session_diff: {
|
session_diff: {
|
||||||
[sessionID: string]: SnapshotFileDiff[]
|
[sessionID: string]: FileDiffInfo[]
|
||||||
}
|
}
|
||||||
session_status: {
|
session_status: {
|
||||||
[sessionID: string]: SessionStatus
|
[sessionID: string]: SessionStatus
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ export function fromRow(row: SessionRow): Info {
|
||||||
messageID: MessageID.make(row.revert.messageID),
|
messageID: MessageID.make(row.revert.messageID),
|
||||||
partID: row.revert.partID ? PartID.make(row.revert.partID) : undefined,
|
partID: row.revert.partID ? PartID.make(row.revert.partID) : undefined,
|
||||||
snapshot: row.revert.snapshot,
|
snapshot: row.revert.snapshot,
|
||||||
diff: row.revert.diff,
|
diff: "diff" in row.revert ? row.revert.diff : undefined,
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ type State = {
|
||||||
type Data =
|
type Data =
|
||||||
| {
|
| {
|
||||||
type: "session"
|
type: "session"
|
||||||
data: SDK.Session
|
data: SDK.SessionV1Info
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "message"
|
type: "message"
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { Hash } from "@opencode-ai/core/util/hash"
|
import { Hash } from "@opencode-ai/core/util/hash"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
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({
|
export const Patch = Schema.Struct({
|
||||||
hash: Schema.String,
|
hash: Schema.String,
|
||||||
|
|
@ -17,7 +17,7 @@ export const Patch = Schema.Struct({
|
||||||
})
|
})
|
||||||
export type Patch = typeof Patch.Type
|
export type Patch = typeof Patch.Type
|
||||||
|
|
||||||
export const FileDiff = Info
|
export const FileDiff = LegacyInfo
|
||||||
export type FileDiff = typeof FileDiff.Type
|
export type FileDiff = typeof FileDiff.Type
|
||||||
|
|
||||||
const prune = "7.days"
|
const prune = "7.days"
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,13 @@ function connected(id = "evt_connected") {
|
||||||
return { id, type: "server.connected", data: {} } satisfies RunV2Event
|
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 }
|
return { aggregateID: sessionID, seq, version }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1311,17 +1317,10 @@ describe("V2 mini transport", () => {
|
||||||
{
|
{
|
||||||
id: "msg_shell",
|
id: "msg_shell",
|
||||||
type: "shell" as const,
|
type: "shell" as const,
|
||||||
shell: {
|
shellID: "sh_1",
|
||||||
id: "sh_1",
|
status: "exited",
|
||||||
status: "exited",
|
command: "ls",
|
||||||
command: "ls",
|
exit: 0,
|
||||||
cwd: "/tmp",
|
|
||||||
shell: "/bin/sh",
|
|
||||||
file: "/tmp/opencode-shell",
|
|
||||||
exit: 0,
|
|
||||||
metadata: {},
|
|
||||||
time: { started: 0, completed: 1 },
|
|
||||||
},
|
|
||||||
output: { output: "file.txt", cursor: 8, size: 8, truncated: false },
|
output: { output: "file.txt", cursor: 8, size: 8, truncated: false },
|
||||||
time: { created: 1, completed: 2 },
|
time: { created: 1, completed: 2 },
|
||||||
},
|
},
|
||||||
|
|
@ -1378,17 +1377,10 @@ describe("V2 mini transport", () => {
|
||||||
{
|
{
|
||||||
id: "msg_failed_shell",
|
id: "msg_failed_shell",
|
||||||
type: "shell" as const,
|
type: "shell" as const,
|
||||||
shell: {
|
shellID: "sh_failed",
|
||||||
id: "sh_failed",
|
status: "exited",
|
||||||
status: "exited",
|
command: "false",
|
||||||
command: "false",
|
exit: 7,
|
||||||
cwd: "/tmp",
|
|
||||||
shell: "/bin/sh",
|
|
||||||
file: "/tmp/failed",
|
|
||||||
exit: 7,
|
|
||||||
metadata: {},
|
|
||||||
time: { started: 0, completed: 1 },
|
|
||||||
},
|
|
||||||
output: { output: "failure output", cursor: 14, size: 14, truncated: false },
|
output: { output: "failure output", cursor: 14, size: 14, truncated: false },
|
||||||
time: { created: 1, completed: 2 },
|
time: { created: 1, completed: 2 },
|
||||||
},
|
},
|
||||||
|
|
@ -1551,6 +1543,7 @@ describe("V2 mini transport", () => {
|
||||||
durable: durable("ses_1"),
|
durable: durable("ses_1"),
|
||||||
data: {
|
data: {
|
||||||
sessionID: "ses_1",
|
sessionID: "ses_1",
|
||||||
|
id: input.skill ?? "tigerstyle",
|
||||||
name: input.skill ?? "tigerstyle",
|
name: input.skill ?? "tigerstyle",
|
||||||
text: "skill instructions",
|
text: "skill instructions",
|
||||||
},
|
},
|
||||||
|
|
@ -1633,6 +1626,7 @@ describe("V2 mini transport", () => {
|
||||||
durable: durable("ses_1"),
|
durable: durable("ses_1"),
|
||||||
data: {
|
data: {
|
||||||
sessionID: "ses_1",
|
sessionID: "ses_1",
|
||||||
|
id: "other",
|
||||||
name: "other",
|
name: "other",
|
||||||
text: "other instructions",
|
text: "other instructions",
|
||||||
},
|
},
|
||||||
|
|
@ -1655,6 +1649,7 @@ describe("V2 mini transport", () => {
|
||||||
durable: durable("ses_1"),
|
durable: durable("ses_1"),
|
||||||
data: {
|
data: {
|
||||||
sessionID: "ses_1",
|
sessionID: "ses_1",
|
||||||
|
id: "tigerstyle",
|
||||||
name: "tigerstyle",
|
name: "tigerstyle",
|
||||||
text: "skill instructions",
|
text: "skill instructions",
|
||||||
},
|
},
|
||||||
|
|
@ -1722,6 +1717,7 @@ describe("V2 mini transport", () => {
|
||||||
{
|
{
|
||||||
id: "msg_skill",
|
id: "msg_skill",
|
||||||
type: "skill" as const,
|
type: "skill" as const,
|
||||||
|
skill: "tigerstyle",
|
||||||
name: "tigerstyle",
|
name: "tigerstyle",
|
||||||
text: "skill instructions",
|
text: "skill instructions",
|
||||||
time: { created: 2 },
|
time: { created: 2 },
|
||||||
|
|
@ -1745,6 +1741,7 @@ describe("V2 mini transport", () => {
|
||||||
durable: durable("ses_1"),
|
durable: durable("ses_1"),
|
||||||
data: {
|
data: {
|
||||||
sessionID: "ses_1",
|
sessionID: "ses_1",
|
||||||
|
id: "tigerstyle",
|
||||||
name: "tigerstyle",
|
name: "tigerstyle",
|
||||||
text: "skill instructions",
|
text: "skill instructions",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from ".
|
||||||
import { Database } from "@opencode-ai/core/database/database"
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||||
|
import { Agent } from "@opencode-ai/schema/agent"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import * as DateTime from "effect/DateTime"
|
import * as DateTime from "effect/DateTime"
|
||||||
|
|
@ -76,7 +77,7 @@ function createTextMessage(sessionID: SessionIDType, text: string) {
|
||||||
id: MessageID.ascending(),
|
id: MessageID.ascending(),
|
||||||
role: "user",
|
role: "user",
|
||||||
sessionID,
|
sessionID,
|
||||||
agent: "build",
|
agent: Agent.ID.make("build"),
|
||||||
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
|
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
|
||||||
time: { created: Date.now() },
|
time: { created: Date.now() },
|
||||||
})
|
})
|
||||||
|
|
@ -123,7 +124,7 @@ const insertLegacyAssistantMessage = (sessionID: SessionIDType, seq = 1, time =
|
||||||
const message = SessionMessage.Assistant.make({
|
const message = SessionMessage.Assistant.make({
|
||||||
id: SessionMessage.ID.create(),
|
id: SessionMessage.ID.create(),
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
agent: "build",
|
agent: Agent.ID.make("build"),
|
||||||
model: {
|
model: {
|
||||||
id: ModelV2.ID.make("model"),
|
id: ModelV2.ID.make("model"),
|
||||||
providerID: ProviderV2.ID.make("provider"),
|
providerID: ProviderV2.ID.make("provider"),
|
||||||
|
|
@ -380,7 +381,7 @@ describe("session HttpApi", () => {
|
||||||
yield* insertLegacyAssistantMessage(parent.id)
|
yield* insertLegacyAssistantMessage(parent.id)
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
(yield* requestJson<{ data: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, {
|
(yield* requestJson<{ data: SessionMessage.Info[] }>(`/api/session/${parent.id}/message`, {
|
||||||
headers,
|
headers,
|
||||||
})).data,
|
})).data,
|
||||||
).toMatchObject([{ type: "assistant" }])
|
).toMatchObject([{ type: "assistant" }])
|
||||||
|
|
@ -474,7 +475,7 @@ describe("session HttpApi", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const messagePage = yield* request(`/api/session/${session.id}/message?limit=1`, { headers })
|
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
|
const messageCursor = messageBody.cursor.next
|
||||||
expect(messageCursor).toBeTruthy()
|
expect(messageCursor).toBeTruthy()
|
||||||
expect(messageBody.data.map((message) => message.id)).toEqual([secondMessage.id])
|
expect(messageBody.data.map((message) => message.id)).toEqual([secondMessage.id])
|
||||||
|
|
@ -488,7 +489,7 @@ describe("session HttpApi", () => {
|
||||||
headers,
|
headers,
|
||||||
})
|
})
|
||||||
expect(
|
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])
|
).toEqual([firstMessage.id])
|
||||||
|
|
||||||
const legacyMessageCursor = Buffer.from(
|
const legacyMessageCursor = Buffer.from(
|
||||||
|
|
@ -498,7 +499,7 @@ describe("session HttpApi", () => {
|
||||||
headers,
|
headers,
|
||||||
})
|
})
|
||||||
expect(
|
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])
|
).toEqual([firstMessage.id])
|
||||||
|
|
||||||
const messageCursorWithOrder = yield* request(
|
const messageCursorWithOrder = yield* request(
|
||||||
|
|
@ -625,7 +626,7 @@ describe("session HttpApi", () => {
|
||||||
})
|
})
|
||||||
expect(wake.status).toBe(200)
|
expect(wake.status).toBe(200)
|
||||||
const message = yield* pollWithTimeout(
|
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)),
|
Effect.map(({ data }) => data.find((message) => message.id === wakeID)),
|
||||||
),
|
),
|
||||||
"V2 prompt was not promoted after wake",
|
"V2 prompt was not promoted after wake",
|
||||||
|
|
@ -637,28 +638,25 @@ describe("session HttpApi", () => {
|
||||||
)
|
)
|
||||||
|
|
||||||
it.instance(
|
it.instance(
|
||||||
"returns v2 public unavailable errors for unfinished session mutations",
|
"supports current session compact and wait endpoints",
|
||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const test = yield* TestInstance
|
const test = yield* TestInstance
|
||||||
const headers = { "x-opencode-directory": test.directory }
|
const headers = { "x-opencode-directory": test.directory }
|
||||||
const session = yield* createSession({ title: "v2 unavailable" })
|
const session = yield* createSession({ title: "v2 unavailable" })
|
||||||
|
|
||||||
const compact = yield* request(`/api/session/${session.id}/compact`, { method: "POST", headers })
|
const compact = yield* request(`/api/session/${session.id}/compact`, {
|
||||||
expect(compact.status).toBe(503)
|
method: "POST",
|
||||||
expect(yield* responseJson(compact)).toEqual({
|
headers: { ...headers, "content-type": "application/json" },
|
||||||
_tag: "ServiceUnavailableError",
|
body: JSON.stringify({}),
|
||||||
message: "Session compact is not available yet",
|
})
|
||||||
service: "session.compact",
|
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 })
|
const wait = yield* request(`/api/session/${session.id}/wait`, { method: "POST", headers })
|
||||||
expect(wait.status).toBe(503)
|
expect(wait.status).toBe(204)
|
||||||
expect(yield* responseJson(wait)).toEqual({
|
|
||||||
_tag: "ServiceUnavailableError",
|
|
||||||
message: "Session wait is not available yet",
|
|
||||||
service: "session.wait",
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
{ git: true, config: { formatter: false, lsp: false } },
|
{ git: true, config: { formatter: false, lsp: false } },
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { DateTime, Effect } from "effect"
|
||||||
import * as DateTime from "effect/DateTime"
|
|
||||||
import { SessionID } from "../../src/session/schema"
|
import { SessionID } from "../../src/session/schema"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
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 { SessionEvent } from "@opencode-ai/core/session/event"
|
||||||
import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater"
|
import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater"
|
||||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
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) {
|
function durable(sessionID: SessionID, seq = 0, version = 1) {
|
||||||
return { aggregateID: sessionID, seq: EventV2.Seq.make(seq), version: EventV2.Version.make(version) }
|
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: {
|
data: {
|
||||||
sessionID,
|
sessionID,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
agent: "build",
|
agent: Agent.ID.make("build"),
|
||||||
model: {
|
model: {
|
||||||
id: ModelV2.ID.make("model"),
|
id: ModelV2.ID.make("model"),
|
||||||
providerID: ProviderV2.ID.make("provider"),
|
providerID: ProviderV2.ID.make("provider"),
|
||||||
variant: ModelV2.VariantID.make("default"),
|
variant: ModelV2.VariantID.make("default"),
|
||||||
},
|
},
|
||||||
snapshot: "before",
|
snapshot: Snapshot.ID.make("before"),
|
||||||
},
|
},
|
||||||
} satisfies SessionEvent.Event),
|
} satisfies SessionEvent.Event),
|
||||||
)
|
)
|
||||||
|
|
@ -50,21 +52,24 @@ test.skip("step snapshots carry over to assistant messages", () => {
|
||||||
sessionID,
|
sessionID,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
finish: "stop",
|
finish: "stop",
|
||||||
cost: 0,
|
cost: Money.USD.zero,
|
||||||
tokens: {
|
tokens: {
|
||||||
input: 1,
|
input: 1,
|
||||||
output: 2,
|
output: 2,
|
||||||
reasoning: 0,
|
reasoning: 0,
|
||||||
cache: { read: 0, write: 0 },
|
cache: { read: 0, write: 0 },
|
||||||
},
|
},
|
||||||
snapshot: "after",
|
snapshot: Snapshot.ID.make("after"),
|
||||||
},
|
},
|
||||||
} satisfies SessionEvent.Event),
|
} satisfies SessionEvent.Event),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(state.messages[0]?.type).toBe("assistant")
|
expect(state.messages[0]?.type).toBe("assistant")
|
||||||
if (state.messages[0]?.type !== "assistant") return
|
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")
|
expect(state.messages[0].finish).toBe("stop")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -82,7 +87,7 @@ test.skip("text ended populates assistant text content", () => {
|
||||||
data: {
|
data: {
|
||||||
sessionID,
|
sessionID,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
agent: "build",
|
agent: Agent.ID.make("build"),
|
||||||
model: {
|
model: {
|
||||||
id: ModelV2.ID.make("model"),
|
id: ModelV2.ID.make("model"),
|
||||||
providerID: ProviderV2.ID.make("provider"),
|
providerID: ProviderV2.ID.make("provider"),
|
||||||
|
|
@ -141,7 +146,7 @@ test.skip("tool completion stores completed timestamp", () => {
|
||||||
data: {
|
data: {
|
||||||
sessionID,
|
sessionID,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
agent: "build",
|
agent: Agent.ID.make("build"),
|
||||||
model: {
|
model: {
|
||||||
id: ModelV2.ID.make("model"),
|
id: ModelV2.ID.make("model"),
|
||||||
providerID: ProviderV2.ID.make("provider"),
|
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 state: SessionMessageUpdater.MemoryState = { messages: [] }
|
||||||
const sessionID = SessionID.make("session")
|
const sessionID = SessionID.make("session")
|
||||||
const id = EventV2.ID.create()
|
const id = EventV2.ID.create()
|
||||||
|
|
@ -224,15 +229,25 @@ test("compaction events reduce to compaction message only when completed", () =>
|
||||||
id,
|
id,
|
||||||
created: DateTime.makeUnsafe(0),
|
created: DateTime.makeUnsafe(0),
|
||||||
type: "session.compaction.started",
|
type: "session.compaction.started",
|
||||||
durable: durable(sessionID),
|
durable: durable(sessionID, 0, 2),
|
||||||
data: {
|
data: {
|
||||||
sessionID,
|
sessionID,
|
||||||
reason: "auto",
|
reason: "auto",
|
||||||
|
recent: "recent context",
|
||||||
},
|
},
|
||||||
} satisfies SessionEvent.Event),
|
} 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(
|
Effect.runSync(
|
||||||
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
|
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
|
||||||
|
|
@ -263,7 +278,7 @@ test("compaction events reduce to compaction message only when completed", () =>
|
||||||
id: endedID,
|
id: endedID,
|
||||||
created: DateTime.makeUnsafe(0),
|
created: DateTime.makeUnsafe(0),
|
||||||
type: "session.compaction.ended",
|
type: "session.compaction.ended",
|
||||||
durable: durable(sessionID, 1),
|
durable: durable(sessionID, 3),
|
||||||
data: {
|
data: {
|
||||||
sessionID,
|
sessionID,
|
||||||
reason: "auto",
|
reason: "auto",
|
||||||
|
|
@ -275,9 +290,10 @@ test("compaction events reduce to compaction message only when completed", () =>
|
||||||
|
|
||||||
expect(state.messages).toHaveLength(1)
|
expect(state.messages).toHaveLength(1)
|
||||||
expect(state.messages[0]).toMatchObject({
|
expect(state.messages[0]).toMatchObject({
|
||||||
id: SessionMessage.ID.fromEvent(endedID),
|
id: SessionMessage.ID.fromEvent(id),
|
||||||
type: "compaction",
|
type: "compaction",
|
||||||
reason: "auto",
|
reason: "auto",
|
||||||
|
status: "completed",
|
||||||
summary: "final summary",
|
summary: "final summary",
|
||||||
recent: "recent context",
|
recent: "recent context",
|
||||||
time: { created: DateTime.makeUnsafe(0) },
|
time: { created: DateTime.makeUnsafe(0) },
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import type { AgentApi } from "@opencode-ai/client/effect/api"
|
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 { Effect } from "effect"
|
||||||
import type { Transform } from "./registration.js"
|
import type { Transform } from "./registration.js"
|
||||||
|
|
||||||
export interface AgentDraft {
|
export interface AgentDraft {
|
||||||
list(): readonly AgentV2Info[]
|
list(): readonly AgentInfo[]
|
||||||
get(id: string): AgentV2Info | undefined
|
get(id: string): AgentInfo | undefined
|
||||||
default(id: string | undefined): void
|
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
|
remove(id: string): void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 { CatalogApi } from "@opencode-ai/client/effect/api"
|
||||||
import type { Effect } from "effect"
|
import type { Effect } from "effect"
|
||||||
import type { Transform } from "./registration.js"
|
import type { Transform } from "./registration.js"
|
||||||
|
|
||||||
export interface CatalogProviderRecord {
|
export interface CatalogProviderRecord {
|
||||||
readonly provider: ProviderV2Info
|
readonly provider: ProviderV2Info
|
||||||
readonly models: ReadonlyMap<string, ModelV2Info>
|
readonly models: ReadonlyMap<string, ModelInfo>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CatalogDraft {
|
export interface CatalogDraft {
|
||||||
|
|
@ -16,8 +16,8 @@ export interface CatalogDraft {
|
||||||
remove(providerID: string): void
|
remove(providerID: string): void
|
||||||
}
|
}
|
||||||
readonly model: {
|
readonly model: {
|
||||||
get(providerID: string, modelID: string): ModelV2Info | undefined
|
get(providerID: string, modelID: string): ModelInfo | undefined
|
||||||
update(providerID: string, modelID: string, update: (model: ModelV2Info) => void): void
|
update(providerID: string, modelID: string, update: (model: ModelInfo) => void): void
|
||||||
remove(providerID: string, modelID: string): void
|
remove(providerID: string, modelID: string): void
|
||||||
readonly default: {
|
readonly default: {
|
||||||
get(): { providerID: string; modelID: string } | undefined
|
get(): { providerID: string; modelID: string } | undefined
|
||||||
|
|
|
||||||
|
|
@ -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 { CommandApi } from "@opencode-ai/client/effect/api"
|
||||||
import type { Effect } from "effect"
|
import type { Effect } from "effect"
|
||||||
import type { Transform } from "./registration.js"
|
import type { Transform } from "./registration.js"
|
||||||
|
|
||||||
export interface CommandDraft {
|
export interface CommandDraft {
|
||||||
list(): readonly CommandV2Info[]
|
list(): readonly CommandInfo[]
|
||||||
get(name: string): CommandV2Info | undefined
|
get(name: string): CommandInfo | undefined
|
||||||
update(name: string, update: (command: CommandV2Info) => void): void
|
update(name: string, update: (command: CommandInfo) => void): void
|
||||||
remove(name: string): void
|
remove(name: string): void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 { SkillApi } from "@opencode-ai/client/effect/api"
|
||||||
import type { Effect } from "effect"
|
import type { Effect } from "effect"
|
||||||
import type { Transform } from "./registration.js"
|
import type { Transform } from "./registration.js"
|
||||||
|
|
||||||
export interface SkillDraft {
|
export interface SkillDraft {
|
||||||
source(source: SkillV2Source): void
|
source(source: SkillSource): void
|
||||||
list(): readonly SkillV2Source[]
|
list(): readonly SkillSource[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkillDomain extends SkillApi<unknown> {
|
export interface SkillDomain extends SkillApi<unknown> {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
|
import { Skill } from "@opencode-ai/schema/skill"
|
||||||
|
|
||||||
export class InvalidRequestError extends Schema.TaggedErrorClass<InvalidRequestError>()(
|
export class InvalidRequestError extends Schema.TaggedErrorClass<InvalidRequestError>()(
|
||||||
"InvalidRequestError",
|
"InvalidRequestError",
|
||||||
|
|
@ -83,7 +84,7 @@ export class MessageNotFoundError extends Schema.TaggedErrorClass<MessageNotFoun
|
||||||
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()(
|
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()(
|
||||||
"SkillNotFoundError",
|
"SkillNotFoundError",
|
||||||
{
|
{
|
||||||
skill: Schema.String,
|
skill: Skill.ID,
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
},
|
},
|
||||||
{ httpApiStatus: 404 },
|
{ httpApiStatus: 404 },
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export const MessageGroup = HttpApiGroup.make("server.message")
|
||||||
params: { sessionID: Session.ID },
|
params: { sessionID: Session.ID },
|
||||||
query: SessionMessagesQuery,
|
query: SessionMessagesQuery,
|
||||||
success: Schema.Struct({
|
success: Schema.Struct({
|
||||||
data: Schema.Array(SessionMessage.Message),
|
data: Schema.Array(SessionMessage.Info),
|
||||||
cursor: Schema.Struct({
|
cursor: Schema.Struct({
|
||||||
previous: Schema.String.pipe(Schema.optional),
|
previous: Schema.String.pipe(Schema.optional),
|
||||||
next: Schema.String.pipe(Schema.optional),
|
next: Schema.String.pipe(Schema.optional),
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,9 @@ import {
|
||||||
UnknownError,
|
UnknownError,
|
||||||
} from "../errors.js"
|
} from "../errors.js"
|
||||||
import { Agent } from "@opencode-ai/schema/agent"
|
import { Agent } from "@opencode-ai/schema/agent"
|
||||||
|
import { Skill } from "@opencode-ai/schema/skill"
|
||||||
import { Model } from "@opencode-ai/schema/model"
|
import { Model } from "@opencode-ai/schema/model"
|
||||||
import { Location } from "@opencode-ai/schema/location"
|
import { Location } from "@opencode-ai/schema/location"
|
||||||
import { Revert } from "@opencode-ai/schema/revert"
|
|
||||||
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
||||||
import { EventLog } from "@opencode-ai/schema/event-log"
|
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),
|
id: SessionMessage.ID.pipe(Schema.optional),
|
||||||
command: Schema.String,
|
command: Schema.String,
|
||||||
arguments: Schema.String.pipe(Schema.optional),
|
arguments: Schema.String.pipe(Schema.optional),
|
||||||
agent: Schema.String.pipe(Schema.optional),
|
agent: Agent.ID.pipe(Schema.optional),
|
||||||
model: Model.Ref.pipe(Schema.optional),
|
model: Model.Ref.pipe(Schema.optional),
|
||||||
files: PromptInput.Prompt.fields.files,
|
files: PromptInput.Prompt.fields.files,
|
||||||
agents: PromptInput.Prompt.fields.agents,
|
agents: PromptInput.Prompt.fields.agents,
|
||||||
|
|
@ -341,7 +341,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||||
params: { sessionID: Session.ID },
|
params: { sessionID: Session.ID },
|
||||||
payload: Schema.Struct({
|
payload: Schema.Struct({
|
||||||
id: SessionMessage.ID.pipe(Schema.optional),
|
id: SessionMessage.ID.pipe(Schema.optional),
|
||||||
skill: Schema.String,
|
skill: Skill.ID,
|
||||||
resume: Schema.Boolean.pipe(Schema.optional),
|
resume: Schema.Boolean.pipe(Schema.optional),
|
||||||
}),
|
}),
|
||||||
success: HttpApiSchema.NoContent,
|
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", {
|
HttpApiEndpoint.post("session.revert.stage", "/api/session/:sessionID/revert/stage", {
|
||||||
params: { sessionID: Session.ID },
|
params: { sessionID: Session.ID },
|
||||||
payload: Schema.Struct({ messageID: SessionMessage.ID, files: Schema.Boolean.pipe(Schema.optional) }),
|
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],
|
error: [MessageNotFoundError, SessionNotFoundError, SessionBusyError, UnknownError],
|
||||||
})
|
})
|
||||||
.middleware(sessionLocationMiddleware)
|
.middleware(sessionLocationMiddleware)
|
||||||
|
|
@ -467,7 +467,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", {
|
HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", {
|
||||||
params: { sessionID: Session.ID },
|
params: { sessionID: Session.ID },
|
||||||
success: Schema.Struct({ data: Schema.Array(SessionMessage.Message) }),
|
success: Schema.Struct({ data: Schema.Array(SessionMessage.Info) }),
|
||||||
error: [SessionNotFoundError, UnknownError],
|
error: [SessionNotFoundError, UnknownError],
|
||||||
})
|
})
|
||||||
.middleware(sessionLocationMiddleware)
|
.middleware(sessionLocationMiddleware)
|
||||||
|
|
@ -583,7 +583,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.get("session.message", "/api/session/:sessionID/message/:messageID", {
|
HttpApiEndpoint.get("session.message", "/api/session/:sessionID/message/:messageID", {
|
||||||
params: { sessionID: Session.ID, messageID: SessionMessage.ID },
|
params: { sessionID: Session.ID, messageID: SessionMessage.ID },
|
||||||
success: Schema.Struct({ data: SessionMessage.Message }),
|
success: Schema.Struct({ data: SessionMessage.Info }),
|
||||||
error: [SessionNotFoundError, MessageNotFoundError],
|
error: [SessionNotFoundError, MessageNotFoundError],
|
||||||
})
|
})
|
||||||
.middleware(sessionLocationMiddleware)
|
.middleware(sessionLocationMiddleware)
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,12 @@ import { PositiveInt, statics } from "./schema.js"
|
||||||
|
|
||||||
const Updated = ephemeral({ type: "agent.updated", schema: {} })
|
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 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([
|
export const Color = Schema.Union([
|
||||||
Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)),
|
Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)),
|
||||||
Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
|
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 interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||||
export const Info = Schema.Struct({
|
export const Info = Schema.Struct({
|
||||||
id: ID,
|
id: ID,
|
||||||
|
name: Name,
|
||||||
model: Model.Ref.pipe(optional),
|
model: Model.Ref.pipe(optional),
|
||||||
request: Provider.Request,
|
request: Provider.Request,
|
||||||
system: Schema.String.pipe(optional),
|
system: Schema.String.pipe(optional),
|
||||||
|
|
@ -32,12 +36,13 @@ export const Info = Schema.Struct({
|
||||||
steps: PositiveInt.pipe(optional),
|
steps: PositiveInt.pipe(optional),
|
||||||
permissions: Permission.Ruleset,
|
permissions: Permission.Ruleset,
|
||||||
})
|
})
|
||||||
.annotate({ identifier: "AgentV2.Info" })
|
.annotate({ identifier: "Agent.Info" })
|
||||||
.pipe(
|
.pipe(
|
||||||
statics((schema) => ({
|
statics((schema) => ({
|
||||||
empty: (id: ID) =>
|
empty: (id: ID) =>
|
||||||
schema.make({
|
schema.make({
|
||||||
id,
|
id,
|
||||||
|
name: Name.make(id),
|
||||||
request: { settings: {}, headers: {}, body: {} },
|
request: { settings: {}, headers: {}, body: {} },
|
||||||
mode: "all",
|
mode: "all",
|
||||||
hidden: false,
|
hidden: false,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { Schema } from "effect"
|
||||||
import { ephemeral, inventory } from "./event.js"
|
import { ephemeral, inventory } from "./event.js"
|
||||||
import { optional } from "./schema.js"
|
import { optional } from "./schema.js"
|
||||||
import { Model } from "./model.js"
|
import { Model } from "./model.js"
|
||||||
|
import { Agent } from "./agent.js"
|
||||||
|
|
||||||
const Updated = ephemeral({ type: "command.updated", schema: {} })
|
const Updated = ephemeral({ type: "command.updated", schema: {} })
|
||||||
|
|
||||||
|
|
@ -12,10 +13,10 @@ export const Info = Schema.Struct({
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
template: Schema.String,
|
template: Schema.String,
|
||||||
description: Schema.String.pipe(optional),
|
description: Schema.String.pipe(optional),
|
||||||
agent: Schema.String.pipe(optional),
|
agent: Agent.ID.pipe(optional),
|
||||||
model: Model.Ref.pipe(optional),
|
model: Model.Ref.pipe(optional),
|
||||||
subtask: Schema.Boolean.pipe(optional),
|
subtask: Schema.Boolean.pipe(optional),
|
||||||
}).annotate({ identifier: "CommandV2.Info" })
|
}).annotate({ identifier: "Command.Info" })
|
||||||
|
|
||||||
export const Event = {
|
export const Event = {
|
||||||
Updated,
|
Updated,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export * as Event from "./event.js"
|
export * as Event from "./event.js"
|
||||||
|
|
||||||
import { Schema } from "effect"
|
import { Schema, SchemaTransformation } from "effect"
|
||||||
import { optional } from "./schema.js"
|
import { optional } from "./schema.js"
|
||||||
import { ascending } from "./identifier.js"
|
import { ascending } from "./identifier.js"
|
||||||
import { Location } from "./location.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>>>> = {
|
type Input<Type extends string, Fields extends Readonly<Record<PropertyKey, Schema.Codec<unknown, unknown>>>> = {
|
||||||
readonly type: Type
|
readonly type: Type
|
||||||
|
readonly identifier?: string
|
||||||
readonly durable?: {
|
readonly durable?: {
|
||||||
readonly version: number
|
readonly version: number
|
||||||
readonly aggregate: string
|
readonly aggregate: string
|
||||||
|
|
@ -84,16 +85,29 @@ export function durable<
|
||||||
const Fields extends Readonly<Record<PropertyKey, Schema.Codec<unknown, unknown>>>,
|
const Fields extends Readonly<Record<PropertyKey, Schema.Codec<unknown, unknown>>>,
|
||||||
>(input: Input<Type, Fields> & { readonly durable: NonNullable<Input<Type, Fields>["durable"]> }) {
|
>(input: Input<Type, Fields> & { readonly durable: NonNullable<Input<Type, Fields>["durable"]> }) {
|
||||||
const data = Schema.Struct(input.schema)
|
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({
|
return Schema.Struct({
|
||||||
id: ID,
|
id: ID,
|
||||||
created: DateTimeUtcFromMillis,
|
created: DateTimeUtcFromMillis,
|
||||||
metadata: optional(Schema.Record(Schema.String, Schema.Unknown)),
|
metadata: optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||||
type: Schema.Literal(input.type),
|
type: Schema.Literal(input.type),
|
||||||
durable: DurableEnvelope,
|
durable,
|
||||||
location: optional(Location.Ref),
|
location: optional(Location.Ref),
|
||||||
data,
|
data,
|
||||||
})
|
})
|
||||||
.annotate({ identifier: input.type })
|
.annotate({ identifier: input.identifier ?? input.type })
|
||||||
.pipe(
|
.pipe(
|
||||||
statics(() => ({
|
statics(() => ({
|
||||||
type: input.type,
|
type: input.type,
|
||||||
|
|
@ -117,7 +131,7 @@ export function ephemeral<
|
||||||
location: optional(Location.Ref),
|
location: optional(Location.Ref),
|
||||||
data,
|
data,
|
||||||
})
|
})
|
||||||
.annotate({ identifier: input.type })
|
.annotate({ identifier: input.identifier ?? input.type })
|
||||||
.pipe(
|
.pipe(
|
||||||
statics(() => ({
|
statics(() => ({
|
||||||
type: input.type,
|
type: input.type,
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,23 @@
|
||||||
export * as FileDiff from "./file-diff.js"
|
export * as FileDiff from "./file-diff.js"
|
||||||
|
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { optional } from "./schema.js"
|
import { NonNegativeInt, optional } from "./schema.js"
|
||||||
|
|
||||||
export const Info = Schema.Struct({
|
export const Info = Schema.Struct({
|
||||||
file: optional(Schema.String),
|
file: Schema.String,
|
||||||
patch: optional(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,
|
additions: Schema.Finite,
|
||||||
deletions: Schema.Finite,
|
deletions: Schema.Finite,
|
||||||
status: optional(Schema.Literals(["added", "deleted", "modified"])),
|
status: Schema.Literals(["added", "deleted", "modified"]).pipe(optional),
|
||||||
}).annotate({ identifier: "SnapshotFileDiff" })
|
}).annotate({ identifier: "FileDiff.LegacyInfo" })
|
||||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
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
Loading…
Add table
Add a link
Reference in a new issue