mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-26 13:15:26 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9609670544 |
@@ -8,7 +8,7 @@ import type {
|
||||
QuestionRequest,
|
||||
Session,
|
||||
SessionStatus,
|
||||
FileDiffInfo,
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { State, VcsCache } from "./types"
|
||||
@@ -188,7 +188,7 @@ export function applyDirectoryEvent(input: {
|
||||
break
|
||||
}
|
||||
case "session.diff": {
|
||||
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
|
||||
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
|
||||
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
|
||||
break
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SessionStatus,
|
||||
FileDiffInfo,
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
||||
@@ -33,7 +33,7 @@ describe("app session cache", () => {
|
||||
test("dropSessionCaches clears orphaned parts without message rows", () => {
|
||||
const store: {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
||||
todo: Record<string, Todo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
@@ -67,7 +67,7 @@ describe("app session cache", () => {
|
||||
const m = msg("msg_1", "ses_1")
|
||||
const store: {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
||||
todo: Record<string, Todo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SessionStatus,
|
||||
FileDiffInfo,
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
@@ -12,7 +12,7 @@ export const SESSION_CACHE_LIMIT = 40
|
||||
|
||||
type SessionCache = {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
||||
todo: Record<string, Todo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
ReferenceInfo,
|
||||
Session,
|
||||
SessionStatus,
|
||||
FileDiffInfo,
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
VcsInfo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
@@ -51,7 +51,7 @@ export type State = {
|
||||
}
|
||||
session_working(id: string): boolean
|
||||
session_diff: {
|
||||
[sessionID: string]: FileDiffInfo[]
|
||||
[sessionID: string]: SnapshotFileDiff[]
|
||||
}
|
||||
todo: {
|
||||
[sessionID: string]: Todo[]
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
QuestionRequest,
|
||||
Session,
|
||||
SessionStatus,
|
||||
FileDiffInfo,
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { batch } from "solid-js"
|
||||
@@ -139,7 +139,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
const [data, setData] = createStore({
|
||||
info: {} as Record<string, Session | undefined>,
|
||||
session_status: {} as Record<string, SessionStatus>,
|
||||
session_diff: {} as Record<string, FileDiffInfo[]>,
|
||||
session_diff: {} as Record<string, SnapshotFileDiff[]>,
|
||||
todo: {} as Record<string, Todo[]>,
|
||||
permission: {} as Record<string, PermissionRequest[]>,
|
||||
question: {} as Record<string, QuestionRequest[]>,
|
||||
@@ -769,7 +769,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
return
|
||||
}
|
||||
case "session.diff": {
|
||||
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
|
||||
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
|
||||
setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" }))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createEffect, onCleanup, type JSX } from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { SessionReview } from "@opencode-ai/session-ui/session-review"
|
||||
import type {
|
||||
SessionReviewCommentActions,
|
||||
@@ -14,7 +14,7 @@ import type { LineComment } from "@/context/comments"
|
||||
|
||||
export type DiffStyle = "unified" | "split"
|
||||
|
||||
type ReviewDiff = FileDiffInfo | VcsFileDiff
|
||||
type ReviewDiff = SnapshotFileDiff | VcsFileDiff
|
||||
|
||||
export interface SessionReviewTabProps {
|
||||
title?: JSX.Element
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useFile, type SelectedLineRange } from "@/context/file"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
||||
import { FileTabContent } from "@/pages/session/file-tabs"
|
||||
import {
|
||||
@@ -35,9 +36,15 @@ import {
|
||||
import { setSessionHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
|
||||
type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
|
||||
|
||||
function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
|
||||
return typeof value.file === "string"
|
||||
}
|
||||
|
||||
export function SessionSidePanel(props: {
|
||||
canReview: () => boolean
|
||||
diffs: () => (FileDiffInfo | VcsFileDiff)[]
|
||||
diffs: () => (SnapshotFileDiff | VcsFileDiff)[]
|
||||
diffsReady: () => boolean
|
||||
empty: () => string
|
||||
hasReview: () => boolean
|
||||
@@ -52,6 +59,7 @@ export function SessionSidePanel(props: {
|
||||
}) {
|
||||
const layout = useLayout()
|
||||
const settings = useSettings()
|
||||
const sync = useSync()
|
||||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
@@ -80,7 +88,7 @@ export function SessionSidePanel(props: {
|
||||
})
|
||||
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
|
||||
|
||||
const diffs = createMemo(() => props.diffs())
|
||||
const diffs = createMemo(() => props.diffs().filter(renderDiff))
|
||||
const diffFiles = createMemo(() => diffs().map((d) => d.file))
|
||||
const kinds = createMemo(() => {
|
||||
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { Kind } from "@/components/file-tree-v2"
|
||||
import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
|
||||
|
||||
export type RenderDiff = FileDiffInfo | VcsFileDiff
|
||||
export type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
|
||||
|
||||
export function normalizePath(p: string) {
|
||||
return normalizeFileTreeV2Path(p)
|
||||
}
|
||||
|
||||
export function filterRenderableDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
|
||||
return typeof value.file === "string"
|
||||
}
|
||||
|
||||
export function reviewDiffKinds(diffs: RenderDiff[]) {
|
||||
const merge = (a: Kind | undefined, b: Kind) => {
|
||||
if (!a) return b
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createMemo, createSignal, Show, type JSX } from "solid-js"
|
||||
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import {
|
||||
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX,
|
||||
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
|
||||
@@ -21,11 +21,16 @@ import type {
|
||||
import FileTreeV2 from "@/components/file-tree-v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { filterReviewFiles, reviewDiffKinds, type RenderDiff } from "@/pages/session/v2/review-diff-kinds"
|
||||
import {
|
||||
filterRenderableDiff,
|
||||
filterReviewFiles,
|
||||
reviewDiffKinds,
|
||||
type RenderDiff,
|
||||
} from "@/pages/session/v2/review-diff-kinds"
|
||||
import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
|
||||
import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
|
||||
|
||||
type ReviewDiff = FileDiffInfo | VcsFileDiff
|
||||
type ReviewDiff = SnapshotFileDiff | VcsFileDiff
|
||||
|
||||
export type ReviewPanelV2Props = {
|
||||
title?: JSX.Element
|
||||
@@ -49,7 +54,7 @@ export type ReviewPanelV2Props = {
|
||||
export function ReviewPanelV2(props: ReviewPanelV2Props) {
|
||||
const sdk = useSDK()
|
||||
|
||||
const diffs = createMemo(() => props.diffs())
|
||||
const diffs = createMemo(() => props.diffs().filter(filterRenderableDiff))
|
||||
const filteredFiles = createMemo(() =>
|
||||
filterReviewFiles(
|
||||
diffs().map((diff) => diff.file),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { FileDiffInfo } from "@opencode-ai/sdk/v2"
|
||||
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
import { diffs, message } from "./diffs"
|
||||
|
||||
@@ -9,7 +9,7 @@ const item = {
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
} satisfies FileDiffInfo
|
||||
} satisfies SnapshotFileDiff
|
||||
|
||||
describe("diffs", () => {
|
||||
test("keeps valid arrays", () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { FileDiffInfo } from "@opencode-ai/sdk/v2"
|
||||
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
type Diff = FileDiffInfo
|
||||
type Diff = SnapshotFileDiff | VcsFileDiff
|
||||
|
||||
function diff(value: unknown): value is Diff {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false
|
||||
|
||||
@@ -37,8 +37,7 @@ function defaultCost(model: CurrentModel) {
|
||||
|
||||
export function runAgent(input: CurrentAgent): RunAgent {
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
name: input.id,
|
||||
description: input.description,
|
||||
mode: input.mode,
|
||||
hidden: input.hidden,
|
||||
@@ -54,7 +53,7 @@ export function runCommand(input: CurrentCommand): RunCommand {
|
||||
|
||||
export function runSkill(input: CurrentSkill): RunCommand {
|
||||
return {
|
||||
name: input.id,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
source: "skill",
|
||||
}
|
||||
|
||||
@@ -333,10 +333,10 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
.map((item) => ({
|
||||
kind: "mention",
|
||||
display: "@" + item.name,
|
||||
value: item.id,
|
||||
value: item.name,
|
||||
part: {
|
||||
type: "agent",
|
||||
name: item.id,
|
||||
name: item.name,
|
||||
source: {
|
||||
start: 0,
|
||||
end: 0,
|
||||
|
||||
@@ -281,6 +281,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
metadata: {
|
||||
structured: event.data.structured,
|
||||
content: event.data.content,
|
||||
outputPaths: event.data.outputPaths,
|
||||
result: event.data.result,
|
||||
providerCall: current.provider,
|
||||
providerResult: { executed: event.data.executed, state: event.data.resultState },
|
||||
|
||||
@@ -89,11 +89,12 @@ async function execute(input: RunCommandInput, prepared: Prepared, transport: Tr
|
||||
!explicitModel && !sessionModel
|
||||
? await client.model
|
||||
.default({ location: { directory: cwd, workspace } })
|
||||
.then((result) => (result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined))
|
||||
.then((result) =>
|
||||
result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined,
|
||||
)
|
||||
: undefined
|
||||
const model = pickRunModel(explicitModel, input.variant, sessionModel, defaultModel)
|
||||
if (input.variant && !model)
|
||||
return reportError(input, "Cannot select a variant before selecting a model", session?.id)
|
||||
if (input.variant && !model) return reportError(input, "Cannot select a variant before selecting a model", session?.id)
|
||||
if (model) {
|
||||
await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model })
|
||||
const available = await client.model.list({ location: { directory: cwd, workspace } })
|
||||
@@ -111,7 +112,9 @@ async function execute(input: RunCommandInput, prepared: Prepared, transport: Tr
|
||||
if (!session && input.title !== undefined) {
|
||||
await client.session.rename({
|
||||
sessionID: selected.id,
|
||||
title: input.title || prepared.message.slice(0, 50) + (prepared.message.length > 50 ? "..." : ""),
|
||||
title:
|
||||
input.title ||
|
||||
prepared.message.slice(0, 50) + (prepared.message.length > 50 ? "..." : ""),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -197,7 +200,7 @@ async function validateAgent(client: OpenCodeClient, directory: string, name?: s
|
||||
warning(`failed to list agents${server ? ` from ${server}` : ""}. Falling back to default agent`)
|
||||
return
|
||||
}
|
||||
const agent = agents.find((item) => item.id === name)
|
||||
const agent = agents.find((item) => item.name === name)
|
||||
if (!agent) {
|
||||
warning(`agent "${name}" not found. Falling back to default agent`)
|
||||
return
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
// backgrounding is intentionally absent: subagent jobs block the parent
|
||||
// session, so only whole-session `v2.session.background(parentID)` exists.
|
||||
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import type { SessionMessageAssistantTool, SessionMessageInfo, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import type { SessionMessage, SessionMessageAssistantTool, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { Locale } from "@opencode-ai/tui/util/locale"
|
||||
import type { FooterSubagentDetail, FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types"
|
||||
|
||||
@@ -54,7 +54,7 @@ export function legacyTool(input: {
|
||||
callID: tool.id,
|
||||
tool: tool.name,
|
||||
}
|
||||
if (tool.state.status === "streaming") {
|
||||
if (tool.state.status === "pending") {
|
||||
return {
|
||||
...base,
|
||||
state: { status: "pending", input: {}, raw: tool.state.input },
|
||||
@@ -83,6 +83,7 @@ export function legacyTool(input: {
|
||||
metadata: {
|
||||
structured: tool.state.structured,
|
||||
content: tool.state.content,
|
||||
outputPaths: tool.state.outputPaths,
|
||||
result: tool.state.result,
|
||||
providerCall,
|
||||
providerResult,
|
||||
@@ -178,7 +179,7 @@ export type SubagentTrackerInput = {
|
||||
export type SubagentTracker = {
|
||||
main(event: V2Event): void
|
||||
foreign(sessionID: string, event: V2Event): void
|
||||
hydrate(next: { messages: SessionMessageInfo[]; active: Record<string, unknown> }): Promise<void>
|
||||
hydrate(next: { messages: SessionMessage[]; active: Record<string, unknown> }): Promise<void>
|
||||
select(sessionID: string | undefined): void
|
||||
snapshot(): FooterSubagentState
|
||||
}
|
||||
@@ -315,7 +316,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
messageID,
|
||||
tool: item,
|
||||
})
|
||||
if (item.state.status === "streaming") return
|
||||
if (item.state.status === "pending") return
|
||||
child.callIDs.add(item.id)
|
||||
if (item.state.status === "running") {
|
||||
setFrame(child, `tool:${item.id}`, toolCommit(part, "start"))
|
||||
@@ -326,7 +327,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
setFrame(child, `tool:${item.id}`, toolCommit(part, "final"))
|
||||
}
|
||||
|
||||
const rebuild = (child: ChildState, messages: SessionMessageInfo[]) => {
|
||||
const rebuild = (child: ChildState, messages: SessionMessage[]) => {
|
||||
child.frames = []
|
||||
child.text.clear()
|
||||
child.projectedText.clear()
|
||||
@@ -411,7 +412,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
for (const [id, prompt] of pendingPrompts) {
|
||||
if (!child.prompts.has(id)) child.prompts.set(id, prompt)
|
||||
}
|
||||
rebuild(child, structuredClone(response.data).toReversed() as SessionMessageInfo[])
|
||||
rebuild(child, structuredClone(response.data).toReversed() as SessionMessage[])
|
||||
for (const [id, tool] of pendingTools) {
|
||||
if (!child.finishedTools.has(id) && !child.tools.has(id)) child.tools.set(id, tool)
|
||||
}
|
||||
@@ -621,6 +622,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
input: current?.input ?? {},
|
||||
structured: event.data.structured,
|
||||
content: event.data.content,
|
||||
outputPaths: event.data.outputPaths,
|
||||
result: event.data.result,
|
||||
},
|
||||
time: {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/p
|
||||
import type {
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SessionMessageInfo,
|
||||
SessionMessage,
|
||||
SessionMessageAssistantTool,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
@@ -389,7 +389,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
messageID,
|
||||
tool: item,
|
||||
})
|
||||
if (item.state.status === "streaming") return
|
||||
if (item.state.status === "pending") return
|
||||
if (item.state.status === "running") {
|
||||
if (state.tools.get(item.id)?.running) return
|
||||
state.tools.set(item.id, {
|
||||
@@ -417,7 +417,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
])
|
||||
}
|
||||
|
||||
const renderMessage = (message: SessionMessageInfo, render: boolean, reuseVisibleWait: boolean) => {
|
||||
const renderMessage = (message: SessionMessage, render: boolean, reuseVisibleWait: boolean) => {
|
||||
if (message.type === "user") {
|
||||
const waiting = state.wait?.messageID === message.id
|
||||
if (waiting && state.wait) state.wait.promoted = true
|
||||
@@ -438,34 +438,34 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
return
|
||||
}
|
||||
if (message.type === "shell") {
|
||||
state.shellCommands.set(message.shellID, message.command)
|
||||
if (state.shellWait?.messageID === message.id) state.shellWait.callID = message.shellID
|
||||
state.shellCommands.set(message.shell.id, message.shell.command)
|
||||
if (state.shellWait?.messageID === message.id) state.shellWait.callID = message.shell.id
|
||||
const completed = message.time.completed !== undefined
|
||||
if (!render) {
|
||||
// Suppressed history: mark settled shells rendered so live redelivery
|
||||
// stays silent. A still-running shell stays unmarked and renders in
|
||||
// full when its live shell.ended event arrives.
|
||||
if (completed) {
|
||||
state.shellStarted.add(message.shellID)
|
||||
state.shellEnded.add(message.shellID)
|
||||
state.shellStarted.add(message.shell.id)
|
||||
state.shellEnded.add(message.shell.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!state.shellStarted.has(message.shellID)) {
|
||||
state.shellStarted.add(message.shellID)
|
||||
if (!state.shellStarted.has(message.shell.id)) {
|
||||
state.shellStarted.add(message.shell.id)
|
||||
write([
|
||||
shellCommit(message.shellID, message.command, {
|
||||
shellCommit(message.shell.id, message.shell.command, {
|
||||
text: "running shell",
|
||||
phase: "start",
|
||||
toolState: "running",
|
||||
}),
|
||||
])
|
||||
}
|
||||
if (completed && message.output && !state.shellEnded.has(message.shellID)) {
|
||||
state.shellEnded.add(message.shellID)
|
||||
write(shellTerminal(message.shellID, message.command, message, message.output))
|
||||
if (completed && message.output && !state.shellEnded.has(message.shell.id)) {
|
||||
state.shellEnded.add(message.shell.id)
|
||||
write(shellTerminal(message.shell.id, message.shell.command, message.shell, message.output))
|
||||
}
|
||||
if (completed && state.shellWait?.callID === message.shellID) state.shellWait.resolve()
|
||||
if (completed && state.shellWait?.callID === message.shell.id) state.shellWait.resolve()
|
||||
return
|
||||
}
|
||||
if (message.type !== "assistant") return
|
||||
@@ -534,7 +534,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
input.sdk.question.list({ sessionID: input.sessionID }),
|
||||
input.sdk.session.active(),
|
||||
])
|
||||
const projected = structuredClone(messages.data).toReversed() as SessionMessageInfo[]
|
||||
const projected = structuredClone(messages.data).toReversed() as SessionMessage[]
|
||||
for (const message of projected) renderMessage(message, next.render, next.reuseVisibleWait)
|
||||
state.permissions = permissions.map(permission)
|
||||
state.questions = questions.map(question)
|
||||
@@ -762,6 +762,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
input: current?.input ?? {},
|
||||
structured: event.data.structured,
|
||||
content: event.data.content,
|
||||
outputPaths: event.data.outputPaths,
|
||||
result: event.data.result,
|
||||
},
|
||||
time: { created: current?.started ?? event.created, ran: current?.started, completed: event.created },
|
||||
|
||||
@@ -113,7 +113,6 @@ export type FooterQueuedPrompt = {
|
||||
}
|
||||
|
||||
export type RunAgent = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
mode: "subagent" | "primary" | "all"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,7 +38,7 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () =>
|
||||
expect(ProjectV2.Directory).toBe(Project.Directory)
|
||||
expect(ProjectV2.Directories).toBe(Project.Directories)
|
||||
expect(CoreSessionInput.Admitted).toBe(SessionInput.Admitted)
|
||||
expect(CoreSessionMessage.Info).toBe(SessionMessage.Info)
|
||||
expect(CoreSessionMessage.Message).toBe(SessionMessage.Message)
|
||||
expect(Api.groups["server.session"].identifier).toBe("server.session")
|
||||
expect(Api.groups["server.project"].identifier).toBe("server.project")
|
||||
expect(Object.keys(ClientApi.groups)).toEqual(Object.keys(Api.groups))
|
||||
|
||||
+697
-683
File diff suppressed because it is too large
Load Diff
@@ -8,8 +8,6 @@ import { State } from "./state"
|
||||
|
||||
export const ID = Agent.ID
|
||||
export type ID = typeof ID.Type
|
||||
export const Name = Agent.Name
|
||||
export type Name = Agent.Name
|
||||
export const defaultID = ID.make("build")
|
||||
|
||||
export const Color = Agent.Color
|
||||
|
||||
@@ -305,7 +305,6 @@ function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) {
|
||||
const route: AnyRoute = {
|
||||
id: `ai-sdk:${ProviderV2.packageName(info.package) ?? "unknown"}`,
|
||||
provider: ProviderID.make(info.providerID),
|
||||
providerMetadataKey: optionKey,
|
||||
protocol: "ai-sdk",
|
||||
endpoint: Endpoint.path("/", { baseURL: "https://ai-sdk.local" }),
|
||||
auth: Auth.none,
|
||||
@@ -418,7 +417,7 @@ function assistantPart(part: ContentPart): AssistantContent {
|
||||
case "media":
|
||||
return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }]
|
||||
case "reasoning":
|
||||
return [{ type: "reasoning", text: part.text, providerOptions: providerOptions(part.providerMetadata) }]
|
||||
return [{ type: "reasoning", text: part.text }]
|
||||
case "tool-call":
|
||||
return [
|
||||
{
|
||||
@@ -427,7 +426,6 @@ function assistantPart(part: ContentPart): AssistantContent {
|
||||
toolName: part.name,
|
||||
input: part.input,
|
||||
providerExecuted: part.providerExecuted,
|
||||
providerOptions: providerOptions(part.providerMetadata),
|
||||
},
|
||||
]
|
||||
case "tool-result":
|
||||
@@ -443,7 +441,6 @@ function toolResultPart(part: ContentPart): ToolResultContent[] {
|
||||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
output: toolOutput(part.result),
|
||||
providerOptions: providerOptions(part.providerMetadata),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as ConfigProviderPlugin from "./provider"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { ModelV2 } from "../../model"
|
||||
@@ -92,8 +91,8 @@ export const Plugin = define({
|
||||
input: cost.input,
|
||||
output: cost.output,
|
||||
cache: {
|
||||
read: cost.cache?.read ?? Money.USDPerMillionTokens.zero,
|
||||
write: cost.cache?.write ?? Money.USDPerMillionTokens.zero,
|
||||
read: cost.cache?.read ?? 0,
|
||||
write: cost.cache?.write ?? 0,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as ConfigProvider from "./provider"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { ModelV2 } from "../model"
|
||||
|
||||
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
|
||||
@@ -18,8 +17,8 @@ export class Request extends Schema.Class<Request>("ConfigV2.Provider.Request")(
|
||||
}) {}
|
||||
|
||||
class Cache extends Schema.Class<Cache>("ConfigV2.Model.Cost.Cache")({
|
||||
read: Money.USDPerMillionTokens.pipe(Schema.optional),
|
||||
write: Money.USDPerMillionTokens.pipe(Schema.optional),
|
||||
read: Schema.Finite.pipe(Schema.optional),
|
||||
write: Schema.Finite.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
class Cost extends Schema.Class<Cost>("ConfigV2.Model.Cost")({
|
||||
@@ -27,8 +26,8 @@ class Cost extends Schema.Class<Cost>("ConfigV2.Model.Cost")({
|
||||
type: Schema.Literal("context"),
|
||||
size: Schema.Int,
|
||||
}).pipe(Schema.optional),
|
||||
input: Money.USDPerMillionTokens,
|
||||
output: Money.USDPerMillionTokens,
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
cache: Cache.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
|
||||
-1
@@ -48,6 +48,5 @@ export const migrations = (
|
||||
import("./migration/20260705180000_rename_instructions"),
|
||||
import("./migration/20260706223930_add-session-fork"),
|
||||
import("./migration/20260707010146_durable_session_inbox"),
|
||||
import("./migration/20260707120000_migrate_prelaunch_v2_state"),
|
||||
])
|
||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
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"
|
||||
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { Revert } from "@opencode-ai/schema/revert"
|
||||
|
||||
export const Diff = FileDiff.Info
|
||||
export const Diff = Revert.FileDiff
|
||||
export type Diff = typeof Diff.Type
|
||||
|
||||
@@ -606,7 +606,7 @@ const layer = Layer.effect(
|
||||
file,
|
||||
])).text
|
||||
return {
|
||||
file,
|
||||
path: file,
|
||||
status,
|
||||
additions: binary ? 0 : Number(stats[0] ?? 0),
|
||||
deletions: binary ? 0 : Number(stats[1] ?? 0),
|
||||
|
||||
@@ -2,7 +2,6 @@ import path from "path"
|
||||
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { ModelsDev } from "@opencode-ai/schema/models-dev"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Global } from "./global"
|
||||
import { Flag } from "./flag/flag"
|
||||
import { Flock } from "./util/flock"
|
||||
@@ -19,10 +18,10 @@ export type CatalogModelStatus = typeof CatalogModelStatus.Type
|
||||
const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}`
|
||||
|
||||
const CostTier = Schema.Struct({
|
||||
input: Money.USDPerMillionTokens,
|
||||
output: Money.USDPerMillionTokens,
|
||||
cache_read: Schema.optional(Money.USDPerMillionTokens),
|
||||
cache_write: Schema.optional(Money.USDPerMillionTokens),
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
cache_read: Schema.optional(Schema.Finite),
|
||||
cache_write: Schema.optional(Schema.Finite),
|
||||
tier: Schema.Struct({
|
||||
type: Schema.Literal("context"),
|
||||
size: Schema.Finite,
|
||||
@@ -30,17 +29,17 @@ const CostTier = Schema.Struct({
|
||||
})
|
||||
|
||||
const Cost = Schema.Struct({
|
||||
input: Money.USDPerMillionTokens,
|
||||
output: Money.USDPerMillionTokens,
|
||||
cache_read: Schema.optional(Money.USDPerMillionTokens),
|
||||
cache_write: Schema.optional(Money.USDPerMillionTokens),
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
cache_read: Schema.optional(Schema.Finite),
|
||||
cache_write: Schema.optional(Schema.Finite),
|
||||
tiers: Schema.optional(Schema.Array(CostTier)),
|
||||
context_over_200k: Schema.optional(
|
||||
Schema.Struct({
|
||||
input: Money.USDPerMillionTokens,
|
||||
output: Money.USDPerMillionTokens,
|
||||
cache_read: Schema.optional(Money.USDPerMillionTokens),
|
||||
cache_write: Schema.optional(Money.USDPerMillionTokens),
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
cache_read: Schema.optional(Schema.Finite),
|
||||
cache_write: Schema.optional(Schema.Finite),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
@@ -125,7 +125,6 @@ export const Plugin = define({
|
||||
|
||||
yield* ctx.agent.transform((draft) => {
|
||||
draft.update(AgentV2.defaultID, (item) => {
|
||||
item.name = AgentV2.Name.make("Build")
|
||||
item.description = "The default agent. Executes tools based on configured permissions."
|
||||
item.mode = "primary"
|
||||
item.permissions.push(
|
||||
@@ -137,7 +136,6 @@ export const Plugin = define({
|
||||
})
|
||||
|
||||
draft.update(AgentV2.ID.make("plan"), (item) => {
|
||||
item.name = AgentV2.Name.make("Plan")
|
||||
item.description = "Plan mode. Disallows all edit tools."
|
||||
item.mode = "primary"
|
||||
item.permissions.push(
|
||||
@@ -157,7 +155,6 @@ export const Plugin = define({
|
||||
})
|
||||
|
||||
draft.update(AgentV2.ID.make("general"), (item) => {
|
||||
item.name = AgentV2.Name.make("General")
|
||||
item.description =
|
||||
"General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel."
|
||||
item.mode = "subagent"
|
||||
@@ -170,7 +167,6 @@ export const Plugin = define({
|
||||
})
|
||||
|
||||
draft.update(AgentV2.ID.make("explore"), (item) => {
|
||||
item.name = AgentV2.Name.make("Explore")
|
||||
item.description =
|
||||
'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.'
|
||||
item.system = PROMPT_EXPLORE
|
||||
@@ -193,7 +189,6 @@ export const Plugin = define({
|
||||
})
|
||||
|
||||
draft.update(AgentV2.ID.make("compaction"), (item) => {
|
||||
item.name = AgentV2.Name.make("Compaction")
|
||||
item.mode = "primary"
|
||||
item.hidden = true
|
||||
item.system = PROMPT_COMPACTION
|
||||
@@ -201,7 +196,6 @@ export const Plugin = define({
|
||||
})
|
||||
|
||||
draft.update(AgentV2.ID.make("title"), (item) => {
|
||||
item.name = AgentV2.Name.make("Title")
|
||||
item.mode = "primary"
|
||||
item.hidden = true
|
||||
item.system = PROMPT_TITLE
|
||||
@@ -209,7 +203,6 @@ export const Plugin = define({
|
||||
})
|
||||
|
||||
draft.update(AgentV2.ID.make("summary"), (item) => {
|
||||
item.name = AgentV2.Name.make("Summary")
|
||||
item.mode = "primary"
|
||||
item.hidden = true
|
||||
item.system = PROMPT_SUMMARY
|
||||
|
||||
@@ -31,7 +31,7 @@ import { SessionInstructions } from "../session/instructions"
|
||||
import { SessionTodo } from "../session/todo"
|
||||
import { Shell } from "../shell"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { PatchTool } from "../tool/patch"
|
||||
import { ApplyPatchTool } from "../tool/apply-patch"
|
||||
import { EditTool } from "../tool/edit"
|
||||
import { GlobTool } from "../tool/glob"
|
||||
import { GrepTool } from "../tool/grep"
|
||||
@@ -127,7 +127,7 @@ const pre = [
|
||||
SkillPlugin.Plugin,
|
||||
ModelsDevPlugin,
|
||||
...ProviderPlugins,
|
||||
PatchTool.Plugin,
|
||||
ApplyPatchTool.Plugin,
|
||||
EditTool.Plugin,
|
||||
GlobTool.Plugin,
|
||||
GrepTool.Plugin,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import type { ModelInfo } from "@opencode-ai/sdk/v2/types"
|
||||
import type { ModelV2Info } from "@opencode-ai/sdk/v2/types"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { EventV2 } from "../event"
|
||||
import { ModelV2 } from "../model"
|
||||
@@ -12,13 +11,13 @@ function released(date: string) {
|
||||
return Number.isFinite(time) ? time : 0
|
||||
}
|
||||
|
||||
function cost(input: ModelsDev.Model["cost"]): ModelInfo["cost"] {
|
||||
function cost(input: ModelsDev.Model["cost"]): ModelV2Info["cost"] {
|
||||
const base = {
|
||||
input: input?.input ?? Money.USDPerMillionTokens.zero,
|
||||
output: input?.output ?? Money.USDPerMillionTokens.zero,
|
||||
input: input?.input ?? 0,
|
||||
output: input?.output ?? 0,
|
||||
cache: {
|
||||
read: input?.cache_read ?? Money.USDPerMillionTokens.zero,
|
||||
write: input?.cache_write ?? Money.USDPerMillionTokens.zero,
|
||||
read: input?.cache_read ?? 0,
|
||||
write: input?.cache_write ?? 0,
|
||||
},
|
||||
}
|
||||
return [
|
||||
@@ -28,8 +27,8 @@ function cost(input: ModelsDev.Model["cost"]): ModelInfo["cost"] {
|
||||
input: item.input,
|
||||
output: item.output,
|
||||
cache: {
|
||||
read: item.cache_read ?? Money.USDPerMillionTokens.zero,
|
||||
write: item.cache_write ?? Money.USDPerMillionTokens.zero,
|
||||
read: item.cache_read ?? 0,
|
||||
write: item.cache_write ?? 0,
|
||||
},
|
||||
})) ?? []),
|
||||
...(input?.context_over_200k
|
||||
@@ -42,8 +41,8 @@ function cost(input: ModelsDev.Model["cost"]): ModelInfo["cost"] {
|
||||
input: input.context_over_200k.input,
|
||||
output: input.context_over_200k.output,
|
||||
cache: {
|
||||
read: input.context_over_200k.cache_read ?? Money.USDPerMillionTokens.zero,
|
||||
write: input.context_over_200k.cache_write ?? Money.USDPerMillionTokens.zero,
|
||||
read: input.context_over_200k.cache_read ?? 0,
|
||||
write: input.context_over_200k.cache_write ?? 0,
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -51,13 +50,13 @@ function cost(input: ModelsDev.Model["cost"]): ModelInfo["cost"] {
|
||||
]
|
||||
}
|
||||
|
||||
function mergeCost(base: ModelInfo["cost"], override: ModelsDev.Model["cost"] | undefined) {
|
||||
function mergeCost(base: ModelV2Info["cost"], override: ModelsDev.Model["cost"] | undefined) {
|
||||
if (!override) return base
|
||||
const next = cost(override)
|
||||
const [baseDefault, ...baseTiers] = base
|
||||
const [nextDefault, ...nextTiers] = next
|
||||
const tierKey = (item: ModelInfo["cost"][number]) => `${item.tier?.type ?? "base"}:${item.tier?.size ?? 0}`
|
||||
const merge = (left: ModelInfo["cost"][number], right: ModelInfo["cost"][number]) => ({
|
||||
const tierKey = (item: ModelV2Info["cost"][number]) => `${item.tier?.type ?? "base"}:${item.tier?.size ?? 0}`
|
||||
const merge = (left: ModelV2Info["cost"][number], right: ModelV2Info["cost"][number]) => ({
|
||||
...left,
|
||||
...right,
|
||||
tier: right.tier ?? left.tier,
|
||||
@@ -68,25 +67,12 @@ function mergeCost(base: ModelInfo["cost"], override: ModelsDev.Model["cost"] |
|
||||
const current = tiers.get(tierKey(item))
|
||||
tiers.set(tierKey(item), current ? merge(current, item) : item)
|
||||
}
|
||||
return [
|
||||
merge(
|
||||
baseDefault ?? {
|
||||
input: Money.USDPerMillionTokens.zero,
|
||||
output: Money.USDPerMillionTokens.zero,
|
||||
cache: {
|
||||
read: Money.USDPerMillionTokens.zero,
|
||||
write: Money.USDPerMillionTokens.zero,
|
||||
},
|
||||
},
|
||||
nextDefault,
|
||||
),
|
||||
...tiers.values(),
|
||||
]
|
||||
return [merge(baseDefault ?? { input: 0, output: 0, cache: { read: 0, write: 0 } }, nextDefault), ...tiers.values()]
|
||||
}
|
||||
|
||||
const OPENAI_INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"]
|
||||
|
||||
function reasoningVariants(provider: ModelsDev.Provider, model: ModelsDev.Model): NonNullable<ModelInfo["variants"]> {
|
||||
function reasoningVariants(provider: ModelsDev.Provider, model: ModelsDev.Model): NonNullable<ModelV2Info["variants"]> {
|
||||
const npm = model.provider?.npm ?? provider.npm
|
||||
const options = model.reasoning_options ?? []
|
||||
const effort = options.find((option) => option.type === "effort")
|
||||
@@ -131,7 +117,7 @@ function settingsForEffort(npm: string | undefined, effort: string): ProviderV2.
|
||||
function budgetVariants(
|
||||
npm: string | undefined,
|
||||
option: Extract<NonNullable<ModelsDev.Model["reasoning_options"]>[number], { type: "budget_tokens" }>,
|
||||
): NonNullable<ModelInfo["variants"]> {
|
||||
): NonNullable<ModelV2Info["variants"]> {
|
||||
const max = option.max
|
||||
const high =
|
||||
option.max === undefined
|
||||
@@ -160,7 +146,7 @@ function modeName(model: ModelsDev.Model, mode: string) {
|
||||
return `${model.name} ${mode.charAt(0).toUpperCase()}${mode.slice(1)}`
|
||||
}
|
||||
|
||||
function mergeVariants(model: ModelInfo, next: NonNullable<ModelInfo["variants"]>) {
|
||||
function mergeVariants(model: ModelV2Info, next: NonNullable<ModelV2Info["variants"]>) {
|
||||
const variants = model.variants ?? []
|
||||
const existing = new Map(variants.map((variant) => [variant.id, variant]))
|
||||
const nextIDs = new Set(next.map((variant) => variant.id))
|
||||
@@ -171,13 +157,13 @@ function mergeVariants(model: ModelInfo, next: NonNullable<ModelInfo["variants"]
|
||||
}
|
||||
|
||||
function applyModel(
|
||||
draft: ModelInfo,
|
||||
draft: ModelV2Info,
|
||||
model: ModelsDev.Model,
|
||||
input: {
|
||||
readonly name?: string
|
||||
readonly cost?: ModelInfo["cost"]
|
||||
readonly cost?: ModelV2Info["cost"]
|
||||
readonly request?: NonNullable<NonNullable<ModelsDev.Model["experimental"]>["modes"]>[string]["provider"]
|
||||
readonly variants?: NonNullable<ModelInfo["variants"]>
|
||||
readonly variants?: NonNullable<ModelV2Info["variants"]>
|
||||
} = {},
|
||||
) {
|
||||
draft.name = input.name ?? model.name
|
||||
|
||||
@@ -10,7 +10,6 @@ import { Integration } from "../../integration"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
import { ConfigProviderV1 } from "../../v1/config/provider"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { ConfigProviderOptionsV1 } from "../../v1/config/provider-options"
|
||||
import { ConfigV1 } from "../../v1/config/config"
|
||||
|
||||
@@ -221,23 +220,20 @@ function withoutCredentials(body: Readonly<Record<string, unknown>> | undefined)
|
||||
|
||||
function remoteCost(input: NonNullable<(typeof ConfigProviderV1.Model.Type)["cost"]>) {
|
||||
const base = {
|
||||
input: Money.USDPerMillionTokens.make(input.input),
|
||||
output: Money.USDPerMillionTokens.make(input.output),
|
||||
cache: {
|
||||
read: Money.USDPerMillionTokens.make(input.cache_read ?? 0),
|
||||
write: Money.USDPerMillionTokens.make(input.cache_write ?? 0),
|
||||
},
|
||||
input: input.input,
|
||||
output: input.output,
|
||||
cache: { read: input.cache_read ?? 0, write: input.cache_write ?? 0 },
|
||||
}
|
||||
if (!input.context_over_200k) return [base]
|
||||
return [
|
||||
base,
|
||||
{
|
||||
tier: { type: "context" as const, size: 200_000 },
|
||||
input: Money.USDPerMillionTokens.make(input.context_over_200k.input),
|
||||
output: Money.USDPerMillionTokens.make(input.context_over_200k.output),
|
||||
input: input.context_over_200k.input,
|
||||
output: input.context_over_200k.output,
|
||||
cache: {
|
||||
read: Money.USDPerMillionTokens.make(input.context_over_200k.cache_read ?? 0),
|
||||
write: Money.USDPerMillionTokens.make(input.context_over_200k.cache_write ?? 0),
|
||||
read: input.context_over_200k.cache_read ?? 0,
|
||||
write: input.context_over_200k.cache_write ?? 0,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -33,8 +33,7 @@ export const Plugin = define({
|
||||
SkillV2.EmbeddedSource.make({
|
||||
type: "embedded",
|
||||
skill: SkillV2.Info.make({
|
||||
id: SkillV2.ID.make("opencode"),
|
||||
name: SkillV2.Name.make("OpenCode"),
|
||||
name: "opencode",
|
||||
description: OpencodeDescription,
|
||||
location: AbsolutePath.make("/builtin/opencode.md"),
|
||||
content: OpencodeContent,
|
||||
@@ -45,8 +44,7 @@ export const Plugin = define({
|
||||
SkillV2.EmbeddedSource.make({
|
||||
type: "embedded",
|
||||
skill: SkillV2.Info.make({
|
||||
id: SkillV2.ID.make("report"),
|
||||
name: SkillV2.Name.make("Report"),
|
||||
name: "report",
|
||||
description: REPORT_DESCRIPTION,
|
||||
slash: true,
|
||||
location: AbsolutePath.make("/builtin/report.md"),
|
||||
@@ -105,22 +103,15 @@ const configuredPlugins = Effect.fn("SkillPlugin.configuredPlugins")(function* (
|
||||
})
|
||||
|
||||
function terminal() {
|
||||
return (
|
||||
[
|
||||
process.env.TERM_PROGRAM ? `TERM_PROGRAM=${process.env.TERM_PROGRAM}` : undefined,
|
||||
process.env.TERM ? `TERM=${process.env.TERM}` : undefined,
|
||||
process.env.COLORTERM ? `COLORTERM=${process.env.COLORTERM}` : undefined,
|
||||
]
|
||||
.filter((item): item is string => item !== undefined)
|
||||
.join(", ") || "Unavailable: terminal environment variables are not set"
|
||||
)
|
||||
return [
|
||||
process.env.TERM_PROGRAM ? `TERM_PROGRAM=${process.env.TERM_PROGRAM}` : undefined,
|
||||
process.env.TERM ? `TERM=${process.env.TERM}` : undefined,
|
||||
process.env.COLORTERM ? `COLORTERM=${process.env.COLORTERM}` : undefined,
|
||||
]
|
||||
.filter((item): item is string => item !== undefined)
|
||||
.join(", ") || "Unavailable: terminal environment variables are not set"
|
||||
}
|
||||
|
||||
function shell() {
|
||||
return (
|
||||
process.env.SHELL ??
|
||||
process.env.ComSpec ??
|
||||
process.env.COMSPEC ??
|
||||
"Unavailable: shell environment variable is not set"
|
||||
)
|
||||
return process.env.SHELL ?? process.env.ComSpec ?? process.env.COMSPEC ?? "Unavailable: shell environment variable is not set"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as SessionV2 from "./session"
|
||||
export * from "./session/schema"
|
||||
|
||||
import { Effect, Layer, Schema, Context, Stream, Scope } from "effect"
|
||||
import { DateTime, Effect, Layer, Schema, Context, Stream, Scope } from "effect"
|
||||
import { ListAnchor } from "@opencode-ai/schema/session"
|
||||
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
|
||||
import { ProjectV2 } from "./project"
|
||||
@@ -19,7 +19,6 @@ import { SessionSchema } from "./session/schema"
|
||||
import { AbsolutePath, PositiveInt, RelativePath } from "./schema"
|
||||
import { AgentV2 } from "./agent"
|
||||
import { SessionV1 } from "./v1/session"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { InstallationVersion } from "./installation/version"
|
||||
import { Slug } from "./util/slug"
|
||||
import { ProjectTable } from "./project/sql"
|
||||
@@ -35,7 +34,7 @@ import { SessionEvent } from "./session/event"
|
||||
import { SessionInput } from "./session/input"
|
||||
import { Snapshot } from "./snapshot"
|
||||
import { SessionRevert } from "./session/revert"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Revert } from "@opencode-ai/schema/revert"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Mime } from "./mime"
|
||||
import type { EventLog } from "@opencode-ai/schema/event-log"
|
||||
@@ -47,8 +46,8 @@ import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
export const RevertState = Session.Revert
|
||||
export type RevertState = Session.Revert
|
||||
export const RevertState = Revert.State
|
||||
export type RevertState = Revert.State
|
||||
|
||||
// get project -> project.locations
|
||||
//
|
||||
@@ -137,7 +136,7 @@ export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.Bus
|
||||
sessionID: SessionSchema.ID,
|
||||
}) {}
|
||||
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
|
||||
skill: SkillV2.ID,
|
||||
skill: Schema.String,
|
||||
}) {}
|
||||
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
|
||||
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
|
||||
@@ -171,14 +170,14 @@ export interface Interface {
|
||||
id: SessionMessage.ID
|
||||
direction: "previous" | "next"
|
||||
}
|
||||
}) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
|
||||
}) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
|
||||
readonly message: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
messageID: SessionMessage.ID
|
||||
}) => Effect.Effect<SessionMessage.Info | undefined>
|
||||
}) => Effect.Effect<SessionMessage.Message | undefined>
|
||||
readonly context: (
|
||||
sessionID: SessionSchema.ID,
|
||||
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
|
||||
) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
|
||||
/**
|
||||
* Durable, ordered, gap-free session log read. Replays public durable
|
||||
* session events after the exclusive `after` cursor, emits a `Synced`
|
||||
@@ -192,10 +191,7 @@ export interface Interface {
|
||||
after?: number
|
||||
follow?: boolean
|
||||
}) => Stream.Stream<SessionEvent.DurableEvent | EventLog.Synced, NotFoundError>
|
||||
readonly switchAgent: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
agent: AgentV2.ID
|
||||
}) => Effect.Effect<void, NotFoundError>
|
||||
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, NotFoundError>
|
||||
readonly switchModel: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
model: ModelV2.Ref
|
||||
@@ -213,7 +209,7 @@ export interface Interface {
|
||||
sessionID: SessionSchema.ID
|
||||
command: string
|
||||
arguments?: string
|
||||
agent?: AgentV2.ID
|
||||
agent?: string
|
||||
model?: ModelV2.Ref
|
||||
files?: PromptInput.Prompt["files"]
|
||||
agents?: PromptInput.Prompt["agents"]
|
||||
@@ -231,7 +227,7 @@ export interface Interface {
|
||||
readonly skill: (input: {
|
||||
id?: SessionMessage.ID
|
||||
sessionID: SessionSchema.ID
|
||||
skill: SkillV2.ID
|
||||
skill: string
|
||||
resume?: boolean
|
||||
}) => Effect.Effect<void, NotFoundError | SkillNotFoundError>
|
||||
readonly compact: (
|
||||
@@ -254,7 +250,7 @@ export interface Interface {
|
||||
sessionID: SessionSchema.ID
|
||||
messageID: SessionMessage.ID
|
||||
files?: boolean
|
||||
}) => Effect.Effect<Session.Revert, NotFoundError | MessageNotFoundError | BusyError | Snapshot.Error>
|
||||
}) => Effect.Effect<Revert.State, NotFoundError | MessageNotFoundError | BusyError | Snapshot.Error>
|
||||
readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError | Snapshot.Error>
|
||||
readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError>
|
||||
}
|
||||
@@ -277,7 +273,7 @@ const layer = Layer.effect(
|
||||
const scope = yield* Scope.Scope
|
||||
const activeShells = new Set<SessionSchema.ID>()
|
||||
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||
@@ -326,7 +322,7 @@ const layer = Layer.effect(
|
||||
variant: input.model.variant,
|
||||
}
|
||||
: undefined,
|
||||
cost: Money.USD.zero,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: now, updated: now },
|
||||
})
|
||||
@@ -533,7 +529,7 @@ const layer = Layer.effect(
|
||||
})
|
||||
const model = command.model ?? commandAgent?.model ?? input.model
|
||||
if (agent !== undefined && session.agent !== AgentV2.ID.make(agent))
|
||||
yield* result.switchAgent({ sessionID: input.sessionID, agent: AgentV2.ID.make(agent) })
|
||||
yield* result.switchAgent({ sessionID: input.sessionID, agent })
|
||||
if (model !== undefined) yield* result.switchModel({ sessionID: input.sessionID, model })
|
||||
|
||||
return yield* result.prompt({
|
||||
@@ -595,13 +591,12 @@ const layer = Layer.effect(
|
||||
skill: Effect.fn("V2Session.skill")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
const skills = yield* SkillV2.Service.pipe(Effect.provide(locations.get(session.location)))
|
||||
const skill = (yield* skills.list()).find((item) => item.id === input.skill)
|
||||
const skill = (yield* skills.list()).find((item) => item.name === input.skill)
|
||||
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
|
||||
yield* events.publish(
|
||||
SessionEvent.Skill.Activated,
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
text: skill.content,
|
||||
},
|
||||
@@ -689,7 +684,9 @@ const layer = Layer.effect(
|
||||
metadata: input.metadata,
|
||||
})
|
||||
if (input.resume === false) return
|
||||
yield* execution.wake(input.sessionID, { force: true })
|
||||
yield* execution
|
||||
.resume(input.sessionID)
|
||||
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||
}),
|
||||
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
|
||||
Effect.uninterruptible(execution.interrupt(sessionID)),
|
||||
|
||||
@@ -64,21 +64,19 @@ type Dependencies = {
|
||||
|
||||
export type AutoInput = {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly messages: readonly SessionMessage.Info[]
|
||||
readonly messages: readonly SessionMessage.Message[]
|
||||
readonly request: LLMRequest
|
||||
}
|
||||
|
||||
type CompactInput = {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly messages: readonly SessionMessage.Info[]
|
||||
readonly messages: readonly SessionMessage.Message[]
|
||||
readonly model: Model
|
||||
readonly inputID?: SessionMessage.ID
|
||||
}
|
||||
|
||||
export type ManualInput = {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly messages: readonly SessionMessage.Info[]
|
||||
readonly inputID: SessionMessage.ID
|
||||
readonly messages: readonly SessionMessage.Message[]
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
@@ -101,7 +99,7 @@ export const serializeToolContent = (content: SessionMessage.ToolStateCompleted[
|
||||
)
|
||||
.join("\n")
|
||||
|
||||
const serialize = (message: SessionMessage.Info) => {
|
||||
const serialize = (message: SessionMessage.Message) => {
|
||||
if (message.type === "user") {
|
||||
const files =
|
||||
message.files?.map(
|
||||
@@ -130,7 +128,7 @@ const serialize = (message: SessionMessage.Info) => {
|
||||
if (message.type === "system") return `[System update]: ${message.text}`
|
||||
if (message.type === "synthetic") return `[Synthetic context]: ${message.text}`
|
||||
if (message.type === "skill") return `[Skill activated: ${message.name}]\n${message.text}`
|
||||
if (message.type === "shell") return `[Shell]: ${message.command}\n${truncate(message.output?.output ?? "")}`
|
||||
if (message.type === "shell") return `[Shell]: ${message.shell.command}\n${truncate(message.output?.output ?? "")}`
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -149,7 +147,7 @@ const settings = (documents: readonly Config.Entry[]) => {
|
||||
}
|
||||
|
||||
const select = (
|
||||
messages: readonly SessionMessage.Info[],
|
||||
messages: readonly SessionMessage.Message[],
|
||||
tokens: number,
|
||||
): { readonly head: string; readonly recent: string } | undefined => {
|
||||
const conversation = messages
|
||||
@@ -200,7 +198,6 @@ const make = (dependencies: Dependencies) => {
|
||||
readonly context: readonly string[]
|
||||
readonly recent: string
|
||||
readonly output?: number
|
||||
readonly inputID?: SessionMessage.ID
|
||||
}) {
|
||||
const context = input.model.route.defaults.limits?.context
|
||||
if (context === undefined || context <= 0) return false
|
||||
@@ -211,8 +208,6 @@ const make = (dependencies: Dependencies) => {
|
||||
yield* dependencies.events.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID: input.sessionID,
|
||||
reason: input.reason,
|
||||
recent: input.recent,
|
||||
inputID: input.inputID,
|
||||
})
|
||||
|
||||
const chunks: string[] = []
|
||||
@@ -240,27 +235,9 @@ const make = (dependencies: Dependencies) => {
|
||||
}),
|
||||
Effect.as(true),
|
||||
Effect.catchTag("LLM.Error", () => Effect.succeed(false)),
|
||||
Effect.onInterrupt(() =>
|
||||
input.reason === "auto"
|
||||
? dependencies.events.publish(SessionEvent.Compaction.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
reason: input.reason,
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
inputID: input.inputID,
|
||||
})
|
||||
: Effect.void,
|
||||
),
|
||||
)
|
||||
const summary = chunks.join("")
|
||||
if (!summarized || failed || !summary.trim()) {
|
||||
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
|
||||
}
|
||||
if (!summarized || failed || !summary.trim()) return false
|
||||
yield* dependencies.events.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
reason: input.reason,
|
||||
@@ -307,7 +284,6 @@ const make = (dependencies: Dependencies) => {
|
||||
),
|
||||
recent: forcedShortContext ? "" : selected.recent,
|
||||
output: input.output,
|
||||
inputID: input.inputID,
|
||||
})
|
||||
})
|
||||
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: CompactInput) {
|
||||
@@ -351,7 +327,6 @@ export const layer = Layer.effect(
|
||||
sessionID: input.session.id,
|
||||
messages: input.messages,
|
||||
model: resolved.model,
|
||||
inputID: input.inputID,
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Context, Effect, Layer } from "effect"
|
||||
import { LayerNode } from "../effect/layer-node"
|
||||
import { Node } from "../effect/app-node"
|
||||
import { SessionRunner } from "./runner/index"
|
||||
import type { SessionRunCoordinator } from "./run-coordinator"
|
||||
import { SessionSchema } from "./schema"
|
||||
|
||||
export interface Interface {
|
||||
@@ -12,8 +11,8 @@ export interface Interface {
|
||||
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
|
||||
/** Starts execution while idle or joins the active execution. */
|
||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
|
||||
/** Registers newly recorded work. Repeated wakeups may coalesce. Force runs even without pending input. */
|
||||
readonly wake: (sessionID: SessionSchema.ID, options?: SessionRunCoordinator.WakeOptions) => Effect.Effect<void>
|
||||
/** Registers newly recorded work. Repeated wakeups may coalesce. */
|
||||
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
|
||||
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
|
||||
|
||||
@@ -8,7 +8,7 @@ import { InstructionCheckpointTable, SessionMessageTable } from "./sql"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||
|
||||
export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return yield* db
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DateTime, Schema } from "effect"
|
||||
import { DateTime } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { Location } from "../location"
|
||||
import { ModelV2 } from "../model"
|
||||
@@ -9,10 +9,7 @@ import { WorkspaceV2 } from "../workspace"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionTable } from "./sql"
|
||||
import { SessionMessage } from "./message"
|
||||
import { PersistedRevert } from "@opencode-ai/schema/session-revert"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
|
||||
const decodeRevert = Schema.decodeUnknownSync(PersistedRevert)
|
||||
import { Snapshot } from "../snapshot"
|
||||
|
||||
export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.Info {
|
||||
return SessionSchema.Info.make({
|
||||
@@ -34,7 +31,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
|
||||
variant: ModelV2.VariantID.make(row.model.variant ?? "default"),
|
||||
}
|
||||
: undefined,
|
||||
cost: Money.USD.make(row.cost),
|
||||
cost: row.cost,
|
||||
tokens: {
|
||||
input: row.tokens_input,
|
||||
output: row.tokens_output,
|
||||
@@ -49,7 +46,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
|
||||
workspaceID: row.workspace_id ? WorkspaceV2.ID.make(row.workspace_id) : undefined,
|
||||
}),
|
||||
subpath: row.path ? RelativePath.make(row.path) : undefined,
|
||||
revert: row.revert ? decodeRevert(row.revert) : undefined,
|
||||
revert: row.revert ? { ...row.revert, messageID: SessionMessage.ID.make(row.revert.messageID) } : undefined,
|
||||
time: {
|
||||
created: DateTime.makeUnsafe(row.time_created),
|
||||
updated: DateTime.makeUnsafe(row.time_updated),
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as SessionInput from "./input"
|
||||
|
||||
import { and, asc, eq, isNull } from "drizzle-orm"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import { Admitted, Compaction, Delivery, Info, PromptEntry } from "@opencode-ai/schema/session-input"
|
||||
import { Admitted, Compaction, Delivery, Entry, PromptEntry } from "@opencode-ai/schema/session-input"
|
||||
import type { Database } from "../database/database"
|
||||
import type { EventV2 } from "../event"
|
||||
import { KeyedMutex } from "../effect/keyed-mutex"
|
||||
@@ -14,7 +14,7 @@ import { SessionInputTable, SessionMessageTable } from "./sql"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
export { Admitted, Compaction, Delivery, Info, PromptEntry }
|
||||
export { Admitted, Compaction, Delivery, Entry, PromptEntry }
|
||||
|
||||
const decodePrompt = Schema.decodeUnknownSync(Prompt)
|
||||
const encodePrompt = Schema.encodeSync(Prompt)
|
||||
@@ -24,7 +24,7 @@ export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict
|
||||
id: SessionMessage.ID,
|
||||
}) {}
|
||||
|
||||
const fromRow = (row: typeof SessionInputTable.$inferSelect): Info => {
|
||||
const fromRow = (row: typeof SessionInputTable.$inferSelect): Entry => {
|
||||
const base = {
|
||||
admittedSeq: row.admitted_seq,
|
||||
id: SessionMessage.ID.make(row.id),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
|
||||
export type MemoryState = {
|
||||
messages: SessionMessage.Info[]
|
||||
messages: SessionMessage.Message[]
|
||||
}
|
||||
|
||||
export interface Adapter {
|
||||
@@ -14,13 +14,13 @@ export interface Adapter {
|
||||
messageID: SessionMessage.ID,
|
||||
) => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
|
||||
readonly getShell: (
|
||||
shellID: SessionMessage.Shell["shellID"],
|
||||
shellID: SessionMessage.Shell["shell"]["id"],
|
||||
) => Effect.Effect<SessionMessage.Shell | undefined, never, never>
|
||||
readonly getCompaction: () => Effect.Effect<SessionMessage.Compaction | undefined, never, never>
|
||||
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void, never, never>
|
||||
readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void, never, never>
|
||||
readonly updateCompaction: (compaction: SessionMessage.Compaction) => Effect.Effect<void, never, never>
|
||||
readonly appendMessage: (message: SessionMessage.Info) => Effect.Effect<void, never, never>
|
||||
readonly appendMessage: (message: SessionMessage.Message) => Effect.Effect<void, never, never>
|
||||
}
|
||||
|
||||
export function memory(state: MemoryState): Adapter {
|
||||
@@ -29,7 +29,9 @@ export function memory(state: MemoryState): Adapter {
|
||||
const shellIndex = (messageID: SessionMessage.ID) =>
|
||||
state.messages.findLastIndex((message) => message.id === messageID)
|
||||
const compactionIndex = () =>
|
||||
state.messages.findLastIndex((message) => message.type === "compaction" && message.status === "running")
|
||||
state.messages.findLastIndex(
|
||||
(message) => message.type === "compaction" && (message.status === "queued" || message.status === "running"),
|
||||
)
|
||||
// A newer step supersedes stale incomplete rows; never resume an older assistant projection.
|
||||
const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant")
|
||||
|
||||
@@ -62,7 +64,7 @@ export function memory(state: MemoryState): Adapter {
|
||||
getShell(shellID) {
|
||||
return Effect.sync(() => {
|
||||
return state.messages.find((message): message is SessionMessage.Shell => {
|
||||
return message.type === "shell" && message.shellID === shellID
|
||||
return message.type === "shell" && message.shell.id === shellID
|
||||
})
|
||||
})
|
||||
},
|
||||
@@ -184,13 +186,13 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "system",
|
||||
text: event.data.text,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
),
|
||||
"session.synthetic": (event) => {
|
||||
return adapter.appendMessage(
|
||||
SessionMessage.Synthetic.make({
|
||||
sessionID: event.data.sessionID,
|
||||
text: event.data.text,
|
||||
description: event.data.description,
|
||||
metadata: event.data.metadata,
|
||||
@@ -205,10 +207,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
SessionMessage.Skill.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "skill",
|
||||
skill: event.data.id,
|
||||
name: event.data.name,
|
||||
text: event.data.text,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
)
|
||||
@@ -219,9 +219,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "shell",
|
||||
metadata: event.metadata,
|
||||
shellID: event.data.shell.id,
|
||||
command: event.data.shell.command,
|
||||
status: event.data.shell.status,
|
||||
shell: event.data.shell,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
)
|
||||
@@ -232,8 +230,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
if (currentShell) {
|
||||
yield* adapter.updateShell(
|
||||
produce(currentShell, (draft) => {
|
||||
draft.status = event.data.shell.status
|
||||
draft.exit = event.data.shell.exit
|
||||
draft.shell = castDraft(event.data.shell)
|
||||
draft.output = event.data.output
|
||||
draft.time.completed = event.created
|
||||
}),
|
||||
@@ -273,7 +270,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
type: "assistant",
|
||||
agent: event.data.agent,
|
||||
model: event.data.model,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
content: [],
|
||||
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
|
||||
@@ -333,7 +329,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
id: event.data.callID,
|
||||
name: event.data.name,
|
||||
time: { created: event.created },
|
||||
state: SessionMessage.ToolStateStreaming.make({ status: "streaming", input: "" }),
|
||||
state: SessionMessage.ToolStatePending.make({ status: "pending", input: "" }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -343,7 +339,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
"session.tool.input.ended": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
if (match && match.state.status === "streaming") match.state.input = event.data.text
|
||||
if (match && match.state.status === "pending") match.state.input = event.data.text
|
||||
})
|
||||
},
|
||||
"session.tool.called": (event) => {
|
||||
@@ -386,6 +382,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
input: match.state.input,
|
||||
structured: event.data.structured,
|
||||
content: [...event.data.content],
|
||||
outputPaths: event.data.outputPaths ? [...event.data.outputPaths] : [],
|
||||
result: event.data.result,
|
||||
}),
|
||||
)
|
||||
@@ -395,7 +392,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
"session.tool.failed": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
if (match && (match.state.status === "streaming" || match.state.status === "running")) {
|
||||
if (match && (match.state.status === "pending" || match.state.status === "running")) {
|
||||
match.executed = event.data.executed || match.executed === true
|
||||
match.providerResultState = event.data.resultState
|
||||
match.time.completed = event.created
|
||||
@@ -451,30 +448,31 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
}
|
||||
})
|
||||
},
|
||||
"session.compaction.admitted": () => Effect.void,
|
||||
"session.compaction.started": (event) =>
|
||||
"session.compaction.admitted": (event) =>
|
||||
adapter.appendMessage(
|
||||
SessionMessage.CompactionRunning.make({
|
||||
id: event.data.inputID ?? SessionMessage.ID.fromEvent(event.id),
|
||||
SessionMessage.Compaction.make({
|
||||
id: event.data.inputID,
|
||||
type: "compaction",
|
||||
status: "running",
|
||||
status: "queued",
|
||||
metadata: event.metadata,
|
||||
reason: event.data.reason,
|
||||
reason: "manual",
|
||||
summary: "",
|
||||
recent: event.data.recent ?? "",
|
||||
recent: "",
|
||||
time: { created: event.created },
|
||||
}),
|
||||
),
|
||||
"session.compaction.delta": (event) =>
|
||||
"session.compaction.started": (event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.data.reason !== "manual") return
|
||||
const current = yield* adapter.getCompaction()
|
||||
if (current?.status !== "running") return
|
||||
yield* adapter.updateCompaction({ ...current, summary: current.summary + event.data.text })
|
||||
if (!current) return
|
||||
yield* adapter.updateCompaction({ ...current, status: "running" })
|
||||
}),
|
||||
"session.compaction.delta": () => Effect.void,
|
||||
"session.compaction.ended": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
const current = yield* adapter.getCompaction()
|
||||
if (current?.status === "running") {
|
||||
const current = event.data.reason === "manual" ? yield* adapter.getCompaction() : undefined
|
||||
if (current) {
|
||||
yield* adapter.updateCompaction({
|
||||
...current,
|
||||
status: "completed",
|
||||
@@ -498,20 +496,11 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
)
|
||||
})
|
||||
},
|
||||
"session.compaction.failed": (event) =>
|
||||
"session.compaction.failed": () =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* adapter.getCompaction()
|
||||
const failed = SessionMessage.CompactionFailed.make({
|
||||
id: current?.id ?? event.data.inputID ?? SessionMessage.ID.fromEvent(event.id),
|
||||
type: "compaction",
|
||||
status: "failed",
|
||||
metadata: current?.metadata ?? event.metadata,
|
||||
reason: event.data.reason,
|
||||
error: event.data.error,
|
||||
time: current?.time ?? { created: event.created },
|
||||
})
|
||||
if (current?.status === "running") return yield* adapter.updateCompaction(failed)
|
||||
yield* adapter.appendMessage(failed)
|
||||
if (!current) return
|
||||
yield* adapter.updateCompaction({ ...current, status: "failed" })
|
||||
}),
|
||||
"session.revert.staged": () => Effect.void,
|
||||
"session.revert.cleared": () => Effect.void,
|
||||
|
||||
@@ -24,14 +24,15 @@ import {
|
||||
} from "./sql"
|
||||
import type { DeepMutable } from "../schema"
|
||||
import { Slug } from "../util/slug"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
|
||||
type MessageEvent = Exclude<CurrentDurableEvent, typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type>
|
||||
type MessageEvent = Exclude<
|
||||
SessionEvent.DurableEvent,
|
||||
typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type
|
||||
>
|
||||
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Message)
|
||||
|
||||
export class SessionAlreadyProjected extends Error {}
|
||||
|
||||
@@ -86,14 +87,7 @@ function sessionRow(info: SessionV1.SessionInfo): typeof SessionTable.$inferInse
|
||||
tokens_reasoning: (info.tokens ?? { reasoning: 0 }).reasoning,
|
||||
tokens_cache_read: (info.tokens ?? { cache: { read: 0 } }).cache.read,
|
||||
tokens_cache_write: (info.tokens ?? { cache: { write: 0 } }).cache.write,
|
||||
revert: info.revert
|
||||
? {
|
||||
messageID: SessionMessage.ID.make(info.revert.messageID),
|
||||
partID: info.revert.partID,
|
||||
snapshot: info.revert.snapshot,
|
||||
diff: info.revert.diff,
|
||||
}
|
||||
: null,
|
||||
revert: info.revert ? { ...info.revert, messageID: SessionMessage.ID.make(info.revert.messageID) } : null,
|
||||
permission: info.permission ? [...info.permission] : undefined,
|
||||
time_created: info.time.created,
|
||||
time_updated: info.time.updated,
|
||||
@@ -157,7 +151,7 @@ const publishSessionUsage = Effect.fn("SessionProjector.publishUsage")(function*
|
||||
if (!row) return
|
||||
yield* events.publish(SessionEvent.UsageUpdated, {
|
||||
sessionID,
|
||||
cost: Money.USD.make(row.cost),
|
||||
cost: row.cost,
|
||||
tokens: {
|
||||
input: row.input,
|
||||
output: row.output,
|
||||
@@ -263,7 +257,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
eq(SessionMessageTable.session_id, event.data.parentID),
|
||||
gt(SessionMessageTable.seq, cursor),
|
||||
lt(SessionMessageTable.seq, copiedSeq + 1),
|
||||
sql`${SessionMessageTable.type} != 'compaction' or json_extract(${SessionMessageTable.data}, '$.status') != 'running'`,
|
||||
sql`${SessionMessageTable.type} != 'compaction' or json_extract(${SessionMessageTable.data}, '$.status') not in ('queued', 'running')`,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
@@ -286,7 +280,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
seq: row.seq,
|
||||
time_created: row.time_created,
|
||||
time_updated: row.time_updated,
|
||||
data: row.data,
|
||||
data: row.type === "synthetic" ? { ...row.data, sessionID: event.data.sessionID } : row.data,
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -342,7 +336,7 @@ function run(db: DatabaseService, event: MessageEvent) {
|
||||
return Effect.gen(function* () {
|
||||
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decodeMessage({ ...row.data, id: row.id, type: row.type })
|
||||
const updateMessage = (message: SessionMessage.Info) => {
|
||||
const updateMessage = (message: SessionMessage.Message) => {
|
||||
if (event.durable === undefined)
|
||||
return Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
||||
const encoded = encodeMessage(message)
|
||||
@@ -359,7 +353,7 @@ function run(db: DatabaseService, event: MessageEvent) {
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
const appendMessage = (message: SessionMessage.Info) => insertMessage(db, event, message)
|
||||
const appendMessage = (message: SessionMessage.Message) => insertMessage(db, event, message)
|
||||
const adapter: SessionMessageUpdater.Adapter = {
|
||||
getModel() {
|
||||
return db
|
||||
@@ -418,7 +412,7 @@ function run(db: DatabaseService, event: MessageEvent) {
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, event.data.sessionID),
|
||||
eq(SessionMessageTable.type, "shell"),
|
||||
sql`json_extract(${SessionMessageTable.data}, '$.shellID') = ${shellID}`,
|
||||
sql`json_extract(${SessionMessageTable.data}, '$.shell.id') = ${shellID}`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
@@ -439,7 +433,7 @@ function run(db: DatabaseService, event: MessageEvent) {
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, event.data.sessionID),
|
||||
eq(SessionMessageTable.type, "compaction"),
|
||||
sql`json_extract(${SessionMessageTable.data}, '$.status') = 'running'`,
|
||||
sql`json_extract(${SessionMessageTable.data}, '$.status') in ('queued', 'running')`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
@@ -460,7 +454,7 @@ function run(db: DatabaseService, event: MessageEvent) {
|
||||
})
|
||||
}
|
||||
|
||||
function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, message: SessionMessage.Info) {
|
||||
function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, message: SessionMessage.Message) {
|
||||
if (event.durable === undefined) return Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
||||
const encoded = encodeMessage(message)
|
||||
const { id, type, ...data } = encoded
|
||||
@@ -667,12 +661,14 @@ const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
if (event.durable === undefined)
|
||||
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
||||
yield* SessionInput.projectCompactionAdmitted(db, {
|
||||
const admitted = yield* SessionInput.projectCompactionAdmitted(db, {
|
||||
admittedSeq: event.durable.seq,
|
||||
id: event.data.inputID,
|
||||
sessionID: event.data.sessionID,
|
||||
timeCreated: event.created,
|
||||
})
|
||||
if (admitted.id !== event.data.inputID) return
|
||||
yield* run(db, event)
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
|
||||
@@ -680,7 +676,15 @@ const layer = Layer.effectDiscard(
|
||||
yield* events.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.InstructionsUpdated, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Skill.Activated, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Skill.Activated, (event) =>
|
||||
insertMessage(db, event, {
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "skill",
|
||||
name: event.data.name,
|
||||
text: event.data.text,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Step.Started, (event) => run(db, event))
|
||||
@@ -726,26 +730,22 @@ const layer = Layer.effectDiscard(
|
||||
yield* run(db, event)
|
||||
if (event.durable === undefined)
|
||||
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
||||
if (event.data.reason === "manual")
|
||||
yield* SessionInput.settleCompaction(db, {
|
||||
sessionID: event.data.sessionID,
|
||||
handledSeq: event.durable.seq,
|
||||
})
|
||||
yield* SessionInput.settleCompaction(db, {
|
||||
sessionID: event.data.sessionID,
|
||||
handledSeq: event.durable.seq,
|
||||
})
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.RevertEvent.Staged, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const revert = event.data.revert
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
revert: { ...revert, files: revert.files ? [...revert.files] : undefined },
|
||||
time_updated: DateTime.toEpochMillis(event.created),
|
||||
})
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
revert: { ...event.data.revert, files: event.data.revert.files ? [...event.data.revert.files] : undefined },
|
||||
time_updated: DateTime.toEpochMillis(event.created),
|
||||
})
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie, Effect.asVoid),
|
||||
)
|
||||
yield* events.project(SessionEvent.RevertEvent.Cleared, (event) =>
|
||||
db
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as SessionRevert from "./revert"
|
||||
|
||||
import { and, asc, eq, gt } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { RelativePath } from "../schema"
|
||||
@@ -46,7 +46,7 @@ const plan = Effect.fn("SessionRevert.plan")(function* (input: BoundaryInput) {
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||
const files = new Map<RelativePath, Snapshot.ID>()
|
||||
for (const row of rows) {
|
||||
const message = yield* decode({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie)
|
||||
@@ -70,7 +70,7 @@ export const stage = Effect.fn("SessionRevert.stage")(function* (input: {
|
||||
const next = yield* plan({ sessionID: input.session.id, messageID: input.messageID })
|
||||
const restore = new Map<RelativePath, Snapshot.ID>()
|
||||
if (original) {
|
||||
for (const file of input.session.revert?.files ?? []) restore.set(RelativePath.make(file.file), original)
|
||||
for (const file of input.session.revert?.files ?? []) restore.set(file.path, original)
|
||||
}
|
||||
if (input.files !== false) for (const [file, tree] of next) restore.set(file, tree)
|
||||
if (restore.size) yield* snapshot.restore({ files: restore })
|
||||
@@ -81,6 +81,10 @@ export const stage = Effect.fn("SessionRevert.stage")(function* (input: {
|
||||
const revert = {
|
||||
messageID: input.messageID,
|
||||
snapshot: original,
|
||||
diff: files
|
||||
.map((file) => file.patch)
|
||||
.join("")
|
||||
.trim(),
|
||||
files,
|
||||
} satisfies SessionSchema.Info["revert"]
|
||||
yield* events.publish(SessionEvent.RevertEvent.Staged, {
|
||||
@@ -96,7 +100,7 @@ export const clear = Effect.fn("SessionRevert.clear")(function* (session: Sessio
|
||||
const original = session.revert.snapshot ? Snapshot.ID.make(session.revert.snapshot) : undefined
|
||||
if (original)
|
||||
yield* snapshot.restore({
|
||||
files: new Map((session.revert.files ?? []).map((file) => [RelativePath.make(file.file), original])),
|
||||
files: new Map((session.revert.files ?? []).map((file) => [file.path, original])),
|
||||
})
|
||||
const events = yield* EventV2.Service
|
||||
yield* events.publish(SessionEvent.RevertEvent.Cleared, {
|
||||
|
||||
@@ -9,15 +9,13 @@ export interface Coordinator<Key, E, Reason = never> {
|
||||
/** Starts an execution while idle, or joins the active execution and returns its exit. */
|
||||
readonly run: (key: Key) => Effect.Effect<void, E>
|
||||
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
|
||||
readonly wake: (key: Key, options?: WakeOptions) => Effect.Effect<void>
|
||||
readonly wake: (key: Key) => Effect.Effect<void>
|
||||
/** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */
|
||||
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void>
|
||||
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
|
||||
readonly awaitIdle: (key: Key) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export type WakeOptions = { readonly force?: boolean }
|
||||
|
||||
/**
|
||||
* One execution is a busy period for one key: one fiber that drains from the first wake
|
||||
* until the key would stay idle. `pendingWake` is the doorbell: work recorded during the
|
||||
@@ -29,7 +27,6 @@ type Execution<E, Reason> = {
|
||||
readonly done: Deferred.Deferred<void, E>
|
||||
owner?: Fiber.Fiber<void>
|
||||
pendingWake: boolean
|
||||
pendingForce: boolean
|
||||
stopping: boolean
|
||||
settling: boolean
|
||||
interruptionReason?: Reason
|
||||
@@ -66,10 +63,8 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
Effect.suspend(() => {
|
||||
if (execution.stopping || !execution.pendingWake) return Effect.void
|
||||
execution.pendingWake = false
|
||||
const force = execution.pendingForce
|
||||
execution.pendingForce = false
|
||||
// Trampoline so drains that complete synchronously cannot grow the stack.
|
||||
return Effect.yieldNow.pipe(Effect.andThen(loop(key, execution, force)))
|
||||
return Effect.yieldNow.pipe(Effect.andThen(loop(key, execution, false)))
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -78,7 +73,6 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
const execution: Execution<E, Reason> = {
|
||||
done: Deferred.makeUnsafe<void, E>(),
|
||||
pendingWake: false,
|
||||
pendingForce: false,
|
||||
stopping: false,
|
||||
settling: false,
|
||||
}
|
||||
@@ -106,7 +100,7 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
// A doorbell that survives the execution loop (rung after the loop decided to end, or
|
||||
// during failure or interruption cleanup) starts a fresh execution for the remaining work.
|
||||
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
|
||||
if (execution.pendingWake) start(key, execution.pendingForce)
|
||||
if (execution.pendingWake) start(key, false)
|
||||
else executions.delete(key)
|
||||
Deferred.doneUnsafe(execution.done, exit)
|
||||
}
|
||||
@@ -122,16 +116,14 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
return restore(Deferred.await(start(key, true).done))
|
||||
})
|
||||
|
||||
const wake = (key: Key, options?: { readonly force?: boolean }) =>
|
||||
const wake = (key: Key) =>
|
||||
Effect.sync(() => {
|
||||
const force = options?.force === true
|
||||
const execution = executions.get(key)
|
||||
if (execution !== undefined) {
|
||||
execution.pendingWake = true
|
||||
execution.pendingForce ||= force
|
||||
return
|
||||
}
|
||||
start(key, force)
|
||||
start(key, false)
|
||||
})
|
||||
|
||||
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
|
||||
@@ -140,7 +132,6 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
if (execution?.owner === undefined || execution.stopping || execution.settling) return Effect.void
|
||||
execution.stopping = true
|
||||
execution.pendingWake = false
|
||||
execution.pendingForce = false
|
||||
execution.interruptionReason = reason
|
||||
return Fiber.interrupt(execution.owner)
|
||||
})
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
type ProviderErrorEvent,
|
||||
} from "@opencode-ai/llm"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
||||
import { AgentV2 } from "../../agent"
|
||||
import { Config } from "../../config"
|
||||
@@ -68,13 +67,13 @@ export function calculateCost(costs: ModelV2.Info["cost"], tokens: StepTokens) {
|
||||
.filter((cost) => cost.tier?.type === "context" && context > cost.tier.size)
|
||||
.toSorted((a, b) => (b.tier?.size ?? 0) - (a.tier?.size ?? 0))[0]
|
||||
const cost = tier ?? costs.find((cost) => cost.tier === undefined)
|
||||
if (!cost) return Money.USD.zero
|
||||
return Money.USD.make(
|
||||
if (!cost) return 0
|
||||
return (
|
||||
(tokens.input * cost.input +
|
||||
(tokens.output + tokens.reasoning) * cost.output +
|
||||
tokens.cache.read * cost.cache.read +
|
||||
tokens.cache.write * cost.cache.write) /
|
||||
1_000_000,
|
||||
1_000_000
|
||||
)
|
||||
}
|
||||
|
||||
@@ -166,7 +165,7 @@ const layer = Layer.effect(
|
||||
for (const message of yield* store.context(sessionID)) {
|
||||
if (message.type !== "assistant") continue
|
||||
for (const tool of message.content) {
|
||||
if (tool.type !== "tool" || (tool.state.status !== "streaming" && tool.state.status !== "running")) continue
|
||||
if (tool.type !== "tool" || (tool.state.status !== "pending" && tool.state.status !== "running")) continue
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID,
|
||||
assistantMessageID: message.id,
|
||||
@@ -233,7 +232,6 @@ const layer = Layer.effect(
|
||||
}
|
||||
const resolved = yield* models.resolve(session)
|
||||
const model = resolved.model
|
||||
const providerMetadataKey = model.route.providerMetadataKey ?? model.provider
|
||||
const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq)
|
||||
const context = entries.map((entry) => entry.message)
|
||||
const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps
|
||||
@@ -251,7 +249,7 @@ const layer = Layer.effect(
|
||||
.filter((part): part is string => part !== undefined && part.length > 0)
|
||||
.map(SystemPart.make),
|
||||
messages: [
|
||||
...toLLMMessages(context, resolved.ref, providerMetadataKey),
|
||||
...toLLMMessages(context, resolved.ref),
|
||||
...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : []),
|
||||
],
|
||||
tools: toolMaterialization?.definitions ?? [],
|
||||
@@ -294,7 +292,7 @@ const layer = Layer.effect(
|
||||
// The selected catalog identity, not model.id: route-level ids are provider API
|
||||
// model ids (for example gpt-5.5-fast resolves to api id gpt-5.5).
|
||||
model: resolved.ref,
|
||||
providerMetadataKey,
|
||||
provider: model.provider,
|
||||
snapshot: startSnapshot,
|
||||
assistantMessageID,
|
||||
})
|
||||
@@ -302,7 +300,8 @@ const layer = Layer.effect(
|
||||
// Durable publishes are serialized so tool fibers and step settlement never interleave
|
||||
// mid-event.
|
||||
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
|
||||
const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
|
||||
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = [], error?: SessionError.Error) =>
|
||||
serialized(publisher.publish(event, outputPaths, error))
|
||||
let overflowFailure: ProviderErrorEvent | undefined
|
||||
const providerStream = llm.stream(hookedRequest).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
@@ -316,25 +315,10 @@ const layer = Layer.effect(
|
||||
}
|
||||
yield* publish(event)
|
||||
if (event.type !== "tool-call" || event.providerExecuted) return
|
||||
if (!toolMaterialization) {
|
||||
if (!toolMaterialization || !advertisedTools.has(event.name)) {
|
||||
yield* serialized(
|
||||
publisher.failUnsettledTools({
|
||||
type: "tool.execution",
|
||||
message: "Tools are disabled after the maximum agent steps",
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
// A request hook hid this registered tool from the current request. Fail only
|
||||
// this call durably and continue so the model can react, instead of executing
|
||||
// a tool that was not advertised. Unregistered tools flow through settle, which
|
||||
// durably fails them as unknown.
|
||||
if (!advertisedTools.has(event.name) && availableTools.has(event.name)) {
|
||||
needsContinuation = true
|
||||
yield* publish(
|
||||
LLMEvent.toolError({
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
message: `Tool is not available for this request: ${event.name}`,
|
||||
}),
|
||||
)
|
||||
@@ -360,6 +344,7 @@ const layer = Layer.effect(
|
||||
result: settlement.result,
|
||||
output: settlement.output,
|
||||
}),
|
||||
settlement.outputPaths ?? [],
|
||||
settlement.error,
|
||||
).pipe(
|
||||
Effect.andThen(
|
||||
@@ -599,30 +584,12 @@ const layer = Layer.effect(
|
||||
return yield* compaction.compactManual({
|
||||
session,
|
||||
messages: yield* store.context(sessionID),
|
||||
inputID: pending.id,
|
||||
})
|
||||
}),
|
||||
).pipe(Effect.exit)
|
||||
if (Exit.isSuccess(compacted) && compacted.value) return true
|
||||
if (Exit.isFailure(compacted)) {
|
||||
const unsettled = yield* SessionInput.pendingCompaction(db, sessionID)
|
||||
if (unsettled)
|
||||
yield* events.publish(SessionEvent.Compaction.Failed, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
error: { type: "compaction.failed", message: Cause.pretty(compacted.cause) },
|
||||
inputID: unsettled.id,
|
||||
})
|
||||
return yield* Effect.failCause(compacted.cause)
|
||||
}
|
||||
const unsettled = yield* SessionInput.pendingCompaction(db, sessionID)
|
||||
if (unsettled)
|
||||
yield* events.publish(SessionEvent.Compaction.Failed, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
error: { type: "compaction.failed", message: "Compaction could not start" },
|
||||
inputID: unsettled.id,
|
||||
})
|
||||
yield* events.publish(SessionEvent.Compaction.Failed, { sessionID })
|
||||
if (Exit.isFailure(compacted)) return yield* Effect.failCause(compacted.cause)
|
||||
return true
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -6,16 +6,13 @@ import { SessionEvent } from "../event"
|
||||
import { SessionMessage } from "../message"
|
||||
import { SessionSchema } from "../schema"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { AgentV2 } from "../../agent"
|
||||
import { Snapshot } from "../../snapshot"
|
||||
|
||||
type Input = {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly agent: AgentV2.ID
|
||||
readonly agent: string
|
||||
readonly model: ModelV2.Ref
|
||||
readonly providerMetadataKey: string
|
||||
readonly snapshot?: Snapshot.ID
|
||||
readonly provider: string
|
||||
readonly snapshot?: string
|
||||
readonly assistantMessageID?: SessionMessage.ID
|
||||
}
|
||||
|
||||
@@ -87,9 +84,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
assistantMessageID ??= SessionMessage.ID.create()
|
||||
stepStarted = true
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
...input,
|
||||
assistantMessageID,
|
||||
snapshot: input.snapshot,
|
||||
})
|
||||
@@ -99,7 +94,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
assistantMessageID === undefined
|
||||
? Effect.die(new Error("Tool event before assistant step start"))
|
||||
: Effect.succeed(assistantMessageID)
|
||||
const providerState = (metadata: ProviderMetadata | undefined) => metadata?.[input.providerMetadataKey]
|
||||
const providerState = (metadata: ProviderMetadata | undefined) => metadata?.[input.provider]
|
||||
|
||||
const fragments = (
|
||||
name: string,
|
||||
ended: (id: string, value: string, ordinal: number, state?: Record<string, unknown>) => Effect.Effect<void>,
|
||||
@@ -230,7 +226,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
})
|
||||
|
||||
const publishStepFailure = Effect.fnUntraced(function* (usage?: {
|
||||
readonly cost: Money.USD
|
||||
readonly cost: number
|
||||
readonly tokens: ReturnType<typeof tokens>
|
||||
}) {
|
||||
if (stepFailed || stepFailure === undefined) return
|
||||
@@ -269,7 +265,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(new Error(`Unknown tool call: ${callID}`))
|
||||
}
|
||||
|
||||
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent, error?: SessionError.Error) {
|
||||
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (
|
||||
event: LLMEvent,
|
||||
outputPaths: ReadonlyArray<string> = [],
|
||||
error?: SessionError.Error,
|
||||
) {
|
||||
switch (event.type) {
|
||||
case "step-start":
|
||||
yield* startAssistant()
|
||||
@@ -353,13 +353,14 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
if (tool.called) return yield* Effect.die(new Error(`Duplicate tool call: ${event.id}`))
|
||||
tool.called = true
|
||||
tool.providerExecuted = event.providerExecuted === true
|
||||
const state = providerState(event.providerMetadata)
|
||||
yield* events.publish(SessionEvent.Tool.Called, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID: event.id,
|
||||
input: record(event.input),
|
||||
executed: tool.providerExecuted,
|
||||
state: providerState(event.providerMetadata),
|
||||
state,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -394,6 +395,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID: event.id,
|
||||
...result,
|
||||
outputPaths,
|
||||
...(executed ? { result: event.result } : {}),
|
||||
executed,
|
||||
resultState,
|
||||
|
||||
@@ -69,7 +69,7 @@ const providerMetadata = (
|
||||
): ProviderMetadata | undefined => (state === undefined ? undefined : { [provider]: state })
|
||||
|
||||
const toolInput = (tool: SessionMessage.AssistantTool) =>
|
||||
tool.state.status === "streaming"
|
||||
tool.state.status === "pending"
|
||||
? Option.getOrElse(decodeToolInput(tool.state.input), () => tool.state.input)
|
||||
: tool.state.input
|
||||
|
||||
@@ -113,7 +113,7 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
|
||||
}
|
||||
}
|
||||
|
||||
const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, providerMetadataKey: string) => {
|
||||
const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => {
|
||||
const sameModel =
|
||||
String(message.model.providerID) === String(model.providerID) && String(message.model.id) === String(model.id)
|
||||
const reuseProviderMetadata = sameModel && message.error === undefined
|
||||
@@ -125,7 +125,7 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, provid
|
||||
{
|
||||
type: "reasoning",
|
||||
text: item.text,
|
||||
providerMetadata: providerMetadata(providerMetadataKey, item.state),
|
||||
providerMetadata: providerMetadata(model.providerID, item.state),
|
||||
},
|
||||
]
|
||||
: item.text.length > 0
|
||||
@@ -133,13 +133,13 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, provid
|
||||
: []
|
||||
const call = toolCall(
|
||||
item,
|
||||
reuseProviderMetadata ? providerMetadata(providerMetadataKey, item.providerState) : undefined,
|
||||
reuseProviderMetadata ? providerMetadata(model.providerID, item.providerState) : undefined,
|
||||
)
|
||||
if (item.executed !== true) return [call]
|
||||
const result = toolResult(
|
||||
item,
|
||||
reuseProviderMetadata
|
||||
? providerMetadata(providerMetadataKey, item.providerResultState ?? item.providerState)
|
||||
? providerMetadata(model.providerID, item.providerResultState ?? item.providerState)
|
||||
: undefined,
|
||||
)
|
||||
return result ? [call, result] : [call]
|
||||
@@ -155,7 +155,7 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, provid
|
||||
toolResult(
|
||||
item,
|
||||
reuseProviderMetadata
|
||||
? providerMetadata(providerMetadataKey, item.providerResultState ?? item.providerState)
|
||||
? providerMetadata(model.providerID, item.providerResultState ?? item.providerState)
|
||||
: undefined,
|
||||
),
|
||||
)
|
||||
@@ -168,7 +168,7 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, provid
|
||||
]
|
||||
}
|
||||
|
||||
function toLLMMessage(message: SessionMessage.Info, model: ModelV2.Ref, providerMetadataKey: string): Message[] {
|
||||
function toLLMMessage(message: SessionMessage.Message, model: ModelV2.Ref): Message[] {
|
||||
switch (message.type) {
|
||||
case "agent-switched":
|
||||
case "model-switched":
|
||||
@@ -202,12 +202,12 @@ function toLLMMessage(message: SessionMessage.Info, model: ModelV2.Ref, provider
|
||||
Message.make({
|
||||
id: message.id,
|
||||
role: "user",
|
||||
content: `Shell command: ${message.command}\n\n${message.output?.output ?? ""}`,
|
||||
content: `Shell command: ${message.shell.command}\n\n${message.output?.output ?? ""}`,
|
||||
metadata: message.metadata,
|
||||
}),
|
||||
]
|
||||
case "assistant":
|
||||
return assistant(message, model, providerMetadataKey)
|
||||
return assistant(message, model)
|
||||
case "compaction":
|
||||
if (message.status !== "completed") return []
|
||||
return [
|
||||
@@ -232,8 +232,5 @@ ${message.recent}
|
||||
}
|
||||
|
||||
/** Translate projected V2 Session history into canonical @opencode-ai/llm context. */
|
||||
export const toLLMMessages = (
|
||||
messages: readonly SessionMessage.Info[],
|
||||
model: ModelV2.Ref,
|
||||
providerMetadataKey: string = model.providerID,
|
||||
) => messages.flatMap((message) => toLLMMessage(message, model, providerMetadataKey))
|
||||
export const toLLMMessages = (messages: readonly SessionMessage.Message[], model: ModelV2.Ref) =>
|
||||
messages.flatMap((message) => toLLMMessage(message, model))
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ProjectTable } from "../project/sql"
|
||||
import type { SessionMessage } from "./message"
|
||||
import type { Prompt } from "@opencode-ai/schema/prompt"
|
||||
import type { SessionInput } from "./input"
|
||||
import type { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import type { Snapshot } from "../snapshot"
|
||||
import { PermissionV1 } from "../v1/permission"
|
||||
import { ProjectV2 } from "../project"
|
||||
import type { SessionSchema } from "./schema"
|
||||
@@ -13,11 +13,10 @@ import type { MessageID, PartID, SessionV1 } from "../v1/session"
|
||||
import { WorkspaceV2 } from "../workspace"
|
||||
import { Timestamps } from "../database/schema.sql"
|
||||
import type { Instructions } from "../instructions/index"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { RevertV1 } from "@opencode-ai/schema/session-revert"
|
||||
import type { Revert } from "@opencode-ai/schema/revert"
|
||||
import type { Schema } from "effect"
|
||||
|
||||
type SessionMessageData = Omit<(typeof SessionMessage.Info)["Encoded"], "type" | "id">
|
||||
type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
|
||||
type V1MessageData = Omit<SessionV1.Info, "id" | "sessionID">
|
||||
type V1PartData = Omit<SessionV1.Part, "id" | "sessionID" | "messageID">
|
||||
|
||||
@@ -42,7 +41,7 @@ export const SessionTable = sqliteTable(
|
||||
summary_additions: integer(),
|
||||
summary_deletions: integer(),
|
||||
summary_files: integer(),
|
||||
summary_diffs: text({ mode: "json" }).$type<FileDiff.LegacyInfo[]>(),
|
||||
summary_diffs: text({ mode: "json" }).$type<Snapshot.LegacyFileDiff[]>(),
|
||||
metadata: text({ mode: "json" }).$type<Record<string, unknown>>(),
|
||||
cost: real().notNull().default(0),
|
||||
tokens_input: integer().notNull().default(0),
|
||||
@@ -50,7 +49,7 @@ export const SessionTable = sqliteTable(
|
||||
tokens_reasoning: integer().notNull().default(0),
|
||||
tokens_cache_read: integer().notNull().default(0),
|
||||
tokens_cache_write: integer().notNull().default(0),
|
||||
revert: text({ mode: "json" }).$type<Session.Revert | RevertV1>(),
|
||||
revert: text({ mode: "json" }).$type<Revert.State>(),
|
||||
permission: text({ mode: "json" }).$type<PermissionV1.Ruleset>(),
|
||||
agent: text(),
|
||||
model: text({ mode: "json" }).$type<{
|
||||
@@ -149,7 +148,7 @@ export const SessionInputTable = sqliteTable(
|
||||
.$type<SessionSchema.ID>()
|
||||
.notNull()
|
||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||
type: text().$type<SessionInput.Info["type"]>().notNull(),
|
||||
type: text().$type<SessionInput.Entry["type"]>().notNull(),
|
||||
prompt: text({ mode: "json" }).$type<Prompt>(),
|
||||
delivery: text().$type<SessionInput.Delivery>(),
|
||||
admitted_seq: integer().notNull(),
|
||||
|
||||
@@ -13,10 +13,10 @@ import { fromRow } from "./info"
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (sessionID: Session.ID) => Effect.Effect<Session.Info | undefined>
|
||||
readonly context: (sessionID: Session.ID) => Effect.Effect<SessionMessage.Info[], MessageDecodeError>
|
||||
readonly context: (sessionID: Session.ID) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
|
||||
readonly message: (
|
||||
messageID: SessionMessage.ID,
|
||||
) => Effect.Effect<{ readonly sessionID: Session.ID; readonly message: SessionMessage.Info } | undefined>
|
||||
) => Effect.Effect<{ readonly sessionID: Session.ID; readonly message: SessionMessage.Message } | undefined>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionStore") {}
|
||||
@@ -25,7 +25,7 @@ const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||
|
||||
return Service.of({
|
||||
get: Effect.fn("SessionStore.get")(function* (sessionID) {
|
||||
|
||||
+15
-21
@@ -28,15 +28,11 @@ export type Source = typeof Source.Type
|
||||
|
||||
export const Info = Skill.Info
|
||||
export type Info = Skill.Info
|
||||
export const ID = Skill.ID
|
||||
export type ID = Skill.ID
|
||||
export const Name = Skill.Name
|
||||
export type Name = Skill.Name
|
||||
|
||||
export const Event = Skill.Event
|
||||
|
||||
export const available = (skills: ReadonlyArray<Info>, agent: AgentV2.Info) =>
|
||||
skills.filter((skill) => PermissionV2.evaluate("skill", skill.id, agent.permissions).effect !== "deny")
|
||||
skills.filter((skill) => PermissionV2.evaluate("skill", skill.name, agent.permissions).effect !== "deny")
|
||||
|
||||
const Frontmatter = Schema.Struct({
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
@@ -100,7 +96,7 @@ const layer = Layer.effect(
|
||||
source: Source.key(source),
|
||||
type: source.type,
|
||||
directories: [],
|
||||
skills: [source.skill.id],
|
||||
skills: [source.skill.name],
|
||||
})
|
||||
return { skills: [source.skill], directories: [] }
|
||||
}
|
||||
@@ -116,13 +112,15 @@ const layer = Layer.effect(
|
||||
if (!markdown) continue
|
||||
const frontmatter = decodeFrontmatter(markdown.data).valueOrUndefined
|
||||
if (!frontmatter) continue
|
||||
const id =
|
||||
path.dirname(filepath) === directory
|
||||
? path.basename(filepath, ".md")
|
||||
: path.basename(path.dirname(filepath))
|
||||
const name =
|
||||
frontmatter.name !== undefined
|
||||
? frontmatter.name
|
||||
: path.dirname(filepath) === directory
|
||||
? path.basename(filepath, ".md")
|
||||
: undefined
|
||||
if (!name) continue
|
||||
skills.push({
|
||||
id: ID.make(id),
|
||||
name: Name.make(frontmatter.name ?? id),
|
||||
name,
|
||||
description: frontmatter.description,
|
||||
slash: metadataBoolean(frontmatter.metadata, "opencode/slash") ?? frontmatter.slash,
|
||||
autoinvoke: metadataBoolean(frontmatter.metadata, "opencode/autoinvoke"),
|
||||
@@ -135,7 +133,7 @@ const layer = Layer.effect(
|
||||
source: Source.key(source),
|
||||
type: source.type,
|
||||
directories,
|
||||
skills: skills.map((skill) => skill.id),
|
||||
skills: skills.map((skill) => skill.name),
|
||||
})
|
||||
return { skills, directories }
|
||||
})
|
||||
@@ -150,7 +148,7 @@ const layer = Layer.effect(
|
||||
yield* Effect.logInfo("skill cache invalidated", {
|
||||
file,
|
||||
sources: invalidated.map(([key]) => key),
|
||||
skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.id)),
|
||||
skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.name)),
|
||||
})
|
||||
yield* events.publish(Event.Updated, {}).pipe(Effect.asVoid)
|
||||
})
|
||||
@@ -161,12 +159,12 @@ const layer = Layer.effect(
|
||||
)
|
||||
|
||||
const list = Effect.fn("SkillV2.list")(function* () {
|
||||
const skills = new Map<ID, Info>()
|
||||
const skills = new Map<string, Info>()
|
||||
for (const source of state.get().sources) {
|
||||
const key = Source.key(source)
|
||||
const loaded = cache.get(key) ?? (yield* load(source))
|
||||
cache.set(key, loaded)
|
||||
for (const skill of loaded.skills) skills.set(skill.id, skill)
|
||||
for (const skill of loaded.skills) skills.set(skill.name, skill)
|
||||
}
|
||||
return Array.from(skills.values())
|
||||
})
|
||||
@@ -182,8 +180,4 @@ 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,8 +8,7 @@ import { SkillV2 } from "../skill"
|
||||
import { Instructions } from "../instructions/index"
|
||||
|
||||
const Summary = Schema.Struct({
|
||||
id: SkillV2.ID,
|
||||
name: SkillV2.Name,
|
||||
name: Schema.String,
|
||||
description: Schema.String,
|
||||
})
|
||||
type Summary = typeof Summary.Type
|
||||
@@ -17,7 +16,6 @@ type Summary = typeof Summary.Type
|
||||
const entries = (skills: ReadonlyArray<Summary>) =>
|
||||
skills.flatMap((skill) => [
|
||||
" <skill>",
|
||||
` <id>${skill.id}</id>`,
|
||||
` <name>${skill.name}</name>`,
|
||||
` <description>${skill.description}</description>`,
|
||||
" </skill>",
|
||||
@@ -36,8 +34,8 @@ const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary
|
||||
const diff = Instructions.diffByKey(
|
||||
previous,
|
||||
current,
|
||||
(skill) => skill.id,
|
||||
(before, after) => before.name !== after.name || before.description !== after.description,
|
||||
(skill) => skill.name,
|
||||
(before, after) => before.description !== after.description,
|
||||
)
|
||||
// Additions and removals render as small deltas; anything else restates the full list.
|
||||
if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0))
|
||||
@@ -52,7 +50,7 @@ const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary
|
||||
...(diff.removed.length === 0
|
||||
? []
|
||||
: [
|
||||
`The following skill IDs are no longer available and must not be used: ${diff.removed.map((skill) => skill.id).join(", ")}.`,
|
||||
`The following skills are no longer available and must not be used: ${diff.removed.map((skill) => skill.name).join(", ")}.`,
|
||||
]),
|
||||
].join("\n")
|
||||
}
|
||||
@@ -79,9 +77,9 @@ const layer = Layer.effect(
|
||||
.flatMap((skill) =>
|
||||
skill.description === undefined || skill.autoinvoke === false
|
||||
? []
|
||||
: [{ id: skill.id, name: skill.name, description: skill.description }],
|
||||
: [{ name: skill.name, description: skill.description }],
|
||||
)
|
||||
.toSorted((a, b) => a.id.localeCompare(b.id))
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
return Instructions.make({
|
||||
key: Instructions.Key.make("core/skill-guidance"),
|
||||
codec: Schema.toCodecJson(Schema.Array(Summary)),
|
||||
|
||||
@@ -10,10 +10,10 @@ import { Git } from "./git"
|
||||
import { Global } from "./global"
|
||||
import { Location } from "./location"
|
||||
import { AbsolutePath, RelativePath } from "./schema"
|
||||
import { ID } from "@opencode-ai/schema/snapshot"
|
||||
import { Hash } from "./util/hash"
|
||||
|
||||
export { ID }
|
||||
export const ID = Schema.String.pipe(Schema.brand("Snapshot.ID"))
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export class Error extends Schema.TaggedErrorClass<Error>()("Snapshot.Error", {
|
||||
operation: Schema.Literals(["capture", "files", "diff", "preview", "restore"]),
|
||||
@@ -253,3 +253,12 @@ function failure(operation: Error["operation"], cause: unknown) {
|
||||
cause,
|
||||
})
|
||||
}
|
||||
|
||||
/** Legacy persisted session diff shape. */
|
||||
export type LegacyFileDiff = {
|
||||
file?: string
|
||||
patch?: string
|
||||
additions: number
|
||||
deletions: number
|
||||
status?: "added" | "deleted" | "modified"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as PatchTool from "./patch"
|
||||
export * as ApplyPatchTool from "./apply-patch"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
@@ -55,8 +55,8 @@ type Prepared =
|
||||
})
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.patch",
|
||||
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
id: "opencode.tool.apply-patch",
|
||||
effect: Effect.fn("ApplyPatchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
@@ -13,11 +13,11 @@ export const name = "skill"
|
||||
const FILE_LIMIT = 10
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
id: SkillV2.ID.annotate({ description: "The ID of the skill from the available skills list" }),
|
||||
name: Schema.String.annotate({ description: "The name of the skill from the available skills list" }),
|
||||
})
|
||||
|
||||
export const Output = Schema.Struct({
|
||||
name: SkillV2.Name,
|
||||
name: Schema.String,
|
||||
directory: Schema.String,
|
||||
output: Schema.String,
|
||||
})
|
||||
@@ -27,7 +27,7 @@ export const description = [
|
||||
"",
|
||||
"Use this tool to inject the skill's instructions and resources into the current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc. in the same directory as the skill.",
|
||||
"",
|
||||
"The skill ID must match one of the available skills in the instructions.",
|
||||
"The skill name must match one of the available skills in the instructions.",
|
||||
].join("\n")
|
||||
|
||||
export const toModelOutput = (skill: SkillV2.Info, files: ReadonlyArray<string>) => {
|
||||
@@ -70,13 +70,13 @@ export const Plugin = {
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* skills.list()
|
||||
const skill = current.find((skill) => skill.id === input.id)
|
||||
if (!skill) return yield* unableToLoad(input.id)
|
||||
const skill = current.find((skill) => skill.name === input.name)
|
||||
if (!skill) return yield* unableToLoad(input.name)
|
||||
return yield* Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [skill.id],
|
||||
save: [skill.id],
|
||||
resources: [skill.name],
|
||||
save: [skill.name],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
@@ -94,7 +94,7 @@ export const Plugin = {
|
||||
directory,
|
||||
output: toModelOutput(skill, files),
|
||||
}
|
||||
}).pipe(Effect.mapError((error) => unableToLoad(input.id, error)))
|
||||
}).pipe(Effect.mapError((error) => unableToLoad(input.name, error)))
|
||||
}),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { LanguageModelV3CallOptions } from "@ai-sdk/provider"
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { LLM, Message } from "@opencode-ai/llm"
|
||||
import { LLM } from "@opencode-ai/llm"
|
||||
import { LLMClient } from "@opencode-ai/llm/route"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
@@ -67,54 +67,3 @@ it.effect("projects request settings, headers, and body overlays", () =>
|
||||
expect(body).toEqual({ safety_setting: "strict" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects replay metadata onto AI SDK prompt parts", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
|
||||
})
|
||||
|
||||
const resolved = yield* aisdk.model(model("@ai-sdk/anthropic"))
|
||||
expect(resolved.route.providerMetadataKey).toBe("anthropic")
|
||||
const prepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
|
||||
LLM.request({
|
||||
model: resolved,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{ type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "signed" } } },
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "hosted",
|
||||
name: "web_search",
|
||||
input: { query: "Effect" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { anthropic: { blockType: "server_tool_use" } },
|
||||
},
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.prompt).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Think",
|
||||
providerOptions: { anthropic: { signature: "signed" } },
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "hosted",
|
||||
toolName: "web_search",
|
||||
input: { query: "Effect" },
|
||||
providerExecuted: true,
|
||||
providerOptions: { anthropic: { blockType: "server_tool_use" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
@@ -299,31 +298,13 @@ describe("CatalogV2", () => {
|
||||
catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => {
|
||||
model.capabilities.input = ["text"]
|
||||
model.capabilities.output = ["text"]
|
||||
model.cost = [
|
||||
{
|
||||
input: Money.USDPerMillionTokens.make(1),
|
||||
output: Money.USDPerMillionTokens.make(1),
|
||||
cache: {
|
||||
read: Money.USDPerMillionTokens.zero,
|
||||
write: Money.USDPerMillionTokens.zero,
|
||||
},
|
||||
},
|
||||
]
|
||||
model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }]
|
||||
model.time.released = Date.now()
|
||||
})
|
||||
catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => {
|
||||
model.capabilities.input = ["text"]
|
||||
model.capabilities.output = ["text"]
|
||||
model.cost = [
|
||||
{
|
||||
input: Money.USDPerMillionTokens.make(10),
|
||||
output: Money.USDPerMillionTokens.make(10),
|
||||
cache: {
|
||||
read: Money.USDPerMillionTokens.zero,
|
||||
write: Money.USDPerMillionTokens.zero,
|
||||
},
|
||||
},
|
||||
]
|
||||
model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }]
|
||||
model.time.released = Date.now()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,6 @@ import { describe, expect } from "bun:test"
|
||||
import { Effect, PubSub, Schema, Stream } from "effect"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { CommandV2 } from "@opencode-ai/core/command"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -88,7 +87,7 @@ Review files`,
|
||||
name: "review",
|
||||
template: "Review files",
|
||||
description: "File review",
|
||||
agent: AgentV2.ID.make("reviewer"),
|
||||
agent: "reviewer",
|
||||
model: {
|
||||
providerID: ProviderV2.ID.make("anthropic"),
|
||||
id: ModelV2.ID.make("claude"),
|
||||
|
||||
@@ -273,9 +273,9 @@ function withLocation<A, E, R>(
|
||||
function mutablePlugin(description: string) {
|
||||
const plugin = pathToFileURL(path.join(import.meta.dir, "../../../plugin/src/v2/promise/index.ts")).href
|
||||
return `
|
||||
import { Plugin } from ${JSON.stringify(plugin)}
|
||||
import { define } from ${JSON.stringify(plugin)}
|
||||
|
||||
export default Plugin.define({
|
||||
export default EffectPlugin.define({
|
||||
id: "mutable-plugin",
|
||||
setup: async (ctx) => {
|
||||
await ctx.agent.transform((agents) => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
@@ -254,17 +253,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
|
||||
expect(model.enabled).toBe(false)
|
||||
expect(model.limit).toEqual({ context: 100, output: 75 })
|
||||
expect(model.cost).toEqual([
|
||||
{
|
||||
input: Money.USDPerMillionTokens.make(1),
|
||||
output: Money.USDPerMillionTokens.make(2),
|
||||
cache: {
|
||||
read: Money.USDPerMillionTokens.zero,
|
||||
write: Money.USDPerMillionTokens.zero,
|
||||
},
|
||||
tier: undefined,
|
||||
},
|
||||
])
|
||||
expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }])
|
||||
expect(model.settings).toEqual({ baseURL: "https://example.test", retained: true })
|
||||
expect(model.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.variants?.map((variant) => variant.id)).toEqual([
|
||||
|
||||
@@ -4,7 +4,7 @@ import { fileURLToPath } from "url"
|
||||
import path from "path"
|
||||
import { SqliteClient } from "@effect/sql-sqlite-bun"
|
||||
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { eq, inArray, sql } from "drizzle-orm"
|
||||
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
|
||||
import { migrations } from "@opencode-ai/core/database/migration.gen"
|
||||
@@ -17,7 +17,6 @@ import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/
|
||||
import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input"
|
||||
import resetSessionEventsMigration from "@opencode-ai/core/database/migration/20260703200000_reset_v2_session_events"
|
||||
import durableSessionInboxMigration from "@opencode-ai/core/database/migration/20260707010146_durable_session_inbox"
|
||||
import migratePrelaunchV2StateMigration from "@opencode-ai/core/database/migration/20260707120000_migrate_prelaunch_v2_state"
|
||||
import renameInstructionsMigration from "@opencode-ai/core/database/migration/20260705180000_rename_instructions"
|
||||
import addSessionForkMigration from "@opencode-ai/core/database/migration/20260706223930_add-session-fork"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -27,7 +26,6 @@ import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata"
|
||||
import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
|
||||
@@ -44,256 +42,6 @@ const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
|
||||
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
|
||||
|
||||
describe("DatabaseMigration", () => {
|
||||
test("migrates pre-launch V2 state in place", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(
|
||||
sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`CREATE TABLE session_input (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, prompt text, delivery text, admitted_seq integer NOT NULL, promoted_seq integer, time_created integer NOT NULL)`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, created integer NOT NULL, type text NOT NULL, data text NOT NULL)`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL, owner_id text)`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`CREATE TABLE instruction_checkpoint (session_id text PRIMARY KEY, baseline text NOT NULL, snapshot text NOT NULL, baseline_seq integer NOT NULL)`,
|
||||
)
|
||||
const messages = [
|
||||
["msg_skill", "skill", { name: "effect", text: "Use Effect", time: { created: 1 } }],
|
||||
[
|
||||
"msg_shell",
|
||||
"shell",
|
||||
{
|
||||
shell: { id: "sh_old", command: "pwd", status: "exited", exit: 0, cwd: "/tmp" },
|
||||
output: { output: "/tmp", cursor: 4, size: 4, truncated: false },
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
],
|
||||
[
|
||||
"msg_assistant",
|
||||
"assistant",
|
||||
{
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_old",
|
||||
name: "read",
|
||||
provider: "removed",
|
||||
state: { status: "pending", input: '{"path":"README.md"}', title: "removed" },
|
||||
time: { created: 3 },
|
||||
},
|
||||
],
|
||||
time: { created: 3 },
|
||||
},
|
||||
],
|
||||
[
|
||||
"msg_failed",
|
||||
"compaction",
|
||||
{
|
||||
status: "failed",
|
||||
reason: "manual",
|
||||
summary: "removed",
|
||||
recent: "removed",
|
||||
time: { created: 4 },
|
||||
},
|
||||
],
|
||||
[
|
||||
"msg_queued",
|
||||
"compaction",
|
||||
{ status: "queued", reason: "manual", summary: "", recent: "", time: { created: 5 } },
|
||||
],
|
||||
[
|
||||
"msg_synthetic",
|
||||
"synthetic",
|
||||
{ sessionID: "ses_test", text: "context", description: "source", time: { created: 6 } },
|
||||
],
|
||||
[
|
||||
"msg_running",
|
||||
"compaction",
|
||||
{ status: "running", reason: "auto", summary: "partial", recent: "recent", time: { created: 7 } },
|
||||
],
|
||||
[
|
||||
"msg_completed",
|
||||
"compaction",
|
||||
{ status: "completed", reason: "auto", summary: "summary", recent: "recent", time: { created: 8 } },
|
||||
],
|
||||
] as const
|
||||
for (const [id, type, data] of messages)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO session_message VALUES (${id}, 'ses_test', ${type}, 1, 10, 11, ${JSON.stringify(data)})`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO session_input VALUES ('msg_queued', 'ses_test', 'compaction', NULL, NULL, 4, NULL, 5)`,
|
||||
)
|
||||
yield* db.run(sql`INSERT INTO event_sequence VALUES ('ses_test', 9, 'owner')`)
|
||||
yield* db.run(sql`INSERT INTO instruction_checkpoint VALUES ('ses_test', 'baseline', '{"source":"value"}', 7)`)
|
||||
const events = [
|
||||
["evt_skill", 1, 101, "session.skill.activated.1", { sessionID: "ses_test", name: "effect", text: "Use" }],
|
||||
["evt_started", 2, 102, "session.compaction.started.1", { sessionID: "ses_test", reason: "auto" }],
|
||||
["evt_delta", 3, 103, "session.compaction.delta.1", { sessionID: "ses_test", text: "partial" }],
|
||||
["evt_failed", 4, 104, "session.compaction.failed.1", { sessionID: "ses_test" }],
|
||||
[
|
||||
"evt_revert",
|
||||
5,
|
||||
105,
|
||||
"session.revert.staged.1",
|
||||
{
|
||||
sessionID: "ses_test",
|
||||
revert: {
|
||||
messageID: "msg_skill",
|
||||
snapshot: "tree",
|
||||
diff: "removed",
|
||||
files: [{ path: "src/a.ts", patch: "@@", additions: 1, deletions: 0, status: "modified" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"evt_skill_current",
|
||||
6,
|
||||
106,
|
||||
"session.skill.activated.2",
|
||||
{ sessionID: "ses_test", id: "effect-id", name: "Effect", text: "Use" },
|
||||
],
|
||||
] as const
|
||||
for (const [id, seq, created, type, data] of events)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO event VALUES (${id}, 'ses_test', ${seq}, ${created}, ${type}, ${JSON.stringify(data)})`,
|
||||
)
|
||||
|
||||
yield* DatabaseMigration.applyOnly(db, [migratePrelaunchV2StateMigration])
|
||||
|
||||
const rows = yield* db.all<{
|
||||
id: string
|
||||
type: string
|
||||
seq: number
|
||||
time_created: number
|
||||
time_updated: number
|
||||
data: string
|
||||
}>(sql`SELECT id, type, seq, time_created, time_updated, data FROM session_message ORDER BY id`)
|
||||
for (const row of rows)
|
||||
Schema.decodeUnknownSync(SessionMessage.Info)({ ...JSON.parse(row.data), id: row.id, type: row.type })
|
||||
expect(rows.every((row) => row.seq === 1 && row.time_created === 10 && row.time_updated === 11)).toBe(true)
|
||||
expect(rows.map((row) => [row.id, JSON.parse(row.data)])).toEqual([
|
||||
[
|
||||
"msg_assistant",
|
||||
expect.objectContaining({
|
||||
content: [expect.objectContaining({ state: { status: "streaming", input: '{"path":"README.md"}' } })],
|
||||
}),
|
||||
],
|
||||
["msg_completed", expect.objectContaining({ status: "completed", summary: "summary", recent: "recent" })],
|
||||
[
|
||||
"msg_failed",
|
||||
{
|
||||
time: { created: 4 },
|
||||
status: "failed",
|
||||
reason: "manual",
|
||||
error: {
|
||||
type: "compaction.failed",
|
||||
message: "Compaction failed before recording an error",
|
||||
},
|
||||
},
|
||||
],
|
||||
["msg_running", expect.objectContaining({ status: "running", summary: "partial", recent: "recent" })],
|
||||
["msg_shell", expect.objectContaining({ shellID: "sh_old", command: "pwd", status: "exited", exit: 0 })],
|
||||
["msg_skill", { time: { created: 1 }, skill: "effect", name: "effect", text: "Use Effect" }],
|
||||
["msg_synthetic", { time: { created: 6 }, text: "context", description: "source" }],
|
||||
])
|
||||
expect(yield* db.get(sql`SELECT * FROM session_input`)).toEqual({
|
||||
id: "msg_queued",
|
||||
session_id: "ses_test",
|
||||
type: "compaction",
|
||||
prompt: null,
|
||||
delivery: null,
|
||||
admitted_seq: 4,
|
||||
promoted_seq: null,
|
||||
time_created: 5,
|
||||
})
|
||||
const migratedEvents = yield* db.all<{
|
||||
id: string
|
||||
aggregate_id: string
|
||||
seq: number
|
||||
created: number
|
||||
type: string
|
||||
data: string
|
||||
}>(sql`SELECT * FROM event ORDER BY seq`)
|
||||
expect(migratedEvents.map((event) => ({ ...event, data: JSON.parse(event.data) }))).toEqual([
|
||||
{
|
||||
id: "evt_skill",
|
||||
aggregate_id: "ses_test",
|
||||
seq: 1,
|
||||
created: 101,
|
||||
type: "session.skill.activated.1",
|
||||
data: { sessionID: "ses_test", id: "effect", name: "effect", text: "Use" },
|
||||
},
|
||||
{
|
||||
id: "evt_started",
|
||||
aggregate_id: "ses_test",
|
||||
seq: 2,
|
||||
created: 102,
|
||||
type: "session.compaction.started.1",
|
||||
data: { sessionID: "ses_test", reason: "auto", recent: "" },
|
||||
},
|
||||
{
|
||||
id: "evt_failed",
|
||||
aggregate_id: "ses_test",
|
||||
seq: 4,
|
||||
created: 104,
|
||||
type: "session.compaction.failed.1",
|
||||
data: {
|
||||
sessionID: "ses_test",
|
||||
reason: "auto",
|
||||
error: {
|
||||
type: "compaction.failed",
|
||||
message: "Compaction failed before recording an error",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "evt_revert",
|
||||
aggregate_id: "ses_test",
|
||||
seq: 5,
|
||||
created: 105,
|
||||
type: "session.revert.staged.1",
|
||||
data: {
|
||||
sessionID: "ses_test",
|
||||
revert: {
|
||||
messageID: "msg_skill",
|
||||
snapshot: "tree",
|
||||
files: [{ file: "src/a.ts", patch: "@@", additions: 1, deletions: 0, status: "modified" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "evt_skill_current",
|
||||
aggregate_id: "ses_test",
|
||||
seq: 6,
|
||||
created: 106,
|
||||
type: "session.skill.activated.1",
|
||||
data: { sessionID: "ses_test", id: "effect-id", name: "Effect", text: "Use" },
|
||||
},
|
||||
])
|
||||
expect(yield* db.get(sql`SELECT * FROM event_sequence`)).toEqual({
|
||||
aggregate_id: "ses_test",
|
||||
seq: 9,
|
||||
owner_id: "owner",
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT * FROM instruction_checkpoint`)).toEqual({
|
||||
session_id: "ses_test",
|
||||
baseline: "baseline",
|
||||
snapshot: '{"source":"value"}',
|
||||
baseline_seq: 7,
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("resets incompatible V2 Session event history", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -146,7 +146,7 @@ describe("Git trees", () => {
|
||||
RelativePath.make("scope/tracked.txt"),
|
||||
])
|
||||
const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 1 })
|
||||
expect(diffs.map((item) => [item.file, item.status])).toEqual([
|
||||
expect(diffs.map((item) => [item.path, item.status])).toEqual([
|
||||
[RelativePath.make("scope/added.txt"), "added"],
|
||||
[RelativePath.make("scope/tracked.txt"), "modified"],
|
||||
])
|
||||
@@ -154,7 +154,7 @@ describe("Git trees", () => {
|
||||
const files = new Map([[RelativePath.make("scope/tracked.txt"), before]])
|
||||
const preview = yield* git.tree.preview({ repository, current: after, files, context: 1 })
|
||||
expect(preview).toHaveLength(1)
|
||||
expect(preview[0]?.file).toBe(RelativePath.make("scope/tracked.txt"))
|
||||
expect(preview[0]?.path).toBe(RelativePath.make("scope/tracked.txt"))
|
||||
yield* git.tree.restore({ repository, files })
|
||||
expect(yield* read(path.join(root.path, "scope", "tracked.txt"))).toBe("one\n")
|
||||
expect(yield* read(path.join(root.path, "scope", "added.txt"))).toBe("added\n")
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
import { Tools } from "@opencode-ai/core/tool/tools"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Effect, type Scope } from "effect"
|
||||
import { host } from "../plugin/host"
|
||||
|
||||
export const toolIdentity = {
|
||||
agent: AgentV2.ID.make("build"),
|
||||
@@ -49,7 +48,7 @@ export const registerToolPlugin = <R>(plugin: {
|
||||
}): Effect.Effect<void, never, R | Tools.Service | Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const tools = yield* Tools.Service
|
||||
const context = host({
|
||||
const context: Pick<PluginContext, "tool"> = {
|
||||
tool: {
|
||||
transform: (callback) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -72,8 +71,8 @@ export const registerToolPlugin = <R>(plugin: {
|
||||
}),
|
||||
hook: () => Effect.die("registerToolPlugin does not support tool hooks"),
|
||||
},
|
||||
})
|
||||
yield* plugin.effect(context)
|
||||
}
|
||||
yield* plugin.effect(context as PluginContext)
|
||||
})
|
||||
|
||||
export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput, model = testModel) =>
|
||||
|
||||
@@ -3,7 +3,6 @@ import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Config } from "@opencode-ai/schema/config"
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Context, DateTime, Effect, Equal, Hash, RcMap, Schema, Stream } from "effect"
|
||||
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
@@ -291,7 +290,6 @@ describe("LocationServiceMap", () => {
|
||||
"edit",
|
||||
"glob",
|
||||
"grep",
|
||||
"patch",
|
||||
"question",
|
||||
"read",
|
||||
"shell",
|
||||
@@ -309,7 +307,6 @@ describe("LocationServiceMap", () => {
|
||||
"edit",
|
||||
"glob",
|
||||
"grep",
|
||||
"patch",
|
||||
"question",
|
||||
"read",
|
||||
"shell",
|
||||
@@ -357,7 +354,7 @@ describe("LocationServiceMap", () => {
|
||||
id: ModelV2.ID.make("chat"),
|
||||
providerID: ProviderV2.ID.make("unavailable"),
|
||||
},
|
||||
cost: Money.USD.zero,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||
location,
|
||||
@@ -406,7 +403,7 @@ describe("LocationServiceMap", () => {
|
||||
providerID: ProviderV2.ID.make("aliased"),
|
||||
variant: ModelV2.VariantID.make("high"),
|
||||
},
|
||||
cost: Money.USD.zero,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||
location,
|
||||
|
||||
@@ -83,9 +83,7 @@ export function host(overrides: Overrides = {}): PluginContext {
|
||||
prompt: () => Effect.die("unused session.prompt"),
|
||||
command: () => Effect.die("unused session.command"),
|
||||
interrupt: () => Effect.die("unused session.interrupt"),
|
||||
// Plugins register session hooks during setup, so a bare host accepts the
|
||||
// registration; the callback only runs when a test triggers the request pipeline.
|
||||
hook: () => Effect.succeed({ dispose: Effect.void }),
|
||||
hook: () => Effect.die("unused session.hook"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
@@ -52,31 +51,23 @@ describe("ModelsDevPlugin", () => {
|
||||
temperature: true,
|
||||
tool_call: true,
|
||||
cost: {
|
||||
input: Money.USDPerMillionTokens.make(2.5),
|
||||
output: Money.USDPerMillionTokens.make(15),
|
||||
input: 2.5,
|
||||
output: 15,
|
||||
tiers: [
|
||||
{
|
||||
tier: { type: "context", size: 272_000 },
|
||||
input: Money.USDPerMillionTokens.make(3),
|
||||
output: Money.USDPerMillionTokens.make(18),
|
||||
cache_read: Money.USDPerMillionTokens.make(0.25),
|
||||
input: 3,
|
||||
output: 18,
|
||||
cache_read: 0.25,
|
||||
},
|
||||
],
|
||||
context_over_200k: {
|
||||
input: Money.USDPerMillionTokens.make(5),
|
||||
output: Money.USDPerMillionTokens.make(22.5),
|
||||
cache_read: Money.USDPerMillionTokens.make(0.5),
|
||||
},
|
||||
context_over_200k: { input: 5, output: 22.5, cache_read: 0.5 },
|
||||
},
|
||||
limit: { context: 1_050_000, input: 922_000, output: 128_000 },
|
||||
experimental: {
|
||||
modes: {
|
||||
fast: {
|
||||
cost: {
|
||||
input: Money.USDPerMillionTokens.make(5),
|
||||
output: Money.USDPerMillionTokens.make(30),
|
||||
cache_read: Money.USDPerMillionTokens.make(0.5),
|
||||
},
|
||||
cost: { input: 5, output: 30, cache_read: 0.5 },
|
||||
provider: {
|
||||
headers: { "x-mode": "fast" },
|
||||
body: { service_tier: "priority" },
|
||||
@@ -116,31 +107,18 @@ describe("ModelsDevPlugin", () => {
|
||||
variants: [],
|
||||
})
|
||||
expect(fast?.cost).toEqual([
|
||||
{
|
||||
input: Money.USDPerMillionTokens.make(5),
|
||||
output: Money.USDPerMillionTokens.make(30),
|
||||
cache: {
|
||||
read: Money.USDPerMillionTokens.make(0.5),
|
||||
write: Money.USDPerMillionTokens.zero,
|
||||
},
|
||||
},
|
||||
{ input: 5, output: 30, cache: { read: 0.5, write: 0 } },
|
||||
{
|
||||
tier: { type: "context", size: 272_000 },
|
||||
input: Money.USDPerMillionTokens.make(3),
|
||||
output: Money.USDPerMillionTokens.make(18),
|
||||
cache: {
|
||||
read: Money.USDPerMillionTokens.make(0.25),
|
||||
write: Money.USDPerMillionTokens.zero,
|
||||
},
|
||||
input: 3,
|
||||
output: 18,
|
||||
cache: { read: 0.25, write: 0 },
|
||||
},
|
||||
{
|
||||
tier: { type: "context", size: 200_000 },
|
||||
input: Money.USDPerMillionTokens.make(5),
|
||||
output: Money.USDPerMillionTokens.make(22.5),
|
||||
cache: {
|
||||
read: Money.USDPerMillionTokens.make(0.5),
|
||||
write: Money.USDPerMillionTokens.zero,
|
||||
},
|
||||
input: 5,
|
||||
output: 22.5,
|
||||
cache: { read: 0.5, write: 0 },
|
||||
},
|
||||
])
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { describe, expect } from "bun:test"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { Effect } from "effect"
|
||||
@@ -186,16 +185,7 @@ describe("OpenAIPlugin", () => {
|
||||
draft.package = item.package
|
||||
})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5"), (model) => {
|
||||
model.cost = [
|
||||
{
|
||||
input: Money.USDPerMillionTokens.make(1),
|
||||
output: Money.USDPerMillionTokens.make(2),
|
||||
cache: {
|
||||
read: Money.USDPerMillionTokens.make(0.1),
|
||||
write: Money.USDPerMillionTokens.zero,
|
||||
},
|
||||
},
|
||||
]
|
||||
model.cost = [{ input: 1, output: 2, cache: { read: 0.1, write: 0 } }]
|
||||
})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5-pro"), () => {})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-4.1"), () => {})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
@@ -66,16 +65,7 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
]
|
||||
const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }]
|
||||
|
||||
describe("OpencodePlugin", () => {
|
||||
it.effect("registers account and service account methods", () =>
|
||||
|
||||
@@ -37,19 +37,17 @@ describe("SkillPlugin.Plugin", () => {
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
)
|
||||
const skills = yield* skill.list()
|
||||
const report = skills.find((item) => item.id === "report")
|
||||
const report = skills.find((item) => item.name === "report")
|
||||
|
||||
expect(skills).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
name: "opencode",
|
||||
description: expect.stringContaining("any question about OpenCode itself"),
|
||||
}),
|
||||
)
|
||||
expect(skills).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "report",
|
||||
name: "Report",
|
||||
name: "report",
|
||||
description: expect.stringContaining("opencode issue"),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -15,7 +15,6 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionInput } from "@opencode-ai/core/session/input"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Prompt } from "@opencode-ai/schema/prompt"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
@@ -105,10 +104,13 @@ describe("SessionV2.compact", () => {
|
||||
|
||||
expect(second.id).toBe(first.id)
|
||||
expect(requests).toHaveLength(0)
|
||||
expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, created.id)).toMatchObject({
|
||||
id: first.id,
|
||||
expect((yield* session.context(created.id)).find((message) => message.id === first.id)).toMatchObject({
|
||||
type: "compaction",
|
||||
status: "queued",
|
||||
reason: "manual",
|
||||
summary: "",
|
||||
recent: "",
|
||||
})
|
||||
expect((yield* session.context(created.id)).find((message) => message.id === first.id)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -141,13 +141,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
.subscribe(SessionEvent.Compaction.Delta)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
expect(
|
||||
yield* compaction.compactManual({
|
||||
session,
|
||||
messages: [userMessage],
|
||||
inputID: SessionMessage.ID.make("msg_manual_compaction"),
|
||||
}),
|
||||
).toBe(true)
|
||||
expect(yield* compaction.compactManual({ session, messages: [userMessage] })).toBe(true)
|
||||
expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual(["manual summary"])
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { DateTime, Effect, Layer, Stream } from "effect"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
@@ -211,7 +210,7 @@ describe("SessionV2.create", () => {
|
||||
expect(forked.parentID).toBeUndefined()
|
||||
expect(forkContext).toMatchObject([
|
||||
{ type: "user", text: "First" },
|
||||
{ type: "synthetic", text: "parent note" },
|
||||
{ type: "synthetic", text: "parent note", sessionID: forked.id },
|
||||
])
|
||||
expect(forkContext.map((message) => message.id)).not.toEqual(parentContext.map((message) => message.id))
|
||||
expect(history).toHaveLength(1)
|
||||
@@ -274,14 +273,14 @@ describe("SessionV2.create", () => {
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID: parent.id,
|
||||
assistantMessageID,
|
||||
agent: AgentV2.ID.make("build"),
|
||||
agent: "build",
|
||||
model,
|
||||
})
|
||||
yield* events.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: parent.id,
|
||||
assistantMessageID,
|
||||
finish: "stop",
|
||||
cost: Money.USD.make(0.75),
|
||||
cost: 0.75,
|
||||
tokens: { input: 6, output: 3, reasoning: 1, cache: { read: 2, write: 1 } },
|
||||
})
|
||||
|
||||
@@ -534,7 +533,7 @@ describe("SessionV2.create", () => {
|
||||
|
||||
const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
|
||||
const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell")
|
||||
expect(shell).toMatchObject({ type: "shell", command: "echo hello", status: "exited", exit: 0 })
|
||||
expect(shell).toMatchObject({ type: "shell", shell: { command: "echo hello", status: "exited", exit: 0 } })
|
||||
expect(shell?.output?.output).toContain("hello")
|
||||
expect(shell?.output?.truncated).toBe(false)
|
||||
expect(shell?.time.completed).toBeDefined()
|
||||
@@ -554,8 +553,8 @@ describe("SessionV2.create", () => {
|
||||
|
||||
const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
|
||||
const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell")
|
||||
expect(shell).toMatchObject({ type: "shell", command: "false", status: "exited" })
|
||||
expect(shell?.exit).not.toBe(0)
|
||||
expect(shell).toMatchObject({ type: "shell", shell: { command: "false", status: "exited" } })
|
||||
expect(shell?.shell.exit).not.toBe(0)
|
||||
expect(shell?.time.completed).toBeDefined()
|
||||
}),
|
||||
),
|
||||
@@ -566,7 +565,7 @@ describe("SessionV2.create", () => {
|
||||
const session = yield* SessionV2.Service
|
||||
const created = yield* session.create({ location })
|
||||
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: AgentV2.ID.make("plan") })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "plan" })
|
||||
|
||||
expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" })
|
||||
expect(
|
||||
@@ -581,7 +580,7 @@ describe("SessionV2.create", () => {
|
||||
const missing = SessionV2.ID.make("ses_missing_agent_switch")
|
||||
|
||||
expect(
|
||||
yield* session.switchAgent({ sessionID: missing, agent: AgentV2.ID.make("plan") }).pipe(
|
||||
yield* session.switchAgent({ sessionID: missing, agent: "plan" }).pipe(
|
||||
Effect.flip,
|
||||
Effect.map((error) => error._tag),
|
||||
),
|
||||
|
||||
@@ -303,6 +303,7 @@ describe("SessionInstructions", () => {
|
||||
const synthetic = SessionMessage.Synthetic.make({
|
||||
id: SessionMessage.ID.make("msg_synthetic"),
|
||||
type: "synthetic",
|
||||
sessionID: SessionV2.ID.make("ses_test"),
|
||||
text: "Instructions from: /repo/sub/AGENTS.md\ncontent",
|
||||
description: "Loaded /repo/sub/AGENTS.md",
|
||||
metadata: { instruction: { paths: ["/repo/sub/AGENTS.md"] } },
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
@@ -88,11 +87,11 @@ describe("SessionV2.log", () => {
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const created = yield* session.create({ location })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: AgentV2.ID.make("one") })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "one" })
|
||||
// Not in the durable manifest, so reads must skip it without failing.
|
||||
yield* events.publish(GapEvent, { sessionID: created.id, value: "filtered" })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: AgentV2.ID.make("two") })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: AgentV2.ID.make("three") })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "two" })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "three" })
|
||||
|
||||
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id, after: 1 })))
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect, Fiber, Option, Schema, Stream } from "effect"
|
||||
import { asc, eq, sql } from "drizzle-orm"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
@@ -16,11 +15,9 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Prompt } from "@opencode-ai/schema/prompt"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { fromRow } from "@opencode-ai/core/session/info"
|
||||
import { SessionInput } from "@opencode-ai/core/session/input"
|
||||
import { Shell } from "@opencode-ai/schema/shell"
|
||||
import {
|
||||
@@ -38,8 +35,7 @@ const sessionID = SessionV2.ID.make("ses_projector_test")
|
||||
const created = DateTime.makeUnsafe(0)
|
||||
const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }
|
||||
const previousModel = { ...model, variant: ModelV2.VariantID.make("medium") }
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
|
||||
const build = AgentV2.defaultID
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Message)
|
||||
|
||||
const assistantRow = (
|
||||
id: SessionMessage.ID,
|
||||
@@ -52,108 +48,12 @@ const assistantRow = (
|
||||
type,
|
||||
...data
|
||||
} = encodeMessage(
|
||||
SessionMessage.Assistant.make({ id, type: "assistant", agent: build, model, content: [], time, ...usage }),
|
||||
SessionMessage.Assistant.make({ id, type: "assistant", agent: "build", model, content: [], time, ...usage }),
|
||||
)
|
||||
return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(time.created), data }
|
||||
}
|
||||
|
||||
describe("SessionProjector", () => {
|
||||
it.effect("does not settle a pending manual compaction on an auto failure", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
const events = yield* EventV2.Service
|
||||
const inputID = SessionMessage.ID.make("msg_manual_compaction")
|
||||
yield* SessionInput.admitCompaction(db, events, { id: inputID, sessionID })
|
||||
|
||||
yield* events.publish(SessionEvent.Compaction.Failed, {
|
||||
sessionID,
|
||||
reason: "auto",
|
||||
error: { type: "compaction.failed", message: "Auto compaction failed" },
|
||||
})
|
||||
|
||||
expect(yield* SessionInput.pendingCompaction(db, sessionID)).toMatchObject({ id: inputID })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("loads legacy revert storage into canonical state", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
const legacy = JSON.stringify({
|
||||
messageID: "msg_boundary",
|
||||
snapshot: "tree",
|
||||
diff: "legacy patch",
|
||||
files: [{ path: "src/old.ts", status: "modified", additions: 1, deletions: 0, patch: "@@" }],
|
||||
})
|
||||
yield* db.run(sql`update session set revert = ${legacy} where id = ${sessionID}`)
|
||||
const stored = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()
|
||||
if (!stored) return yield* Effect.die("Session row missing")
|
||||
const storedRevert = fromRow(stored).revert
|
||||
expect(String(storedRevert?.messageID)).toBe("msg_boundary")
|
||||
expect(String(storedRevert?.snapshot)).toBe("tree")
|
||||
expect(storedRevert?.files).toEqual([
|
||||
{ file: "src/old.ts", status: "modified", additions: 1, deletions: 0, patch: "@@" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("folds live compaction deltas into running memory state", () =>
|
||||
Effect.gen(function* () {
|
||||
const state = {
|
||||
messages: [
|
||||
SessionMessage.CompactionRunning.make({
|
||||
id: SessionMessage.ID.make("msg_compaction"),
|
||||
type: "compaction",
|
||||
status: "running",
|
||||
reason: "manual",
|
||||
summary: "partial ",
|
||||
recent: "recent",
|
||||
time: { created },
|
||||
}),
|
||||
],
|
||||
}
|
||||
yield* SessionMessageUpdater.update(
|
||||
SessionMessageUpdater.memory(state),
|
||||
SessionEvent.Compaction.Delta.make({
|
||||
id: EventV2.ID.make("evt_delta"),
|
||||
type: "session.compaction.delta",
|
||||
created,
|
||||
data: { sessionID, text: "summary" },
|
||||
}),
|
||||
)
|
||||
expect(state.messages[0]).toMatchObject({ status: "running", summary: "partial summary", recent: "recent" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects staged, cleared, and committed reverts", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
@@ -189,7 +89,7 @@ describe("SessionProjector", () => {
|
||||
1,
|
||||
{ created },
|
||||
{
|
||||
cost: Money.USD.make(0.5),
|
||||
cost: 0.5,
|
||||
tokens: { input: 4, output: 1, reasoning: 1, cache: { read: 1, write: 0 } },
|
||||
},
|
||||
),
|
||||
@@ -198,7 +98,7 @@ describe("SessionProjector", () => {
|
||||
2,
|
||||
{ created },
|
||||
{
|
||||
cost: Money.USD.make(0.75),
|
||||
cost: 0.75,
|
||||
tokens: { input: 6, output: 3, reasoning: 1, cache: { read: 2, write: 1 } },
|
||||
},
|
||||
),
|
||||
@@ -211,7 +111,7 @@ describe("SessionProjector", () => {
|
||||
const events = yield* EventV2.Service
|
||||
yield* events.publish(SessionEvent.RevertEvent.Staged, {
|
||||
sessionID,
|
||||
revert: { messageID: boundary, snapshot: Snapshot.ID.make("tree"), files: [] },
|
||||
revert: { messageID: boundary, snapshot: Snapshot.ID.make("tree"), diff: "patch", files: [] },
|
||||
})
|
||||
expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toMatchObject({
|
||||
messageID: boundary,
|
||||
@@ -232,7 +132,7 @@ describe("SessionProjector", () => {
|
||||
(yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id),
|
||||
).toEqual([earlier])
|
||||
expect(yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()).toMatchObject({
|
||||
cost: Money.USD.make(1.25),
|
||||
cost: 1.25,
|
||||
tokens_input: 10,
|
||||
tokens_output: 4,
|
||||
tokens_reasoning: 2,
|
||||
@@ -385,7 +285,7 @@ describe("SessionProjector", () => {
|
||||
|
||||
yield* events.publish(SessionEvent.AgentSelected, {
|
||||
sessionID,
|
||||
agent: build,
|
||||
agent: "build",
|
||||
})
|
||||
yield* events.publish(SessionEvent.ModelSelected, {
|
||||
sessionID,
|
||||
@@ -427,7 +327,6 @@ describe("SessionProjector", () => {
|
||||
yield* events.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
recent: "recent context",
|
||||
})
|
||||
yield* events.publish(SessionEvent.Compaction.Delta, {
|
||||
sessionID,
|
||||
@@ -437,18 +336,18 @@ describe("SessionProjector", () => {
|
||||
yield* db
|
||||
.select({ id: EventTable.id })
|
||||
.from(EventTable)
|
||||
.where(sql`${EventTable.type} like 'session.compaction.delta.%'`)
|
||||
.where(eq(EventTable.type, SessionEvent.Compaction.Delta.type))
|
||||
.all()
|
||||
.pipe(Effect.orDie),
|
||||
).toHaveLength(0)
|
||||
).toEqual([])
|
||||
expect(
|
||||
yield* db
|
||||
.select({ data: SessionMessageTable.data })
|
||||
.select({ id: SessionMessageTable.id })
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.type, "compaction"))
|
||||
.all()
|
||||
.pipe(Effect.orDie),
|
||||
).toEqual([{ data: expect.objectContaining({ status: "running", summary: "", recent: "recent context" }) }])
|
||||
).toEqual([])
|
||||
yield* events.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
@@ -464,7 +363,7 @@ describe("SessionProjector", () => {
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const messages = rows.map((row) =>
|
||||
Schema.decodeUnknownSync(SessionMessage.Info)({ ...row.data, id: row.id, type: row.type }),
|
||||
Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }),
|
||||
)
|
||||
|
||||
expect(messages.map((message) => message.type)).toEqual([
|
||||
@@ -480,9 +379,7 @@ describe("SessionProjector", () => {
|
||||
})
|
||||
expect(messages.find((message) => message.type === "model-switched")).toMatchObject({ previous: previousModel })
|
||||
expect(messages.find((message) => message.type === "shell")).toMatchObject({
|
||||
command: "pwd",
|
||||
status: "exited",
|
||||
exit: 0,
|
||||
shell: { command: "pwd", status: "exited", exit: 0 },
|
||||
output: { output: "/project", truncated: false },
|
||||
time: { completed: DateTime.makeUnsafe(0) },
|
||||
})
|
||||
@@ -522,7 +419,11 @@ describe("SessionProjector", () => {
|
||||
.pipe(Effect.orDie)
|
||||
const events = yield* EventV2.Service
|
||||
const id = SessionMessage.ID.make("msg_creator_collision")
|
||||
const { id: _, type, ...data } = encodeMessage({ id, type: "synthetic", text: "existing", time: { created } })
|
||||
const {
|
||||
id: _,
|
||||
type,
|
||||
...data
|
||||
} = encodeMessage({ id, sessionID, type: "synthetic", text: "existing", time: { created } })
|
||||
yield* db
|
||||
.insert(SessionMessageTable)
|
||||
.values({ id, session_id: sessionID, type, seq: 0, time_created: 0, data })
|
||||
@@ -532,7 +433,7 @@ describe("SessionProjector", () => {
|
||||
.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
assistantMessageID: id,
|
||||
agent: build,
|
||||
agent: "build",
|
||||
model,
|
||||
})
|
||||
.pipe(Effect.exit)
|
||||
@@ -549,7 +450,7 @@ describe("SessionProjector", () => {
|
||||
const stale = SessionMessage.Assistant.make({
|
||||
id: SessionMessage.ID.make("msg_assistant_stale"),
|
||||
type: "assistant",
|
||||
agent: build,
|
||||
agent: "build",
|
||||
model,
|
||||
content: [],
|
||||
time: { created },
|
||||
@@ -557,7 +458,7 @@ describe("SessionProjector", () => {
|
||||
const completed = SessionMessage.Assistant.make({
|
||||
id: SessionMessage.ID.make("msg_assistant_completed"),
|
||||
type: "assistant",
|
||||
agent: build,
|
||||
agent: "build",
|
||||
model,
|
||||
content: [],
|
||||
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
|
||||
@@ -592,7 +493,7 @@ describe("SessionProjector", () => {
|
||||
const events = yield* EventV2.Service
|
||||
const first = SessionMessage.ID.make("msg_retry_first")
|
||||
const second = SessionMessage.ID.make("msg_retry_second")
|
||||
yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: first, agent: build, model })
|
||||
yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: first, agent: "build", model })
|
||||
yield* events.publish(SessionEvent.RetryScheduled, {
|
||||
sessionID,
|
||||
assistantMessageID: first,
|
||||
@@ -602,7 +503,7 @@ describe("SessionProjector", () => {
|
||||
})
|
||||
|
||||
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
Schema.decodeUnknownSync(SessionMessage.Info)({ ...row.data, id: row.id, type: row.type })
|
||||
Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type })
|
||||
const firstRow = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
@@ -614,7 +515,7 @@ describe("SessionProjector", () => {
|
||||
retry: { attempt: 2, at: DateTime.makeUnsafe(2_000), error: { type: "provider.transport" } },
|
||||
})
|
||||
|
||||
yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: second, agent: build, model })
|
||||
yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: second, agent: "build", model })
|
||||
yield* events.publish(SessionEvent.RetryScheduled, {
|
||||
sessionID,
|
||||
assistantMessageID: second,
|
||||
@@ -673,7 +574,7 @@ describe("SessionProjector", () => {
|
||||
sessionID,
|
||||
assistantMessageID: SessionMessage.ID.make("msg_assistant_2"),
|
||||
finish: "stop",
|
||||
cost: Money.USD.make(1.25),
|
||||
cost: 1.25,
|
||||
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
|
||||
})
|
||||
|
||||
@@ -685,13 +586,13 @@ describe("SessionProjector", () => {
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const messages = rows.map((row) =>
|
||||
Schema.decodeUnknownSync(SessionMessage.Info)({ ...row.data, id: row.id, type: row.type }),
|
||||
Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }),
|
||||
)
|
||||
expect(messages[0]).not.toHaveProperty("time.completed")
|
||||
expect(messages[1]).toMatchObject({
|
||||
type: "assistant",
|
||||
finish: "stop",
|
||||
cost: Money.USD.make(1.25),
|
||||
cost: 1.25,
|
||||
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
|
||||
time: { completed: DateTime.makeUnsafe(0) },
|
||||
})
|
||||
@@ -707,7 +608,7 @@ describe("SessionProjector", () => {
|
||||
})
|
||||
expect(Option.getOrThrow(yield* Fiber.join(usageUpdated)).data).toEqual({
|
||||
sessionID,
|
||||
cost: Money.USD.make(1.25),
|
||||
cost: 1.25,
|
||||
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
|
||||
})
|
||||
}),
|
||||
@@ -760,13 +661,13 @@ describe("SessionProjector", () => {
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const messages = rows.map((row) =>
|
||||
Schema.decodeUnknownSync(SessionMessage.Info)({ ...row.data, id: row.id, type: row.type }),
|
||||
Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }),
|
||||
)
|
||||
expect(messages).toEqual([
|
||||
SessionMessage.Assistant.make({
|
||||
id: SessionMessage.ID.make("msg_assistant_completed"),
|
||||
type: "assistant",
|
||||
agent: build,
|
||||
agent: "build",
|
||||
model,
|
||||
content: [SessionMessage.AssistantText.make({ type: "text", text: "" })],
|
||||
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
|
||||
@@ -774,7 +675,7 @@ describe("SessionProjector", () => {
|
||||
SessionMessage.Assistant.make({
|
||||
id: SessionMessage.ID.make("msg_assistant_stale"),
|
||||
type: "assistant",
|
||||
agent: build,
|
||||
agent: "build",
|
||||
model,
|
||||
content: [],
|
||||
time: { created },
|
||||
|
||||
@@ -6,7 +6,6 @@ import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
@@ -106,7 +105,7 @@ const eventCount = (type: string) =>
|
||||
),
|
||||
)
|
||||
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Message)
|
||||
const assistantRow = (id: SessionMessage.ID, seq: number) => {
|
||||
const {
|
||||
id: _,
|
||||
@@ -116,7 +115,7 @@ const assistantRow = (id: SessionMessage.ID, seq: number) => {
|
||||
SessionMessage.Assistant.make({
|
||||
id,
|
||||
type: "assistant",
|
||||
agent: AgentV2.ID.make("build"),
|
||||
agent: "build",
|
||||
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||
content: [],
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
@@ -678,6 +677,7 @@ describe("SessionV2.prompt", () => {
|
||||
...data
|
||||
} = encodeMessage({
|
||||
id: messageID,
|
||||
sessionID,
|
||||
type: "synthetic",
|
||||
text: "Existing history",
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
|
||||
@@ -191,36 +191,6 @@ describe("SessionRunCoordinator", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("preserves a forced wake received during active execution", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const firstGate = yield* Deferred.make<void>()
|
||||
const secondStarted = yield* Deferred.make<void>()
|
||||
const forces: boolean[] = []
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: (_key, force) =>
|
||||
Effect.sync(() => forces.push(force)).pipe(
|
||||
Effect.flatMap(() =>
|
||||
forces.length === 1
|
||||
? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(firstGate)))
|
||||
: Deferred.succeed(secondStarted, undefined),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
yield* coordinator.wake("session")
|
||||
yield* Deferred.await(firstStarted)
|
||||
yield* coordinator.wake("session", { force: true })
|
||||
yield* Deferred.succeed(firstGate, undefined)
|
||||
yield* Deferred.await(secondStarted)
|
||||
yield* coordinator.awaitIdle("session")
|
||||
|
||||
expect(forces).toEqual([false, true])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("runs again when woken during the follow-up", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -5,14 +5,13 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { AgentAttachment, Base64, FileAttachment } from "@opencode-ai/schema/prompt"
|
||||
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { Shell } from "@opencode-ai/schema/shell"
|
||||
import { DateTime } from "effect"
|
||||
|
||||
const created = DateTime.makeUnsafe(0)
|
||||
const id = (value: string) => SessionMessage.ID.make(`msg_${value}`)
|
||||
const model = ModelV2.Ref.make({ id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") })
|
||||
const build = AgentV2.defaultID
|
||||
|
||||
describe("toLLMMessages", () => {
|
||||
test("omits empty assistant turns", () => {
|
||||
@@ -20,7 +19,7 @@ describe("toLLMMessages", () => {
|
||||
SessionMessage.Assistant.make({
|
||||
id: id(value),
|
||||
type: "assistant",
|
||||
agent: build,
|
||||
agent: "build",
|
||||
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||
content,
|
||||
time: { created, completed: created },
|
||||
@@ -57,7 +56,7 @@ describe("toLLMMessages", () => {
|
||||
SessionMessage.AgentSelected.make({
|
||||
id: id("agent"),
|
||||
type: "agent-switched",
|
||||
agent: build,
|
||||
agent: "build",
|
||||
time: { created },
|
||||
}),
|
||||
SessionMessage.ModelSelected.make({
|
||||
@@ -83,16 +82,24 @@ describe("toLLMMessages", () => {
|
||||
SessionMessage.Synthetic.make({
|
||||
id: id("synthetic"),
|
||||
type: "synthetic",
|
||||
sessionID: SessionV2.ID.make("ses_translate"),
|
||||
text: "Synthetic context",
|
||||
time: { created },
|
||||
}),
|
||||
SessionMessage.Shell.make({
|
||||
id: id("shell"),
|
||||
type: "shell",
|
||||
shellID: Shell.ID.make("sh_test"),
|
||||
status: "exited",
|
||||
command: "pwd",
|
||||
exit: 0,
|
||||
shell: Shell.Info.make({
|
||||
id: Shell.ID.make("sh_test"),
|
||||
status: "exited",
|
||||
command: "pwd",
|
||||
cwd: "/project",
|
||||
shell: "/bin/sh",
|
||||
file: "/tmp/sh_test.out",
|
||||
exit: 0,
|
||||
metadata: {},
|
||||
time: { started: 0, completed: 0 },
|
||||
}),
|
||||
output: { output: "/project", cursor: 8, size: 8, truncated: false },
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
@@ -275,7 +282,7 @@ Recent work
|
||||
SessionMessage.Assistant.make({
|
||||
id: id("assistant"),
|
||||
type: "assistant",
|
||||
agent: build,
|
||||
agent: "build",
|
||||
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||
content: [
|
||||
SessionMessage.AssistantText.make({ type: "text", text: "Checking" }),
|
||||
@@ -288,7 +295,7 @@ Recent work
|
||||
type: "tool",
|
||||
id: "pending",
|
||||
name: "read",
|
||||
state: SessionMessage.ToolStateStreaming.make({ status: "streaming", input: '{"path":"README.md"}' }),
|
||||
state: SessionMessage.ToolStatePending.make({ status: "pending", input: '{"path":"README.md"}' }),
|
||||
time: { created },
|
||||
}),
|
||||
SessionMessage.AssistantTool.make({
|
||||
@@ -430,7 +437,7 @@ Recent work
|
||||
SessionMessage.Assistant.make({
|
||||
id: id("assistant-openai-reasoning"),
|
||||
type: "assistant",
|
||||
agent: build,
|
||||
agent: "build",
|
||||
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||
content: [
|
||||
SessionMessage.AssistantReasoning.make({
|
||||
@@ -454,41 +461,13 @@ Recent work
|
||||
])
|
||||
})
|
||||
|
||||
test("replays flat state under an OpenCode hosted model's route key", () => {
|
||||
const opencode = ModelV2.Ref.make({ id: ModelV2.ID.make("claude-fable-5"), providerID: ProviderV2.ID.opencode })
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.Assistant.make({
|
||||
id: id("assistant-opencode-reasoning"),
|
||||
type: "assistant",
|
||||
agent: build,
|
||||
model: opencode,
|
||||
content: [
|
||||
SessionMessage.AssistantReasoning.make({
|
||||
type: "reasoning",
|
||||
text: "Think",
|
||||
state: { signature: "signed" },
|
||||
}),
|
||||
],
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
],
|
||||
opencode,
|
||||
"anthropic",
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "signed" } } },
|
||||
])
|
||||
})
|
||||
|
||||
test("lowers failed assistant reasoning to text", () => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.Assistant.make({
|
||||
id: id("assistant-failed"),
|
||||
type: "assistant",
|
||||
agent: build,
|
||||
agent: "build",
|
||||
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||
content: [
|
||||
SessionMessage.AssistantReasoning.make({
|
||||
@@ -557,7 +536,7 @@ Recent work
|
||||
SessionMessage.Assistant.make({
|
||||
id: id("assistant-old-model"),
|
||||
type: "assistant",
|
||||
agent: build,
|
||||
agent: "build",
|
||||
model: { id: ModelV2.ID.make("old-model"), providerID: ProviderV2.ID.make("provider") },
|
||||
content: [
|
||||
SessionMessage.AssistantReasoning.make({
|
||||
@@ -652,7 +631,7 @@ Recent work
|
||||
SessionMessage.Assistant.make({
|
||||
id: id("assistant-alias"),
|
||||
type: "assistant",
|
||||
agent: build,
|
||||
agent: "build",
|
||||
model: { id: ModelV2.ID.make("fast"), providerID: ProviderV2.ID.make("provider") },
|
||||
content: [
|
||||
SessionMessage.AssistantReasoning.make({
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect } from "bun:test"
|
||||
import { LLM, Model } from "@opencode-ai/llm"
|
||||
import { LLMClient } from "@opencode-ai/llm/route"
|
||||
import { DateTime, Effect } from "effect"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
@@ -53,7 +52,6 @@ describe("SessionRunnerModel", () => {
|
||||
expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" })
|
||||
expect(resolved.route).toMatchObject({
|
||||
id: "openai-responses",
|
||||
providerMetadataKey: "openai",
|
||||
endpoint: { baseURL: "https://openai.example/v1" },
|
||||
defaults: {
|
||||
headers: { "x-test": "header" },
|
||||
@@ -133,7 +131,7 @@ describe("SessionRunnerModel", () => {
|
||||
providerID: catalog.providerID,
|
||||
variant: ModelV2.VariantID.make("high"),
|
||||
},
|
||||
cost: Money.USD.zero,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||
location: { directory: AbsolutePath.make("/project") },
|
||||
@@ -172,7 +170,7 @@ describe("SessionRunnerModel", () => {
|
||||
projectID: ProjectV2.ID.global,
|
||||
title: "test",
|
||||
model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") },
|
||||
cost: Money.USD.zero,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||
location: { directory: AbsolutePath.make("/project") },
|
||||
@@ -202,7 +200,7 @@ describe("SessionRunnerModel", () => {
|
||||
providerID: catalog.providerID,
|
||||
variant: ModelV2.VariantID.make("unknown"),
|
||||
},
|
||||
cost: Money.USD.zero,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||
location: { directory: AbsolutePath.make("/project") },
|
||||
@@ -238,7 +236,7 @@ describe("SessionRunnerModel", () => {
|
||||
projectID: ProjectV2.ID.global,
|
||||
title: "test",
|
||||
model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") },
|
||||
cost: Money.USD.zero,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||
location: { directory: AbsolutePath.make("/project") },
|
||||
@@ -265,7 +263,6 @@ describe("SessionRunnerModel", () => {
|
||||
|
||||
expect(resolved.route).toMatchObject({
|
||||
id: "anthropic-messages",
|
||||
providerMetadataKey: "anthropic",
|
||||
endpoint: { baseURL: "https://anthropic.example/v1" },
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { LLMEvent } from "@opencode-ai/llm"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
@@ -14,7 +12,7 @@ import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publis
|
||||
const sessionID = SessionV2.ID.make("ses_tool_event_test")
|
||||
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
|
||||
|
||||
const capture = (providerMetadataKey = "anthropic") => {
|
||||
const capture = () => {
|
||||
const published: Array<{ readonly type: string; readonly data: unknown }> = []
|
||||
const events = EventV2.Service.of({
|
||||
publish: (definition, data) =>
|
||||
@@ -42,12 +40,12 @@ const capture = (providerMetadataKey = "anthropic") => {
|
||||
published,
|
||||
publisher: createLLMEventPublisher(events, {
|
||||
sessionID,
|
||||
agent: AgentV2.ID.make("build"),
|
||||
agent: "build",
|
||||
model: {
|
||||
id: ModelV2.ID.make("model"),
|
||||
providerID: ProviderV2.ID.opencode,
|
||||
providerID: ProviderV2.ID.make("provider"),
|
||||
},
|
||||
providerMetadataKey,
|
||||
provider: "openai",
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -99,76 +97,35 @@ test("provider-executed success retains its raw provider result", async () => {
|
||||
expect(success?.data).toHaveProperty("result")
|
||||
})
|
||||
|
||||
test("provider metadata is flattened using the route key", async () => {
|
||||
test("provider state uses the route provider instead of the catalog provider", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(
|
||||
publisher.publish(
|
||||
LLMEvent.reasoningStart({ id: "reasoning", providerMetadata: { anthropic: { signature: "signed" } } }),
|
||||
LLMEvent.reasoningStart({ id: "reasoning", providerMetadata: { openai: { itemId: "reasoning" } } }),
|
||||
),
|
||||
)
|
||||
|
||||
expect(published.find((event) => event.type === "session.reasoning.started.1")?.data).toMatchObject({
|
||||
state: { signature: "signed" },
|
||||
state: { itemId: "reasoning" },
|
||||
})
|
||||
})
|
||||
|
||||
test("reasoning state from start, empty delta, and end is merged", async () => {
|
||||
test("reasoning state from an empty delta is retained at reasoning end", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(
|
||||
publisher.publish(
|
||||
LLMEvent.reasoningStart({ id: "reasoning", providerMetadata: { anthropic: { blockType: "thinking" } } }),
|
||||
),
|
||||
)
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.reasoningStart({ id: "reasoning" })))
|
||||
await Effect.runPromise(
|
||||
publisher.publish(
|
||||
LLMEvent.reasoningDelta({
|
||||
id: "reasoning",
|
||||
text: "",
|
||||
providerMetadata: { anthropic: { signature: "signed" }, gateway: { traceID: "trace" } },
|
||||
providerMetadata: { openai: { signature: "signed" } },
|
||||
}),
|
||||
),
|
||||
)
|
||||
await Effect.runPromise(
|
||||
publisher.publish(
|
||||
LLMEvent.reasoningEnd({ id: "reasoning", providerMetadata: { anthropic: { stopReason: "tool_use" } } }),
|
||||
),
|
||||
)
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.reasoningEnd({ id: "reasoning" })))
|
||||
|
||||
expect(published.find((event) => event.type === "session.reasoning.ended.1")?.data).toMatchObject({
|
||||
state: { blockType: "thinking", signature: "signed", stopReason: "tool_use" },
|
||||
})
|
||||
})
|
||||
|
||||
test("provider-executed tool metadata is flattened using the route key", async () => {
|
||||
const { published, publisher } = capture("openai")
|
||||
await Effect.runPromise(
|
||||
publisher.publish(
|
||||
LLMEvent.toolCall({
|
||||
id: "hosted",
|
||||
name: "web_search",
|
||||
input: { query: "Effect" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "call" } },
|
||||
}),
|
||||
),
|
||||
)
|
||||
await Effect.runPromise(
|
||||
publisher.publish(
|
||||
LLMEvent.toolResult({
|
||||
id: "hosted",
|
||||
name: "web_search",
|
||||
result: { type: "json", value: { found: true } },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "result" } },
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(published.find((event) => event.type === "session.tool.called.1")?.data).toMatchObject({
|
||||
state: { itemId: "call" },
|
||||
})
|
||||
expect(published.find((event) => event.type === "session.tool.success.1")?.data).toMatchObject({
|
||||
resultState: { itemId: "result" },
|
||||
state: { signature: "signed" },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -236,7 +193,7 @@ test("content-filter finish retains failure evidence until step closeout", async
|
||||
if (!settlement) throw new Error("Expected content-filter settlement")
|
||||
await Effect.runPromise(
|
||||
publisher.publishStepFailure({
|
||||
cost: Money.USD.make(1.25),
|
||||
cost: 1.25,
|
||||
tokens: settlement.tokens,
|
||||
}),
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,8 +26,7 @@ const skills = Layer.mock(SkillV2.Service, {
|
||||
list: () =>
|
||||
Effect.succeed([
|
||||
SkillV2.Info.make({
|
||||
id: SkillV2.ID.make("effect"),
|
||||
name: SkillV2.Name.make("Effect"),
|
||||
name: "effect",
|
||||
description: "Effect guidance",
|
||||
location: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")),
|
||||
content: "Use Effect",
|
||||
@@ -61,10 +60,10 @@ describe("SessionV2.skill", () => {
|
||||
const session = yield* sessions.create({ location })
|
||||
const id = SessionMessage.ID.make("msg_caller_skill")
|
||||
|
||||
yield* sessions.skill({ id, sessionID: session.id, skill: SkillV2.ID.make("effect"), resume: false })
|
||||
yield* sessions.skill({ id, sessionID: session.id, skill: "effect", resume: false })
|
||||
|
||||
expect(yield* sessions.messages({ sessionID: session.id })).toContainEqual(
|
||||
expect.objectContaining({ id, type: "skill", skill: "effect", name: "Effect", text: "Use Effect" }),
|
||||
expect.objectContaining({ id, type: "skill", name: "effect", text: "Use Effect" }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4,7 +4,6 @@ import { DateTime, Effect, Schema } from "effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
@@ -52,7 +51,7 @@ describe("Tool.Progress", () => {
|
||||
yield* service.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
agent: AgentV2.ID.make("build"),
|
||||
agent: "build",
|
||||
model,
|
||||
})
|
||||
const readAssistant = Effect.gen(function* () {
|
||||
|
||||
@@ -76,7 +76,6 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||
|
||||
const schemas = [
|
||||
[AgentV2.ID, Agent.ID],
|
||||
[AgentV2.Name, Agent.Name],
|
||||
[AgentV2.Color, Agent.Color],
|
||||
[AgentV2.Info, Agent.Info],
|
||||
[coreCommand.Info, Command.Info],
|
||||
@@ -146,7 +145,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||
[coreSessionMessage.Synthetic, SessionMessage.Synthetic],
|
||||
[coreSessionMessage.System, SessionMessage.System],
|
||||
[coreSessionMessage.Shell, SessionMessage.Shell],
|
||||
[coreSessionMessage.ToolStateStreaming, SessionMessage.ToolStateStreaming],
|
||||
[coreSessionMessage.ToolStatePending, SessionMessage.ToolStatePending],
|
||||
[coreSessionMessage.ToolStateRunning, SessionMessage.ToolStateRunning],
|
||||
[coreSessionMessage.ToolStateCompleted, SessionMessage.ToolStateCompleted],
|
||||
[coreSessionMessage.ToolStateError, SessionMessage.ToolStateError],
|
||||
@@ -157,7 +156,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||
[coreSessionMessage.AssistantContent, SessionMessage.AssistantContent],
|
||||
[coreSessionMessage.Assistant, SessionMessage.Assistant],
|
||||
[coreSessionMessage.Compaction, SessionMessage.Compaction],
|
||||
[coreSessionMessage.Info, SessionMessage.Info],
|
||||
[coreSessionMessage.Message, SessionMessage.Message],
|
||||
[coreSessionTodo.Info, SessionTodo.Info],
|
||||
[coreSessionTodo.Event, SessionTodo.Event],
|
||||
[coreSkill.DirectorySource, Skill.DirectorySource],
|
||||
|
||||
@@ -88,15 +88,13 @@ describe("SkillV2", () => {
|
||||
])
|
||||
expect(yield* skill.list()).toEqual([
|
||||
SkillV2.Info.make({
|
||||
id: SkillV2.ID.make("foo"),
|
||||
name: SkillV2.Name.make("foo"),
|
||||
name: "foo",
|
||||
slash: true,
|
||||
location: AbsolutePath.make(path.join(first, "foo.md")),
|
||||
content: "# foo",
|
||||
}),
|
||||
{
|
||||
id: SkillV2.ID.make("review"),
|
||||
name: SkillV2.Name.make("review"),
|
||||
name: "review",
|
||||
description: "Second",
|
||||
location: AbsolutePath.make(path.join(second, "review", "SKILL.md")),
|
||||
content: "# review",
|
||||
@@ -131,8 +129,8 @@ describe("SkillV2", () => {
|
||||
const skill = yield* SkillV2.Service
|
||||
yield* skill.transform((editor) => editor.source({ type: "url", url: "https://example.test/skills/" }))
|
||||
|
||||
expect((yield* skill.list()).map((item) => item.name)).toEqual([SkillV2.Name.make("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(["deploy"])
|
||||
expect(pulls).toBe(1)
|
||||
expect(SkillV2.available(yield* skill.list(), (yield* agents.get(AgentV2.ID.make("reviewer")))!)).toEqual([])
|
||||
}),
|
||||
@@ -167,8 +165,7 @@ metadata:
|
||||
|
||||
expect(yield* skill.list()).toEqual([
|
||||
{
|
||||
id: SkillV2.ID.make("manual"),
|
||||
name: SkillV2.Name.make("manual"),
|
||||
name: "manual",
|
||||
description: "Manual only",
|
||||
slash: true,
|
||||
autoinvoke: false,
|
||||
|
||||
@@ -11,28 +11,24 @@ import { it } from "../lib/effect"
|
||||
|
||||
const build = AgentV2.ID.make("build")
|
||||
const effect = SkillV2.Info.make({
|
||||
id: SkillV2.ID.make("effect"),
|
||||
name: SkillV2.Name.make("Effect"),
|
||||
name: "effect",
|
||||
description: "Build applications with Effect",
|
||||
location: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")),
|
||||
content: "Effect guidance",
|
||||
})
|
||||
const hidden = SkillV2.Info.make({
|
||||
id: SkillV2.ID.make("hidden"),
|
||||
name: SkillV2.Name.make("Hidden"),
|
||||
name: "hidden",
|
||||
location: AbsolutePath.make(path.resolve("/skills/hidden/SKILL.md")),
|
||||
content: "Undescribed guidance",
|
||||
})
|
||||
const denied = SkillV2.Info.make({
|
||||
id: SkillV2.ID.make("denied"),
|
||||
name: SkillV2.Name.make("Denied"),
|
||||
name: "denied",
|
||||
description: "Must not be advertised",
|
||||
location: AbsolutePath.make(path.resolve("/skills/denied/SKILL.md")),
|
||||
content: "Denied guidance",
|
||||
})
|
||||
const manual = SkillV2.Info.make({
|
||||
id: SkillV2.ID.make("manual"),
|
||||
name: SkillV2.Name.make("Manual"),
|
||||
name: "manual",
|
||||
description: "Load only when explicitly selected",
|
||||
autoinvoke: false,
|
||||
location: AbsolutePath.make(path.resolve("/skills/manual/SKILL.md")),
|
||||
@@ -63,8 +59,7 @@ describe("SkillGuidance", () => {
|
||||
"Use the skill tool to load a skill when a task matches its description.",
|
||||
"<available_skills>",
|
||||
" <skill>",
|
||||
" <id>effect</id>",
|
||||
" <name>Effect</name>",
|
||||
" <name>effect</name>",
|
||||
" <description>Build applications with Effect</description>",
|
||||
" </skill>",
|
||||
"</available_skills>",
|
||||
@@ -79,7 +74,7 @@ describe("SkillGuidance", () => {
|
||||
.pipe(Effect.flatMap((context) => Instructions.reconcile(context, initialized.applied))),
|
||||
).toMatchObject({
|
||||
_tag: "Updated",
|
||||
text: "The following skill IDs are no longer available and must not be used: effect.",
|
||||
text: "The following skills are no longer available and must not be used: effect.",
|
||||
})
|
||||
}).pipe(Effect.provide(layer(() => skills)))
|
||||
})
|
||||
@@ -87,8 +82,7 @@ describe("SkillGuidance", () => {
|
||||
it.effect("announces added and removed skills as deltas without restating the list", () => {
|
||||
const agent = AgentV2.Info.make(AgentV2.Info.empty(build))
|
||||
const debugging = SkillV2.Info.make({
|
||||
id: SkillV2.ID.make("debugging"),
|
||||
name: SkillV2.Name.make("Debugging"),
|
||||
name: "debugging",
|
||||
description: "Diagnose hard bugs",
|
||||
location: AbsolutePath.make(path.resolve("/skills/debugging/SKILL.md")),
|
||||
content: "Debugging guidance",
|
||||
@@ -109,8 +103,7 @@ describe("SkillGuidance", () => {
|
||||
text: [
|
||||
"New skills are available in addition to those previously listed:",
|
||||
" <skill>",
|
||||
" <id>debugging</id>",
|
||||
" <name>Debugging</name>",
|
||||
" <name>debugging</name>",
|
||||
" <description>Diagnose hard bugs</description>",
|
||||
" </skill>",
|
||||
].join("\n"),
|
||||
@@ -124,7 +117,7 @@ describe("SkillGuidance", () => {
|
||||
)
|
||||
expect(removed).toMatchObject({
|
||||
_tag: "Updated",
|
||||
text: "The following skill IDs are no longer available and must not be used: effect.",
|
||||
text: "The following skills are no longer available and must not be used: effect.",
|
||||
})
|
||||
}).pipe(Effect.provide(layer(() => skills)))
|
||||
})
|
||||
@@ -199,7 +192,7 @@ describe("SkillGuidance", () => {
|
||||
const guidance = yield* SkillGuidance.Service
|
||||
expect(
|
||||
(yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(Instructions.initialize))).text,
|
||||
).toContain("<name>Effect</name>")
|
||||
).toContain("<name>effect</name>")
|
||||
}).pipe(Effect.provide(layer(() => [effect])))
|
||||
})
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ describe("Snapshot", () => {
|
||||
const plan = new Map([[RelativePath.make("scope/tracked.txt"), before]])
|
||||
const preview = yield* snapshot.preview({ files: plan, context: 1 })
|
||||
expect(preview).toHaveLength(1)
|
||||
expect(preview[0]?.file).toBe(RelativePath.make("scope/tracked.txt"))
|
||||
expect(preview[0]?.path).toBe(RelativePath.make("scope/tracked.txt"))
|
||||
yield* snapshot.restore({ files: plan })
|
||||
expect(yield* read(path.join(location, "tracked.txt"))).toBe("one\n")
|
||||
expect(yield* read(path.join(location, "added.txt"))).toBe("added\n")
|
||||
|
||||
@@ -13,16 +13,16 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { PatchTool } from "@opencode-ai/core/tool/patch"
|
||||
import { ApplyPatchTool } from "@opencode-ai/core/tool/apply-patch"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const patchToolNode = makeLocationNode({
|
||||
name: "test/patch-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
|
||||
const applyPatchToolNode = makeLocationNode({
|
||||
name: "test/apply-patch-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(ApplyPatchTool.Plugin)),
|
||||
deps: [ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node],
|
||||
})
|
||||
|
||||
@@ -116,7 +116,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
|
||||
ToolRegistry.toolsNode,
|
||||
LocationMutation.node,
|
||||
FileMutation.node,
|
||||
patchToolNode,
|
||||
applyPatchToolNode,
|
||||
]),
|
||||
[
|
||||
[FSUtil.node, filesystem],
|
||||
@@ -129,7 +129,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
|
||||
)
|
||||
}
|
||||
|
||||
const call = (patchText: string, id = "call-patch") => ({
|
||||
const call = (patchText: string, id = "call-apply-patch") => ({
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call" as const, id, name: "patch", input: { patchText } },
|
||||
@@ -147,7 +147,7 @@ const exists = (target: string) =>
|
||||
)
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
describe("PatchTool", () => {
|
||||
describe("ApplyPatchTool", () => {
|
||||
it.live("registers and sequentially applies add, update, and delete hunks", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -3,7 +3,6 @@ import { realpathSync } from "node:fs"
|
||||
import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { DateTime, Duration, Effect, Fiber, Layer, Scope } from "effect"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
|
||||
@@ -105,7 +104,7 @@ const executionNode = makeGlobalNode({
|
||||
sessionID: id,
|
||||
assistantMessageID,
|
||||
finish: "stop",
|
||||
cost: Money.USD.zero,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
})
|
||||
@@ -443,7 +442,10 @@ describe("ShellTool", () => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const settled = yield* settleTool(registry, call({ command: idleCommand, timeout: 50, background: true }))
|
||||
const settled = yield* settleTool(
|
||||
registry,
|
||||
call({ command: idleCommand, timeout: 50, background: true }),
|
||||
)
|
||||
const structured = settled.output?.structured as Record<string, unknown> | undefined
|
||||
const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined
|
||||
expect(settled.output?.structured).toMatchObject({ truncated: false })
|
||||
|
||||
@@ -26,7 +26,7 @@ const skillToolNode = makeLocationNode({
|
||||
const sessionID = SessionV2.ID.make("ses_skill_tool_test")
|
||||
|
||||
describe("SkillTool", () => {
|
||||
it.live("lists available skills, authorizes the selected ID, and loads model-facing content", () =>
|
||||
it.live("lists available skills, authorizes the selected name, and loads model-facing content", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
@@ -42,8 +42,7 @@ describe("SkillTool", () => {
|
||||
)
|
||||
|
||||
const info: SkillV2.Info = {
|
||||
id: SkillV2.ID.make("effect"),
|
||||
name: SkillV2.Name.make("Effect"),
|
||||
name: "effect",
|
||||
description: "Use Effect",
|
||||
location: AbsolutePath.make(location),
|
||||
content: "# Effect\n\nGuidance",
|
||||
@@ -103,7 +102,7 @@ describe("SkillTool", () => {
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-skill", name: "skill", input: { id: "effect" } },
|
||||
call: { type: "tool-call", id: "call-skill", name: "skill", input: { name: "effect" } },
|
||||
}),
|
||||
).toEqual({
|
||||
type: "text",
|
||||
@@ -114,11 +113,11 @@ describe("SkillTool", () => {
|
||||
yield* settleTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-skill-overflow", name: "skill", input: { id: "effect" } },
|
||||
call: { type: "tool-call", id: "call-skill-overflow", name: "skill", input: { name: "effect" } },
|
||||
}),
|
||||
).toMatchObject({
|
||||
result: { type: "text", value: SkillTool.toModelOutput(info, [reference]) },
|
||||
output: { structured: { name: "Effect" } },
|
||||
output: { structured: { name: "effect" } },
|
||||
})
|
||||
expect(assertions).toMatchObject([
|
||||
{ sessionID, action: "skill", resources: ["effect"], save: ["effect"] },
|
||||
@@ -128,7 +127,7 @@ describe("SkillTool", () => {
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-missing-skill", name: "skill", input: { id: "missing" } },
|
||||
call: { type: "tool-call", id: "call-missing-skill", name: "skill", input: { name: "missing" } },
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Unable to load skill missing" })
|
||||
deny = true
|
||||
@@ -136,13 +135,12 @@ describe("SkillTool", () => {
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-denied-skill", name: "skill", input: { id: "effect" } },
|
||||
call: { type: "tool-call", id: "call-denied-skill", name: "skill", input: { name: "effect" } },
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Unable to load skill effect" })
|
||||
deny = false
|
||||
const flat = SkillV2.Info.make({
|
||||
id: SkillV2.ID.make("public"),
|
||||
name: SkillV2.Name.make("Public"),
|
||||
name: "public",
|
||||
description: "Public guidance",
|
||||
location: AbsolutePath.make(path.join(tmp.path, "public.md")),
|
||||
content: "Public",
|
||||
@@ -158,7 +156,7 @@ describe("SkillTool", () => {
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { id: "public" } },
|
||||
call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { name: "public" } },
|
||||
}),
|
||||
).toEqual({ type: "text", value: SkillTool.toModelOutput(flat, []) })
|
||||
}).pipe(Effect.provide(skillToolLayer))
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
|
||||
@@ -71,7 +70,7 @@ const executionNode = makeGlobalNode({
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
finish: "stop",
|
||||
cost: Money.USD.zero,
|
||||
cost: 0,
|
||||
tokens,
|
||||
})
|
||||
})
|
||||
@@ -108,11 +107,6 @@ const withSubagent = (location: Location.Ref) =>
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
yield* AgentV2.Service.use((agents) =>
|
||||
agents.transform((draft) => {
|
||||
// The caller identity used by executeTool; subagent permission asserts against it.
|
||||
draft.update(toolIdentity.agent, (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.permissions.push({ action: "*", resource: "*", effect: "allow" })
|
||||
})
|
||||
draft.update(AgentV2.ID.make("reviewer"), (agent) => {
|
||||
agent.mode = "subagent"
|
||||
agent.model = childModel
|
||||
|
||||
+697
-683
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { FileDiffInfo, Message, Model, Part, Session } from "@opencode-ai/sdk/v2"
|
||||
import { Message, Model, Part, Session, SnapshotFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { iife } from "@opencode-ai/core/util/iife"
|
||||
import z from "zod"
|
||||
import { Storage } from "./storage"
|
||||
@@ -30,7 +30,7 @@ export namespace Share {
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("session_diff"),
|
||||
data: z.custom<FileDiffInfo[]>(),
|
||||
data: z.custom<SnapshotFileDiff[]>(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("model"),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FileDiffInfo, Message, Model, Part, Session, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { Message, Model, Part, Session, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { SessionTurn } from "@opencode-ai/session-ui/session-turn"
|
||||
import { SessionReview } from "@opencode-ai/session-ui/session-review"
|
||||
import { DataProvider } from "@opencode-ai/session-ui/context"
|
||||
@@ -65,7 +65,7 @@ const getData = query(async (shareID) => {
|
||||
shareID: string
|
||||
session: Session[]
|
||||
session_diff: {
|
||||
[sessionID: string]: FileDiffInfo[]
|
||||
[sessionID: string]: SnapshotFileDiff[]
|
||||
}
|
||||
session_status: {
|
||||
[sessionID: string]: SessionStatus
|
||||
|
||||
@@ -876,7 +876,6 @@ export const protocol = Protocol.make({
|
||||
export const route = Route.make({
|
||||
id: ADAPTER,
|
||||
provider: "anthropic",
|
||||
providerMetadataKey: "anthropic",
|
||||
protocol,
|
||||
endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }),
|
||||
auth: Auth.none,
|
||||
|
||||
@@ -657,7 +657,6 @@ export const protocol = Protocol.make({
|
||||
export const route = Route.make({
|
||||
id: ADAPTER,
|
||||
provider: "bedrock",
|
||||
providerMetadataKey: "bedrock",
|
||||
protocol,
|
||||
// Bedrock's URL embeds the region in the route endpoint host and the
|
||||
// validated modelId in the path. We read the validated body so the URL
|
||||
|
||||
@@ -500,7 +500,6 @@ export const protocol = Protocol.make({
|
||||
export const route = Route.make({
|
||||
id: ADAPTER,
|
||||
provider: "google",
|
||||
providerMetadataKey: "google",
|
||||
protocol,
|
||||
// Gemini's path embeds the model id and pins SSE framing at the URL level.
|
||||
endpoint: Endpoint.path(({ request }) => `/models/${request.model.id}:streamGenerateContent?alt=sse`, {
|
||||
|
||||
@@ -493,7 +493,6 @@ export const httpTransport = HttpTransport.sseJson.with<OpenAIChatBody>()
|
||||
export const route = Route.make({
|
||||
id: ADAPTER,
|
||||
provider: "openai",
|
||||
providerMetadataKey: "openai",
|
||||
protocol,
|
||||
endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }),
|
||||
auth: Auth.none,
|
||||
|
||||
@@ -16,7 +16,6 @@ export type OpenAICompatibleChatModelInput = RouteRoutedModelInput
|
||||
*/
|
||||
export const route = Route.make({
|
||||
id: ADAPTER,
|
||||
providerMetadataKey: "openai",
|
||||
protocol: OpenAIChat.protocol,
|
||||
endpoint: Endpoint.path("/chat/completions"),
|
||||
framing: Framing.sse,
|
||||
|
||||
@@ -990,7 +990,6 @@ export const httpTransport = HttpTransport.sseJson.with<OpenAIResponsesBody>()
|
||||
export const route = Route.make({
|
||||
id: ADAPTER,
|
||||
provider: "openai",
|
||||
providerMetadataKey: "openai",
|
||||
protocol,
|
||||
endpoint,
|
||||
auth,
|
||||
@@ -1019,7 +1018,6 @@ export const webSocketTransport = WebSocketTransport.jsonTransport.with<
|
||||
export const webSocketRoute = Route.make({
|
||||
id: `${ADAPTER}-websocket`,
|
||||
provider: "openai",
|
||||
providerMetadataKey: "openai",
|
||||
protocol,
|
||||
endpoint,
|
||||
auth,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user