Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 243a7adff7 | |||
| 4c1c374a41 | |||
| b9fa63a0bc | |||
| 846d548154 | |||
| 84336e4f91 | |||
| 01a5c69244 | |||
| d78f91afeb | |||
| 33762292f7 | |||
| 5d6aa3b41a | |||
| 683aca5dbe | |||
| 92025e9a47 | |||
| 411e053572 | |||
| b862d178bf | |||
| bda0ddc207 | |||
| 58ba99e505 | |||
| 6ee817d041 | |||
| dfeb1b5051 | |||
| 61a7f6db35 | |||
| 6446b8ae3b | |||
| ae53163cad | |||
| 41202819a4 | |||
| a31698f99b | |||
| 6248542c49 | |||
| d25c91e5eb | |||
| 5d63020dcd | |||
| 062f54590e | |||
| a76c6918d2 | |||
| 2b91a6f210 | |||
| 10579cceb2 | |||
| 25702e01ce | |||
| ecc5c44d9a | |||
| f5a0b920a2 | |||
| 2caa016fe1 | |||
| 6861fedd09 | |||
| 3d072112ce | |||
| bdfea046db | |||
| a2d08fb63b | |||
| 5a55135d89 | |||
| 8870d36e0f | |||
| eb923c27ca | |||
| 9903abc704 | |||
| 225a1fbf35 | |||
| 93159bccbf | |||
| fab8ec4f54 | |||
| a3035c53ea | |||
| 36c416e143 | |||
| e1e0304a96 | |||
| 71c3a7c8f2 | |||
| ecdfff5a42 | |||
| 4b948c5d74 | |||
| cd56c51e2d | |||
| 43e39d7f68 | |||
| 7a17925495 | |||
| 5acb2530b4 | |||
| 44a6787359 | |||
| 42e6b7db32 | |||
| 2c02f8bace | |||
| 2ec20e576b | |||
| 20f47fec7a | |||
| 65210f2d97 | |||
| af0b7ffae7 |
@@ -6,7 +6,6 @@ on:
|
||||
branches:
|
||||
- ci
|
||||
- dev
|
||||
- v2
|
||||
- beta
|
||||
- fix/npm-native-binary-install
|
||||
- snapshot-*
|
||||
@@ -32,9 +31,6 @@ permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'next') || '' }}
|
||||
|
||||
jobs:
|
||||
version:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
@@ -126,7 +122,7 @@ jobs:
|
||||
- build-cli
|
||||
- version
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -225,7 +221,7 @@ jobs:
|
||||
needs:
|
||||
- build-cli
|
||||
- version
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
continue-on-error: false
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
@@ -451,7 +447,6 @@ jobs:
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'v2'
|
||||
with:
|
||||
name: opencode-cli-signed-windows
|
||||
path: packages/opencode/dist
|
||||
|
||||
@@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- v2
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -75,9 +74,13 @@ jobs:
|
||||
working-directory: packages/client
|
||||
run: bun run check:generated
|
||||
|
||||
- name: Run HttpApi exerciser gates
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: packages/opencode
|
||||
run: bun run test:httpapi
|
||||
|
||||
e2e:
|
||||
name: e2e (${{ matrix.settings.name }})
|
||||
if: github.ref_name != 'v2' && github.head_ref != 'v2'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.17.11",
|
||||
"bin": {
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
"lildax": "./bin/lildax.cjs",
|
||||
},
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -106,15 +106,12 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"effect": "catalog:",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"semver": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/semver": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
@@ -939,7 +936,6 @@
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"clipboardy": "4.0.0",
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -5983,8 +5979,6 @@
|
||||
|
||||
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
||||
|
||||
"@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
|
||||
|
||||
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||
|
||||
+1
-2
@@ -2,12 +2,11 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.14",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
|
||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useSync } from "@/context/sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
|
||||
import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
|
||||
@@ -45,6 +45,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||
})
|
||||
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
|
||||
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
||||
|
||||
const usd = createMemo(
|
||||
() =>
|
||||
@@ -54,10 +55,10 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
}),
|
||||
)
|
||||
|
||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
|
||||
const context = createMemo(() => metrics().context)
|
||||
const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
|
||||
const tokens = createMemo(() => info()?.tokens)
|
||||
const cost = createMemo(() => {
|
||||
return usd().format(metrics().totalCost)
|
||||
return usd().format(info()?.cost ?? 0)
|
||||
})
|
||||
|
||||
const openContext = () => {
|
||||
@@ -82,18 +83,22 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
|
||||
const tooltipValue = () => (
|
||||
<div>
|
||||
<Show when={tokens()}>
|
||||
{(value) => (
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-text-invert-strong">
|
||||
{getSessionTokenTotal(value())?.toLocaleString(language.intl())}
|
||||
</span>
|
||||
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={context()}>
|
||||
{(ctx) => (
|
||||
<>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-text-invert-strong">{ctx().total.toLocaleString(language.intl())}</span>
|
||||
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-text-invert-strong">{ctx().usage ?? 0}%</span>
|
||||
<span class="text-text-invert-base">{language.t("context.usage.usage")}</span>
|
||||
</div>
|
||||
</>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-text-invert-strong">{ctx().usage ?? 0}%</span>
|
||||
<span class="text-text-invert-base">{language.t("context.usage.usage")}</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
import { getSessionContextMetrics } from "./session-context-metrics"
|
||||
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
|
||||
|
||||
const assistant = (
|
||||
id: string,
|
||||
@@ -37,8 +37,8 @@ const user = (id: string) => {
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
describe("getSessionContextMetrics", () => {
|
||||
test("computes totals and usage from latest assistant with tokens", () => {
|
||||
describe("getSessionContext", () => {
|
||||
test("computes usage from latest assistant with tokens", () => {
|
||||
const messages = [
|
||||
user("u1"),
|
||||
assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5),
|
||||
@@ -57,45 +57,52 @@ describe("getSessionContextMetrics", () => {
|
||||
},
|
||||
]
|
||||
|
||||
const metrics = getSessionContextMetrics(messages, providers)
|
||||
const ctx = getSessionContext(messages, providers)
|
||||
|
||||
expect(metrics.totalCost).toBe(1.75)
|
||||
expect(metrics.context?.message.id).toBe("a2")
|
||||
expect(metrics.context?.total).toBe(500)
|
||||
expect(metrics.context?.usage).toBe(50)
|
||||
expect(metrics.context?.providerLabel).toBe("OpenAI")
|
||||
expect(metrics.context?.modelLabel).toBe("GPT-4.1")
|
||||
expect(ctx?.message.id).toBe("a2")
|
||||
expect(ctx?.usage).toBe(50)
|
||||
expect(ctx?.providerLabel).toBe("OpenAI")
|
||||
expect(ctx?.modelLabel).toBe("GPT-4.1")
|
||||
})
|
||||
|
||||
test("preserves fallback labels and null usage when model metadata is missing", () => {
|
||||
const messages = [assistant("a1", { input: 40, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1, "p-1", "m-1")]
|
||||
const providers = [{ id: "p-1", models: {} }]
|
||||
|
||||
const metrics = getSessionContextMetrics(messages, providers)
|
||||
const ctx = getSessionContext(messages, providers)
|
||||
|
||||
expect(metrics.context?.providerLabel).toBe("p-1")
|
||||
expect(metrics.context?.modelLabel).toBe("m-1")
|
||||
expect(metrics.context?.limit).toBeUndefined()
|
||||
expect(metrics.context?.usage).toBeNull()
|
||||
expect(ctx?.providerLabel).toBe("p-1")
|
||||
expect(ctx?.modelLabel).toBe("m-1")
|
||||
expect(ctx?.limit).toBeUndefined()
|
||||
expect(ctx?.usage).toBeNull()
|
||||
})
|
||||
|
||||
test("recomputes when message array is mutated in place", () => {
|
||||
const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)]
|
||||
const providers = [{ id: "openai", models: {} }]
|
||||
|
||||
const one = getSessionContextMetrics(messages, providers)
|
||||
const one = getSessionContext(messages, providers)
|
||||
messages.push(assistant("a2", { input: 100, output: 20, reasoning: 0, read: 0, write: 0 }, 0.75))
|
||||
const two = getSessionContextMetrics(messages, providers)
|
||||
const two = getSessionContext(messages, providers)
|
||||
|
||||
expect(one.context?.message.id).toBe("a1")
|
||||
expect(two.context?.message.id).toBe("a2")
|
||||
expect(two.totalCost).toBe(1)
|
||||
expect(one?.message.id).toBe("a1")
|
||||
expect(two?.message.id).toBe("a2")
|
||||
})
|
||||
|
||||
test("returns empty metrics when inputs are undefined", () => {
|
||||
const metrics = getSessionContextMetrics(undefined, undefined)
|
||||
test("returns undefined when inputs are undefined", () => {
|
||||
const ctx = getSessionContext(undefined, undefined)
|
||||
|
||||
expect(metrics.totalCost).toBe(0)
|
||||
expect(metrics.context).toBeUndefined()
|
||||
expect(ctx).toBeUndefined()
|
||||
})
|
||||
|
||||
test("computes stored session token totals", () => {
|
||||
expect(
|
||||
getSessionTokenTotal({
|
||||
input: 10,
|
||||
output: 20,
|
||||
reasoning: 30,
|
||||
cache: { read: 40, write: 50 },
|
||||
}),
|
||||
).toBe(150)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AssistantMessage, Message, Session } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
type Provider = {
|
||||
id: string
|
||||
@@ -21,19 +21,9 @@ type Context = {
|
||||
modelLabel: string
|
||||
limit: number | undefined
|
||||
input: number
|
||||
output: number
|
||||
reasoning: number
|
||||
cacheRead: number
|
||||
cacheWrite: number
|
||||
total: number
|
||||
usage: number | null
|
||||
}
|
||||
|
||||
type Metrics = {
|
||||
totalCost: number
|
||||
context: Context | undefined
|
||||
}
|
||||
|
||||
const tokenTotal = (msg: AssistantMessage) => {
|
||||
return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
|
||||
}
|
||||
@@ -47,10 +37,9 @@ const lastAssistantWithTokens = (messages: Message[]) => {
|
||||
}
|
||||
}
|
||||
|
||||
const build = (messages: Message[] = [], providers: Provider[] = []): Metrics => {
|
||||
const totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0)
|
||||
const build = (messages: Message[] = [], providers: Provider[] = []): Context | undefined => {
|
||||
const message = lastAssistantWithTokens(messages)
|
||||
if (!message) return { totalCost, context: undefined }
|
||||
if (!message) return undefined
|
||||
|
||||
const provider = providers.find((item) => item.id === message.providerID)
|
||||
const model = provider?.models[message.modelID]
|
||||
@@ -58,25 +47,22 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Metrics =>
|
||||
const total = tokenTotal(message)
|
||||
|
||||
return {
|
||||
totalCost,
|
||||
context: {
|
||||
message,
|
||||
provider,
|
||||
model,
|
||||
providerLabel: provider?.name ?? message.providerID,
|
||||
modelLabel: model?.name ?? message.modelID,
|
||||
limit,
|
||||
input: message.tokens.input,
|
||||
output: message.tokens.output,
|
||||
reasoning: message.tokens.reasoning,
|
||||
cacheRead: message.tokens.cache.read,
|
||||
cacheWrite: message.tokens.cache.write,
|
||||
total,
|
||||
usage: limit ? Math.round((total / limit) * 100) : null,
|
||||
},
|
||||
message,
|
||||
provider,
|
||||
model,
|
||||
providerLabel: provider?.name ?? message.providerID,
|
||||
modelLabel: model?.name ?? message.modelID,
|
||||
limit,
|
||||
input: message.tokens.input,
|
||||
usage: limit ? Math.round((total / limit) * 100) : null,
|
||||
}
|
||||
}
|
||||
|
||||
export function getSessionContextMetrics(messages: Message[] = [], providers: Provider[] = []) {
|
||||
export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) {
|
||||
return build(messages, providers)
|
||||
}
|
||||
|
||||
export function getSessionTokenTotal(tokens: Session["tokens"] | undefined) {
|
||||
if (!tokens) return undefined
|
||||
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { getSessionContextMetrics } from "./session-context-metrics"
|
||||
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
|
||||
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
||||
import { createSessionContextFormatter } from "./session-context-format"
|
||||
|
||||
@@ -134,12 +134,12 @@ export function SessionContextTab() {
|
||||
}),
|
||||
)
|
||||
|
||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
|
||||
const ctx = createMemo(() => metrics().context)
|
||||
const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
|
||||
const tokens = createMemo(() => info()?.tokens)
|
||||
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
|
||||
|
||||
const cost = createMemo(() => {
|
||||
return usd().format(metrics().totalCost)
|
||||
return usd().format(info()?.cost ?? 0)
|
||||
})
|
||||
|
||||
const counts = createMemo(() => {
|
||||
@@ -204,14 +204,14 @@ export function SessionContextTab() {
|
||||
{ label: "context.stats.provider", value: providerLabel },
|
||||
{ label: "context.stats.model", value: modelLabel },
|
||||
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
|
||||
{ label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) },
|
||||
{ label: "context.stats.totalTokens", value: () => formatter().number(getSessionTokenTotal(tokens())) },
|
||||
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
|
||||
{ label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
|
||||
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.output) },
|
||||
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.reasoning) },
|
||||
{ label: "context.stats.inputTokens", value: () => formatter().number(tokens()?.input) },
|
||||
{ label: "context.stats.outputTokens", value: () => formatter().number(tokens()?.output) },
|
||||
{ label: "context.stats.reasoningTokens", value: () => formatter().number(tokens()?.reasoning) },
|
||||
{
|
||||
label: "context.stats.cacheTokens",
|
||||
value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`,
|
||||
value: () => `${formatter().number(tokens()?.cache.read)} / ${formatter().number(tokens()?.cache.write)}`,
|
||||
},
|
||||
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
|
||||
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { batch, createEffect, createMemo } from "solid-js"
|
||||
import { batch, createEffect, createMemo, startTransition } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useModels } from "@/context/models"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
@@ -294,19 +294,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
model.set({ providerID: entry.provider.id, modelID: entry.id })
|
||||
},
|
||||
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
|
||||
batch(() => {
|
||||
setStore("last", {
|
||||
type: "model",
|
||||
agent: agent.current()?.name,
|
||||
model: item ?? null,
|
||||
variant: selected(),
|
||||
})
|
||||
write({ model: item })
|
||||
if (!item) return
|
||||
models.setVisibility(item, true)
|
||||
if (!options?.recent) return
|
||||
models.recent.push(item)
|
||||
})
|
||||
startTransition(() =>
|
||||
batch(() => {
|
||||
setStore("last", {
|
||||
type: "model",
|
||||
agent: agent.current()?.name,
|
||||
model: item ?? null,
|
||||
variant: selected(),
|
||||
})
|
||||
write({ model: item })
|
||||
if (!item) return
|
||||
models.setVisibility(item, true)
|
||||
if (!options?.recent) return
|
||||
models.recent.push(item)
|
||||
}),
|
||||
)
|
||||
},
|
||||
visible(item: ModelKey) {
|
||||
return models.visible(item)
|
||||
@@ -335,19 +337,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
return Object.keys(item.variants)
|
||||
},
|
||||
set(value: string | undefined) {
|
||||
batch(() => {
|
||||
const model = current()
|
||||
setStore("last", {
|
||||
type: "variant",
|
||||
agent: agent.current()?.name,
|
||||
model: model ? { providerID: model.provider.id, modelID: model.id } : null,
|
||||
variant: value ?? null,
|
||||
})
|
||||
write({ variant: value ?? null })
|
||||
if (model) {
|
||||
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value ?? undefined)
|
||||
}
|
||||
})
|
||||
startTransition(() =>
|
||||
batch(() => {
|
||||
const model = current()
|
||||
setStore("last", {
|
||||
type: "variant",
|
||||
agent: agent.current()?.name,
|
||||
model: model ? { providerID: model.provider.id, modelID: model.id } : null,
|
||||
variant: value ?? null,
|
||||
})
|
||||
write({ variant: value ?? null })
|
||||
if (model) {
|
||||
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value ?? undefined)
|
||||
}
|
||||
}),
|
||||
)
|
||||
},
|
||||
cycle() {
|
||||
const items = this.list()
|
||||
|
||||
@@ -138,12 +138,14 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
const next = { type: "session" as const, ...tab }
|
||||
const existing = store.find((item) => tabKey(item) === tabKey(next))
|
||||
if (existing) return existing
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
if (tabs.some((item) => tabKey(item) === tabKey(next))) return
|
||||
tabs.push(next)
|
||||
}),
|
||||
)
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
if (tabs.some((item) => tabKey(item) === tabKey(next))) return
|
||||
tabs.push(next)
|
||||
}),
|
||||
)
|
||||
})
|
||||
return next
|
||||
},
|
||||
reorder(keys: string[]) {
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
# V2 CLI and TUI development guide
|
||||
|
||||
## Migration context
|
||||
|
||||
- The TUI is being ported from legacy APIs to the new V2 APIs. New and migrated TUI behavior should use `sdk.client.v2` and the location-scoped data in `packages/tui/src/context/data.tsx` instead of adding dependencies on legacy sync state.
|
||||
- Preserve established TUI behavior unless the task intentionally changes it. When behavior, copy, keyboard interaction, or layout is unclear, compare the local V2 TUI with the latest released legacy TUI.
|
||||
- Run both versions in separate Terminal Control sessions and save PNG-only captures at equivalent states:
|
||||
|
||||
```bash
|
||||
# From packages/cli: local V2 TUI
|
||||
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev --standalone
|
||||
|
||||
# Released legacy TUI behavior reference
|
||||
termctrl start opencode-legacy --host opentui --cols 112 --rows 34 -- bunx opencode-ai@latest
|
||||
|
||||
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2.png
|
||||
termctrl save opencode-legacy --format png --out /tmp/opencode/legacy.png
|
||||
```
|
||||
|
||||
- Use the same viewport and send equivalent inputs to both sessions before comparing screenshots. The released CLI is a behavioral reference, not a source of V2 API design; keep the local implementation on V2 endpoints.
|
||||
- Stop both sessions after comparison: `termctrl stop opencode-v2-dev` and `termctrl stop opencode-legacy`.
|
||||
|
||||
## Interactive debugging
|
||||
|
||||
- This package is the V2 CLI adapter. Run its `dev` script when testing the TUI; do not use the repository-root `bun dev`, which launches the legacy `packages/opencode` CLI.
|
||||
- Run commands from `packages/cli`. Use `bun dev --standalone` for most debugging so the TUI starts with a private V2 server instead of depending on the background service.
|
||||
- Use `termctrl` for interactive checks instead of starting the TUI as a blocking foreground process. It provides a real PTY, handles OpenTUI's host handshake, and can save reviewable screenshots.
|
||||
- Use a dedicated session name and do not reuse or kill an unrelated session.
|
||||
|
||||
```bash
|
||||
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev --standalone
|
||||
termctrl wait opencode-v2-dev "Ask anything" --timeout 20000
|
||||
termctrl show opencode-v2-dev
|
||||
```
|
||||
|
||||
- Wait for visible text before interacting instead of relying on fixed sleeps. Use the text expected from the screen under test, such as `Ask anything` or `Connect a provider`.
|
||||
- Drive the running TUI with `termctrl send`. Prefix typed input with `text:` and send control keys separately so the interaction matches real terminal input.
|
||||
|
||||
```bash
|
||||
termctrl send opencode-v2-dev 'text:example prompt' enter
|
||||
termctrl send opencode-v2-dev ctrl-c
|
||||
```
|
||||
|
||||
- Use `termctrl show` after each meaningful interaction and inspect the full visible screen for rendering errors, stale state, error toasts, and unexpected exits.
|
||||
- Save PNG evidence for every user-visible bug and fix. Do not save text captures; inspect the rendered PNG. Write temporary captures outside the repository unless the artifact is intended to be committed.
|
||||
|
||||
```bash
|
||||
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2-tui.png
|
||||
```
|
||||
|
||||
- For resize-sensitive changes, resize the viewport, wait for the expected content, and capture the screen again:
|
||||
|
||||
```bash
|
||||
termctrl resize opencode-v2-dev --cols 100 --rows 30
|
||||
termctrl show opencode-v2-dev
|
||||
```
|
||||
|
||||
- Source changes may require restarting the process. Use `termctrl restart opencode-v2-dev` rather than assuming the running TUI reloaded the change.
|
||||
- To exercise background-service behavior, omit `--standalone`. Service lifecycle commands are available through `bun dev service start`, `bun dev service status`, and `bun dev service stop`.
|
||||
- Always clean up the Terminal Control session when the check is complete:
|
||||
|
||||
```bash
|
||||
termctrl stop opencode-v2-dev
|
||||
```
|
||||
|
||||
## Server/API debugging
|
||||
|
||||
- Use `bun dev api --help` from `packages/cli` to inspect the API debugging command. It sends one request to the V2 server using the same daemon discovery/auth path as the CLI.
|
||||
- Use `bun dev api` to introspect the server-side data backing the TUI. This is useful when debugging UI bugs: compare what the screen renders with the raw session, message, event, agent, or health data returned by the API to determine whether the bug is in the server state, the client data layer, or the TUI rendering.
|
||||
- `bun dev api` accepts either an OpenAPI operation ID or a raw HTTP method plus path:
|
||||
|
||||
```bash
|
||||
bun dev api get /health
|
||||
bun dev api get /openapi.json
|
||||
bun dev api <operationId> --param key=value
|
||||
```
|
||||
|
||||
- Pass JSON request bodies with `--data`/`-d`; the command sets `content-type: application/json` automatically unless you provide a header. Add extra headers with `--header`/`-H name:value`.
|
||||
- If no compatible background server is registered, `bun dev api` starts one through the daemon service. Use `bun dev service status`, `bun dev service restart`, and `bun dev service stop` when you need explicit lifecycle control.
|
||||
- Prefer raw method/path calls for quick server debugging and operation IDs when exercising documented OpenAPI routes with path or query parameters.
|
||||
|
||||
## Debugger
|
||||
|
||||
- To debug the V2 CLI or TUI with Bun's inspector, launch the CLI entrypoint through Terminal Control with an inspector URL, then attach a debugger to that URL:
|
||||
|
||||
```bash
|
||||
termctrl start opencode-v2-debug --host opentui --cols 112 --rows 34 -- \
|
||||
bun run --inspect=ws://localhost:6499/ src/index.ts --standalone
|
||||
```
|
||||
|
||||
- Use `--inspect-wait` or `--inspect-brk` when execution must pause until the debugger attaches.
|
||||
- Use `termctrl logs opencode-v2-debug` for inspector output or startup failures emitted before the TUI renderer starts. Use `termctrl show` for the visible full-screen TUI.
|
||||
|
||||
## Verification
|
||||
|
||||
- Run `bun typecheck` from `packages/cli` after CLI adapter changes.
|
||||
- Run `bun typecheck` and `bun test` from `packages/tui` after shared TUI changes. Do not run tests from the repository root.
|
||||
- Treat automated checks and Terminal Control smoke tests as complementary. For user-visible changes, verify initial render, the changed interaction, Ctrl-C exit behavior, and save a screenshot of the corrected state.
|
||||
@@ -31,11 +31,11 @@ function run(target) {
|
||||
|
||||
const envPath = process.env.OPENCODE_BIN_PATH
|
||||
const scriptDir = path.dirname(fs.realpathSync(__filename))
|
||||
const cached = path.join(scriptDir, ".opencode2")
|
||||
const cached = path.join(scriptDir, ".lildax")
|
||||
const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] || os.platform()
|
||||
const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] || os.arch()
|
||||
const base = "@opencode-ai/cli-" + platform + "-" + arch
|
||||
const binary = platform === "windows" ? "opencode2.exe" : "opencode2"
|
||||
const binary = platform === "windows" ? "lildax.exe" : "lildax"
|
||||
|
||||
function supportsAvx2() {
|
||||
if (arch !== "x64") return false
|
||||
@@ -121,7 +121,7 @@ function findBinary(startDir) {
|
||||
const resolved = envPath || (fs.existsSync(cached) ? cached : findBinary(scriptDir))
|
||||
if (!resolved) {
|
||||
console.error(
|
||||
"It seems that your package manager failed to install the right opencode2 CLI package. Try manually installing " +
|
||||
"It seems that your package manager failed to install the right lildax CLI package. Try manually installing " +
|
||||
names.map((name) => `"${name}"`).join(" or ") +
|
||||
" package",
|
||||
)
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"opencode2": "./bin/opencode2.cjs"
|
||||
"lildax": "./bin/lildax.cjs"
|
||||
},
|
||||
"files": [
|
||||
"bin"
|
||||
@@ -25,15 +25,12 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"effect": "catalog:",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"semver": "catalog:",
|
||||
"solid-js": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/semver": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import pkg from "../package.json"
|
||||
import { modelsData } from "./generate"
|
||||
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
const binary = "opencode2"
|
||||
const binary = "lildax"
|
||||
process.chdir(dir)
|
||||
|
||||
await rm("dist", { recursive: true, force: true })
|
||||
|
||||
@@ -25,15 +25,14 @@ for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: "./dist" }
|
||||
}
|
||||
console.log("binaries", binaries)
|
||||
const version = Object.values(binaries)[0]
|
||||
const name = pkg.name
|
||||
|
||||
await $`mkdir -p ./dist/${name}/bin`
|
||||
await $`cp ./bin/opencode2.cjs ./dist/${name}/bin/opencode2`
|
||||
await Bun.file(`./dist/${name}/package.json`).write(
|
||||
await $`mkdir -p ./dist/${pkg.name}/bin`
|
||||
await $`cp ./bin/lildax.cjs ./dist/${pkg.name}/bin/lildax`
|
||||
await Bun.file(`./dist/${pkg.name}/package.json`).write(
|
||||
JSON.stringify(
|
||||
{
|
||||
name,
|
||||
bin: { opencode2: "./bin/opencode2" },
|
||||
name: pkg.name,
|
||||
bin: { lildax: "./bin/lildax" },
|
||||
version,
|
||||
license: pkg.license,
|
||||
repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" },
|
||||
@@ -51,4 +50,4 @@ await Promise.all(
|
||||
publish(`./dist/${name.replace("@opencode-ai/", "")}`, name, version),
|
||||
),
|
||||
)
|
||||
await publish(`./dist/${name}`, name, version)
|
||||
await publish(`./dist/${pkg.name}`, pkg.name, version)
|
||||
|
||||
@@ -5,16 +5,6 @@ declare const OPENCODE_CLI_NAME: string | undefined
|
||||
|
||||
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
|
||||
description: "OpenCode 2.0 preview command line interface",
|
||||
params: {
|
||||
directory: Argument.string("directory").pipe(
|
||||
Argument.withDescription("Directory to start OpenCode in"),
|
||||
Argument.optional,
|
||||
),
|
||||
standalone: Flag.boolean("standalone").pipe(
|
||||
Flag.withDescription("Run with a private server instead of the background service"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
},
|
||||
commands: [
|
||||
Spec.make("api", {
|
||||
description: "Make a request to the running server",
|
||||
@@ -56,7 +46,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||
hostname: Flag.string("hostname").pipe(Flag.withDefault("127.0.0.1")),
|
||||
port: Flag.integer("port").pipe(Flag.optional),
|
||||
register: Flag.boolean("register").pipe(Flag.withDefault(false)),
|
||||
stdio: Flag.boolean("stdio").pipe(Flag.withDefault(false)),
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -1,27 +1,13 @@
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { Daemon } from "../../services/daemon"
|
||||
import { Standalone } from "../../services/standalone"
|
||||
import { Updater } from "../../services/updater"
|
||||
|
||||
export default Runtime.handler(Commands, (input) =>
|
||||
export default Runtime.handler(Commands, () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = Option.getOrUndefined(input.directory)
|
||||
if (directory !== undefined) process.chdir(directory)
|
||||
const updater = yield* Updater.Service
|
||||
yield* updater.check()
|
||||
const daemon = yield* Daemon.Service
|
||||
const transport = yield* (input.standalone ? Standalone.transport() : daemon.transport())
|
||||
const transport = yield* daemon.transport()
|
||||
const { runTui } = yield* Effect.promise(() => import("../../tui"))
|
||||
yield* runTui(
|
||||
transport,
|
||||
input.standalone
|
||||
? undefined
|
||||
: async () => {
|
||||
await Effect.runPromise(daemon.stop())
|
||||
return Effect.runPromise(daemon.transport())
|
||||
},
|
||||
)
|
||||
yield* runTui(transport)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -6,12 +6,9 @@ import * as Effect from "effect/Effect"
|
||||
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { createServer } from "node:http"
|
||||
import { createRoutes } from "@opencode-ai/server/routes"
|
||||
import { ServerAuth } from "@opencode-ai/server/auth"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Daemon } from "../../services/daemon"
|
||||
import { Updater } from "../../services/updater"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.serve,
|
||||
@@ -19,43 +16,15 @@ export default Runtime.handler(
|
||||
return yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const daemon = yield* Daemon.Service
|
||||
const standalonePassword = process.env.OPENCODE_SERVER_PASSWORD
|
||||
if (input.stdio) delete process.env.OPENCODE_SERVER_PASSWORD
|
||||
const password = input.stdio ? standalonePassword : yield* daemon.password()
|
||||
if (!password) return yield* Effect.fail(new Error("Missing server password"))
|
||||
const address = yield* listen(input.hostname, input.port, password)
|
||||
yield* Effect.tryPromise(() =>
|
||||
createOpencodeClient({
|
||||
baseUrl: HttpServer.formatAddress(address),
|
||||
headers: ServerAuth.headers({ password }),
|
||||
}).v2.location.get(undefined, { throwOnError: true }),
|
||||
)
|
||||
const address = yield* listen(input.hostname, input.port, yield* daemon.password())
|
||||
if (input.register) yield* daemon.register(address)
|
||||
const url = HttpServer.formatAddress(address)
|
||||
console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`)
|
||||
const updater = yield* Updater.Service
|
||||
yield* updater.check().pipe(Effect.forkScoped)
|
||||
return yield* (input.stdio ? waitForStdinClose() : Effect.never)
|
||||
}).pipe(Effect.annotateLogs({ role: "server" })),
|
||||
console.log(`server listening on ${HttpServer.formatAddress(address)}`)
|
||||
return yield* Effect.never
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
function waitForStdinClose() {
|
||||
return Effect.callback<void>((resume) => {
|
||||
const close = () => resume(Effect.void)
|
||||
process.stdin.once("end", close)
|
||||
process.stdin.once("close", close)
|
||||
process.stdin.resume()
|
||||
if (process.stdin.readableEnded || process.stdin.destroyed) close()
|
||||
return Effect.sync(() => {
|
||||
process.stdin.off("end", close)
|
||||
process.stdin.off("close", close)
|
||||
process.stdin.pause()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function listen(hostname: string, port: Option.Option<number>, password: string) {
|
||||
if (Option.isSome(port)) return bind(hostname, port.value, password)
|
||||
const next = (port: number): ReturnType<typeof bind> =>
|
||||
@@ -66,15 +35,11 @@ function listen(hostname: string, port: Option.Option<number>, password: string)
|
||||
}
|
||||
|
||||
function bind(hostname: string, port: number, password: string) {
|
||||
const server = createServer()
|
||||
return Layer.build(
|
||||
HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe(
|
||||
Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })),
|
||||
Layer.provideMerge(NodeHttpServer.layer(() => createServer(), { port, host: hostname })),
|
||||
Layer.provide(Credential.defaultLayer),
|
||||
Layer.provide(PermissionSaved.defaultLayer),
|
||||
),
|
||||
).pipe(
|
||||
Effect.tap(() => Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))),
|
||||
Effect.map((context) => Context.get(context, HttpServer.HttpServer).address),
|
||||
)
|
||||
).pipe(Effect.map((context) => Context.get(context, HttpServer.HttpServer).address))
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@ export default Runtime.handler(
|
||||
Commands.commands.service.commands.status,
|
||||
Effect.fn("cli.service.status")(function* () {
|
||||
const url = yield* (yield* Daemon.Service).status()
|
||||
process.stdout.write((url ? url : "stopped") + EOL)
|
||||
process.stdout.write((url ? `running ${url}` : "stopped") + EOL)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2,8 +2,6 @@ import * as Effect from "effect/Effect"
|
||||
import * as Command from "effect/unstable/cli/Command"
|
||||
import { Spec } from "./spec"
|
||||
import { Daemon } from "../services/daemon"
|
||||
import { Updater } from "../services/updater"
|
||||
import { Scope } from "effect"
|
||||
|
||||
export type Input<Value> =
|
||||
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
|
||||
@@ -12,11 +10,11 @@ export type Input<Value> =
|
||||
? Input
|
||||
: never
|
||||
|
||||
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service | Updater.Service | Scope.Scope>
|
||||
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service>
|
||||
type Loader<Node extends Spec.Any> = () => Promise<{
|
||||
default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service | Updater.Service | Scope.Scope>
|
||||
default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service>
|
||||
}>
|
||||
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service | Updater.Service | Scope.Scope>
|
||||
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service>
|
||||
|
||||
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
|
||||
? Loader<Node>
|
||||
|
||||
@@ -2,21 +2,10 @@
|
||||
|
||||
import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
|
||||
import * as NodeServices from "@effect/platform-node/NodeServices"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { Layer, Logger, References } from "effect"
|
||||
import { Commands } from "./commands/commands"
|
||||
import { Runtime } from "./framework/runtime"
|
||||
import { Daemon } from "./services/daemon"
|
||||
import { Logging } from "@opencode-ai/core/observability/logging"
|
||||
import { Updater } from "./services/updater"
|
||||
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version"
|
||||
|
||||
const LoggingLayer = Logger.layer(Logging.loggers(), { mergeWithExisting: false }).pipe(
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
Layer.orDie,
|
||||
Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())),
|
||||
)
|
||||
|
||||
const Handlers = Runtime.handlers(Commands, {
|
||||
$: () => import("./commands/handlers/default"),
|
||||
@@ -35,14 +24,9 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
serve: () => import("./commands/handlers/serve"),
|
||||
})
|
||||
|
||||
Effect.logInfo("cli starting", { version: InstallationVersion, channel: InstallationChannel, local: InstallationLocal }).pipe(
|
||||
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })),
|
||||
Effect.annotateLogs({ role: "cli" }),
|
||||
Runtime.run(Commands, Handlers, { version: "local" }).pipe(
|
||||
Effect.provide(Daemon.defaultLayer),
|
||||
Effect.provide(Updater.defaultLayer),
|
||||
Effect.provide(LoggingLayer),
|
||||
Effect.provide(NodeServices.layer),
|
||||
Effect.scoped,
|
||||
Effect.tap(() => Effect.sync(() => process.exit(0))),
|
||||
NodeRuntime.runMain,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { ServerAuth } from "@opencode-ai/server/auth"
|
||||
import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect"
|
||||
@@ -28,10 +28,6 @@ const Registration = Schema.Struct({
|
||||
})
|
||||
type Registration = typeof Registration.Type
|
||||
|
||||
const Config = Schema.Struct({
|
||||
password: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
function sameRegistration(left: Registration, right: Registration) {
|
||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||
}
|
||||
@@ -41,30 +37,22 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const directory = Global.Path.state
|
||||
const file = path.join(directory, InstallationChannel === "local" ? "server-local.json" : "server.json")
|
||||
const configFile = path.join(Global.Path.config, "service.json")
|
||||
const legacyPasswordFile = path.join(directory, "password")
|
||||
const file = path.join(directory, "server.json")
|
||||
const passwordFile = path.join(directory, "password")
|
||||
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration))
|
||||
const decodeConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(Config))
|
||||
|
||||
const password = Effect.fn("cli.daemon.password")(function* (value?: string) {
|
||||
const config = yield* fs
|
||||
.readFileString(configFile)
|
||||
.pipe(Effect.flatMap(decodeConfig), Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (value === undefined && config?.password) return config.password
|
||||
|
||||
const legacy = yield* fs
|
||||
.readFileString(legacyPasswordFile)
|
||||
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
const next = value ?? legacy ?? randomBytes(32).toString("base64url")
|
||||
const existing = yield* fs.readFileString(passwordFile).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (value === undefined && existing) return existing
|
||||
|
||||
// Keep one private credential across server restarts so discovered clients
|
||||
// can reconnect without exposing a password flag or environment variable.
|
||||
const temp = configFile + ".tmp"
|
||||
yield* fs.writeFileString(temp, JSON.stringify({ password: next }, null, 2) + "\n", { mode: 0o600 })
|
||||
yield* fs.rename(temp, configFile)
|
||||
if (legacy) yield* fs.remove(legacyPasswordFile).pipe(Effect.ignore)
|
||||
return next
|
||||
const generated = value ?? randomBytes(32).toString("base64url")
|
||||
const temp = passwordFile + ".tmp"
|
||||
yield* fs.makeDirectory(directory, { recursive: true })
|
||||
yield* fs.writeFileString(temp, generated, { mode: 0o600 })
|
||||
yield* fs.rename(temp, passwordFile)
|
||||
return generated
|
||||
})
|
||||
|
||||
const registration = Effect.fnUntraced(function* () {
|
||||
@@ -123,7 +111,7 @@ export const layer = Layer.effect(
|
||||
const existing = yield* healthy().pipe(Effect.option)
|
||||
const found = Option.getOrUndefined(existing)
|
||||
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
|
||||
if (found?.version === InstallationVersion) return found.url
|
||||
if (found?.version === InstallationVersion && compiled) return found.url
|
||||
if (found) yield* stopProcess(found).pipe(Effect.ignore)
|
||||
|
||||
const entrypoint = compiled ? undefined : process.argv[1]
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { ServerAuth } from "@opencode-ai/server/auth"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { randomBytes } from "node:crypto"
|
||||
import path from "node:path"
|
||||
|
||||
const Ready = Schema.Struct({ url: Schema.String })
|
||||
const decodeReady = Schema.decodeUnknownPromise(Schema.fromJsonString(Ready))
|
||||
|
||||
function command(password: string) {
|
||||
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
|
||||
const entrypoint = compiled ? [] : process.argv[1] ? [process.argv[1]] : []
|
||||
if (!compiled && entrypoint.length === 0) throw new Error("Failed to resolve CLI entrypoint")
|
||||
return ChildProcess.make(process.execPath, [...entrypoint, "serve", "--stdio", "--port", "0"], {
|
||||
cwd: process.cwd(),
|
||||
env: { OPENCODE_SERVER_PASSWORD: password },
|
||||
extendEnv: true,
|
||||
// The server treats EOF on this pipe as the end of its ownership lease.
|
||||
// The OS closes it even when the TUI is killed before Effect finalizers run.
|
||||
stdin: "pipe",
|
||||
stderr: "ignore",
|
||||
killSignal: "SIGTERM",
|
||||
forceKillAfter: "3 seconds",
|
||||
})
|
||||
}
|
||||
|
||||
export const transport = Effect.fn("cli.standalone.transport")(
|
||||
function* () {
|
||||
const password = randomBytes(32).toString("base64url")
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const proc = yield* spawner.spawn(command(password))
|
||||
const output = yield* proc.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.take(1), Stream.mkString)
|
||||
if (!output) return yield* Effect.fail(new Error("Standalone server exited before reporting readiness"))
|
||||
const ready = yield* Effect.tryPromise(() => decodeReady(output))
|
||||
return { url: ready.url, headers: ServerAuth.headers({ password }), pid: proc.pid }
|
||||
},
|
||||
Effect.provide(CrossSpawnSpawner.defaultLayer),
|
||||
)
|
||||
|
||||
export * as Standalone from "./standalone"
|
||||
@@ -1,33 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { action, decodePolicy } from "./updater"
|
||||
|
||||
describe("updater", () => {
|
||||
test("reads autoupdate from JSONC", () => {
|
||||
expect(decodePolicy('{ // preference\n "autoupdate": "notify",\n}')).toBe("notify")
|
||||
expect(decodePolicy('{ "autoupdate": false }')).toBe(false)
|
||||
expect(decodePolicy('{ "autoupdate": "invalid" }')).toBeUndefined()
|
||||
})
|
||||
|
||||
test("automatically updates patches and minors", () => {
|
||||
expect(action("1.2.3", "1.2.4", true)).toBe("upgrade")
|
||||
expect(action("1.2.3", "1.3.0", true)).toBe("upgrade")
|
||||
expect(action("1.2.3", "1.2.4", "notify")).toBe("upgrade")
|
||||
expect(action("1.2.3", "1.3.0", "notify")).toBe("upgrade")
|
||||
})
|
||||
|
||||
test("skips when autoupdate is disabled", () => {
|
||||
expect(action("1.2.3", "1.2.4", false)).toBe("none")
|
||||
})
|
||||
|
||||
test("never automatically updates majors", () => {
|
||||
expect(action("1.2.3", "2.0.0", true)).toBe("none")
|
||||
})
|
||||
|
||||
test("reports up-to-date only when versions match", () => {
|
||||
expect(action("1.2.3", "1.2.3", true)).toBe("none")
|
||||
})
|
||||
|
||||
test("upgrades when latest is lower (rollback)", () => {
|
||||
expect(action("1.2.4", "1.2.3", true)).toBe("upgrade")
|
||||
})
|
||||
})
|
||||
@@ -1,158 +0,0 @@
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import {
|
||||
InstallationChannel,
|
||||
InstallationLocal,
|
||||
InstallationVersion,
|
||||
} from "@opencode-ai/core/installation/version"
|
||||
import { Context, Duration, Effect, FileSystem, Layer } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "node:path"
|
||||
import semver from "semver"
|
||||
|
||||
export type Policy = boolean | "notify"
|
||||
export type Action = "none" | "upgrade"
|
||||
type Method = "npm" | "pnpm" | "bun" | "yarn"
|
||||
|
||||
const packageName = "@opencode-ai/cli"
|
||||
|
||||
export interface Interface {
|
||||
readonly check: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
|
||||
|
||||
export function decodePolicy(text: string): Policy | undefined {
|
||||
// The CLI only projects this host-level preference instead of initializing
|
||||
// the location-scoped server configuration graph.
|
||||
const errors: ParseError[] = []
|
||||
const input: unknown = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length || typeof input !== "object" || input === null || !("autoupdate" in input)) return
|
||||
const value = input.autoupdate
|
||||
if (typeof value === "boolean" || value === "notify") return value
|
||||
}
|
||||
|
||||
export function action(current: string, latest: string, policy: Policy): Action {
|
||||
if (policy === false) return "none"
|
||||
if (!semver.valid(current) || !semver.valid(latest) || semver.eq(latest, current)) return "none"
|
||||
// Major upgrades are never installed automatically.
|
||||
if (semver.major(latest) !== semver.major(current)) return "none"
|
||||
return "upgrade"
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const appProcess = yield* AppProcess.Service
|
||||
const channel = InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
|
||||
const readPolicy = Effect.fnUntraced(function* () {
|
||||
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
|
||||
fs
|
||||
.readFileString(path.join(global.config, name))
|
||||
.pipe(Effect.map(decodePolicy), Effect.catch(() => Effect.succeed(undefined))),
|
||||
)
|
||||
return values.findLast((value) => value !== undefined) ?? true
|
||||
})
|
||||
|
||||
const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
|
||||
return yield* appProcess
|
||||
.run(ChildProcess.make(command[0], command.slice(1)), {
|
||||
timeout,
|
||||
maxOutputBytes: 100_000,
|
||||
maxErrorBytes: 100_000,
|
||||
})
|
||||
.pipe(
|
||||
Effect.map((result) => ({
|
||||
code: result.exitCode,
|
||||
stdout: result.stdout.toString("utf8"),
|
||||
stderr: result.stderr.toString("utf8"),
|
||||
})),
|
||||
Effect.catch(() => Effect.succeed({ code: 1, stdout: "", stderr: "" })),
|
||||
)
|
||||
})
|
||||
|
||||
const method = Effect.fnUntraced(function* () {
|
||||
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
|
||||
{ method: "npm", command: ["npm", "list", "-g", "--depth=0", packageName] },
|
||||
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", packageName] },
|
||||
{ method: "bun", command: ["bun", "pm", "ls", "-g"] },
|
||||
{ method: "yarn", command: ["yarn", "global", "list"] },
|
||||
]
|
||||
const results = yield* Effect.forEach(
|
||||
checks,
|
||||
(check) => run(check.command).pipe(Effect.map((result) => ({ check, result }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return results.find((result) => result.result.stdout.includes(packageName))?.check.method
|
||||
})
|
||||
|
||||
const latest = Effect.fnUntraced(function* () {
|
||||
const response = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
fetch(
|
||||
`https://registry.npmjs.org/${encodeURIComponent(packageName)}/${encodeURIComponent(InstallationChannel)}`,
|
||||
{ headers: { "User-Agent": `opencode/${InstallationVersion}` }, signal: AbortSignal.timeout(10_000) },
|
||||
),
|
||||
catch: (cause) => new Error("Failed to check for updates", { cause }),
|
||||
})
|
||||
if (!response.ok) return yield* Effect.fail(new Error(`Update check failed with status ${response.status}`))
|
||||
const data = yield* Effect.tryPromise({
|
||||
try: () => response.json(),
|
||||
catch: (cause) => new Error("Failed to read update information", { cause }),
|
||||
})
|
||||
if (typeof data !== "object" || data === null || !("version" in data) || typeof data.version !== "string") {
|
||||
return yield* Effect.fail(new Error("Update information did not include a version"))
|
||||
}
|
||||
return data.version
|
||||
})
|
||||
|
||||
const upgrade = Effect.fnUntraced(function* (method: Method, version: string) {
|
||||
const target = `${packageName}@${version}`
|
||||
const commands: Record<Method, string[]> = {
|
||||
npm: ["npm", "install", "--global", target],
|
||||
pnpm: ["pnpm", "install", "--global", target],
|
||||
bun: ["bun", "install", "--global", target],
|
||||
yarn: ["yarn", "global", "add", target],
|
||||
}
|
||||
const result = yield* run(commands[method], "5 minutes")
|
||||
if (result.code === 0) return
|
||||
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
|
||||
})
|
||||
|
||||
const check = Effect.fn("cli.updater.check")(function* () {
|
||||
if (InstallationLocal || Flag.OPENCODE_DISABLE_AUTOUPDATE)
|
||||
return yield* Effect.logInfo("update check skipped", {
|
||||
reason: InstallationLocal ? "local-install" : "disabled",
|
||||
version: InstallationVersion,
|
||||
channel: InstallationChannel,
|
||||
})
|
||||
const policy = yield* readPolicy()
|
||||
if (policy === false) return yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const version = yield* latest()
|
||||
yield* Effect.logInfo("update check", {
|
||||
current: InstallationVersion,
|
||||
latest: version,
|
||||
})
|
||||
const next = action(InstallationVersion, version, policy)
|
||||
if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" })
|
||||
const detected = yield* method()
|
||||
if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found")
|
||||
yield* upgrade(detected, version)
|
||||
yield* Effect.logInfo("updated OpenCode", { from: InstallationVersion, to: version, method: detected })
|
||||
})
|
||||
}, Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })))
|
||||
|
||||
return Service.of({ check })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppProcess.defaultLayer), Layer.provide(Global.defaultLayer))
|
||||
|
||||
export * as Updater from "./updater"
|
||||
+28
-38
@@ -2,45 +2,35 @@ import { run } from "@opencode-ai/tui"
|
||||
import { TuiConfig } from "@opencode-ai/tui/config"
|
||||
import { Effect } from "effect"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
type Transport = { url: string; headers: RequestInit["headers"] }
|
||||
|
||||
export function runTui(transport: Transport, reload?: () => Promise<Transport>) {
|
||||
export function runTui(transport: { url: string; headers: RequestInit["headers"] }) {
|
||||
const config = TuiConfig.resolve({}, { terminalSuspend: false })
|
||||
let disposeSlots: (() => void) | undefined
|
||||
return Effect.gen(function* () {
|
||||
const options = { baseUrl: transport.url, headers: transport.headers }
|
||||
const client = createOpencodeClient(options)
|
||||
const directory = yield* Effect.tryPromise(() =>
|
||||
client.v2.fs.list({ location: { directory: process.cwd() } }, { throwOnError: true }),
|
||||
).pipe(
|
||||
Effect.map((response) => response.data.location.directory),
|
||||
Effect.catch(() =>
|
||||
Effect.tryPromise(() => client.v2.location.get(undefined, { throwOnError: true })).pipe(
|
||||
Effect.map((response) => response.data.directory),
|
||||
),
|
||||
),
|
||||
)
|
||||
return yield* run({
|
||||
client: createOpencodeClient({ ...options, directory }),
|
||||
reload: reload
|
||||
? async () => {
|
||||
const next = await reload()
|
||||
return createOpencodeClient({ baseUrl: next.url, headers: next.headers, directory })
|
||||
}
|
||||
: undefined,
|
||||
args: {},
|
||||
config,
|
||||
pluginHost: {
|
||||
async start(input) {
|
||||
disposeSlots = await loadBuiltinPlugins(input.api, input.runtime)
|
||||
},
|
||||
async dispose() {
|
||||
disposeSlots?.()
|
||||
},
|
||||
},
|
||||
})
|
||||
return run({
|
||||
...transport,
|
||||
args: {},
|
||||
config,
|
||||
fetch: gracefulFetch,
|
||||
pluginHost: {
|
||||
async start() {},
|
||||
async dispose() {},
|
||||
},
|
||||
}).pipe(Effect.provide(Global.defaultLayer))
|
||||
}
|
||||
|
||||
const legacyDefaults: Record<string, unknown> = {
|
||||
"/config/providers": { providers: [], default: {} },
|
||||
"/provider": { all: [], default: {}, connected: [] },
|
||||
"/agent": [],
|
||||
"/config": {},
|
||||
}
|
||||
|
||||
const gracefulFetch = Object.assign(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const response = await fetch(input, init)
|
||||
if (response.status !== 404) return response
|
||||
const fallback = legacyDefaults[new URL(input instanceof Request ? input.url : input).pathname]
|
||||
if (fallback === undefined) return response
|
||||
return Response.json(fallback)
|
||||
},
|
||||
{ preconnect: fetch.preconnect },
|
||||
)
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import path from "node:path"
|
||||
import { Standalone } from "../../src/services/standalone"
|
||||
|
||||
process.argv[1] = path.join(import.meta.dir, "../../src/index.ts")
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* Standalone.transport()
|
||||
console.log(`${transport.pid} ${transport.url}`)
|
||||
return yield* Effect.never
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -1,65 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
|
||||
test("standalone server exits when its owner is killed", async () => {
|
||||
const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "fixture/standalone-owner.ts")], {
|
||||
cwd: path.join(import.meta.dir, ".."),
|
||||
env: process.env,
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const line = await Promise.race([readLine(owner.stdout), Bun.sleep(10_000).then(() => undefined)])
|
||||
const [rawPID, url] = line?.split(" ") ?? []
|
||||
const pid = Number(rawPID)
|
||||
|
||||
try {
|
||||
expect(pid).toBeGreaterThan(0)
|
||||
expect(url).toStartWith("http://127.0.0.1:")
|
||||
expect(running(pid)).toBe(true)
|
||||
|
||||
owner.kill("SIGKILL")
|
||||
await owner.exited
|
||||
|
||||
expect(await waitForExit(pid)).toBe(true)
|
||||
} finally {
|
||||
owner.kill("SIGKILL")
|
||||
if (running(pid)) process.kill(pid, "SIGKILL")
|
||||
}
|
||||
})
|
||||
|
||||
async function readLine(stream: ReadableStream<Uint8Array>) {
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
const chunks: string[] = []
|
||||
while (true) {
|
||||
const result = await reader.read()
|
||||
if (result.done) break
|
||||
chunks.push(decoder.decode(result.value, { stream: true }))
|
||||
const output = chunks.join("")
|
||||
const newline = output.indexOf("\n")
|
||||
if (newline !== -1) {
|
||||
reader.releaseLock()
|
||||
return output.slice(0, newline)
|
||||
}
|
||||
}
|
||||
reader.releaseLock()
|
||||
return chunks.join("") + decoder.decode()
|
||||
}
|
||||
|
||||
async function waitForExit(pid: number, attempts = 100): Promise<boolean> {
|
||||
if (!running(pid)) return true
|
||||
if (attempts === 0) return false
|
||||
await Bun.sleep(50)
|
||||
return waitForExit(pid, attempts - 1)
|
||||
}
|
||||
|
||||
function running(pid: number) {
|
||||
if (!Number.isSafeInteger(pid) || pid <= 0) return false
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -106,25 +106,15 @@ const Endpoint3_5 = (raw: RawClient["server.session"]) => (input: Endpoint3_5Inp
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
type Endpoint3_6Request = Parameters<RawClient["server.session"]["session.rename"]>[0]
|
||||
type Endpoint3_6Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
|
||||
type Endpoint3_6Input = {
|
||||
readonly sessionID: Endpoint3_6Request["params"]["sessionID"]
|
||||
readonly title: Endpoint3_6Request["payload"]["title"]
|
||||
readonly id?: Endpoint3_6Request["payload"]["id"]
|
||||
readonly prompt: Endpoint3_6Request["payload"]["prompt"]
|
||||
readonly delivery?: Endpoint3_6Request["payload"]["delivery"]
|
||||
readonly resume?: Endpoint3_6Request["payload"]["resume"]
|
||||
}
|
||||
const Endpoint3_6 = (raw: RawClient["server.session"]) => (input: Endpoint3_6Input) =>
|
||||
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
type Endpoint3_7Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
|
||||
type Endpoint3_7Input = {
|
||||
readonly sessionID: Endpoint3_7Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint3_7Request["payload"]["id"]
|
||||
readonly prompt: Endpoint3_7Request["payload"]["prompt"]
|
||||
readonly delivery?: Endpoint3_7Request["payload"]["delivery"]
|
||||
readonly resume?: Endpoint3_7Request["payload"]["resume"]
|
||||
}
|
||||
const Endpoint3_7 = (raw: RawClient["server.session"]) => (input: Endpoint3_7Input) =>
|
||||
raw["session.prompt"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] },
|
||||
@@ -133,23 +123,23 @@ const Endpoint3_7 = (raw: RawClient["server.session"]) => (input: Endpoint3_7Inp
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint3_8Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||
type Endpoint3_8Input = { readonly sessionID: Endpoint3_8Request["params"]["sessionID"] }
|
||||
const Endpoint3_8 = (raw: RawClient["server.session"]) => (input: Endpoint3_8Input) =>
|
||||
type Endpoint3_7Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||
type Endpoint3_7Input = { readonly sessionID: Endpoint3_7Request["params"]["sessionID"] }
|
||||
const Endpoint3_7 = (raw: RawClient["server.session"]) => (input: Endpoint3_7Input) =>
|
||||
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint3_9Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||
type Endpoint3_9Input = { readonly sessionID: Endpoint3_9Request["params"]["sessionID"] }
|
||||
const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Input) =>
|
||||
type Endpoint3_8Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||
type Endpoint3_8Input = { readonly sessionID: Endpoint3_8Request["params"]["sessionID"] }
|
||||
const Endpoint3_8 = (raw: RawClient["server.session"]) => (input: Endpoint3_8Input) =>
|
||||
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint3_10Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||
type Endpoint3_10Input = {
|
||||
readonly sessionID: Endpoint3_10Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint3_10Request["payload"]["messageID"]
|
||||
readonly files?: Endpoint3_10Request["payload"]["files"]
|
||||
type Endpoint3_9Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||
type Endpoint3_9Input = {
|
||||
readonly sessionID: Endpoint3_9Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint3_9Request["payload"]["messageID"]
|
||||
readonly files?: Endpoint3_9Request["payload"]["files"]
|
||||
}
|
||||
const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10Input) =>
|
||||
const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Input) =>
|
||||
raw["session.revert.stage"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { messageID: input["messageID"], files: input["files"] },
|
||||
@@ -158,42 +148,42 @@ const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10I
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint3_11Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||
type Endpoint3_11Input = { readonly sessionID: Endpoint3_11Request["params"]["sessionID"] }
|
||||
const Endpoint3_11 = (raw: RawClient["server.session"]) => (input: Endpoint3_11Input) =>
|
||||
type Endpoint3_10Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||
type Endpoint3_10Input = { readonly sessionID: Endpoint3_10Request["params"]["sessionID"] }
|
||||
const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10Input) =>
|
||||
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint3_12Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
||||
type Endpoint3_12Input = { readonly sessionID: Endpoint3_12Request["params"]["sessionID"] }
|
||||
const Endpoint3_12 = (raw: RawClient["server.session"]) => (input: Endpoint3_12Input) =>
|
||||
type Endpoint3_11Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
||||
type Endpoint3_11Input = { readonly sessionID: Endpoint3_11Request["params"]["sessionID"] }
|
||||
const Endpoint3_11 = (raw: RawClient["server.session"]) => (input: Endpoint3_11Input) =>
|
||||
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint3_13Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||
type Endpoint3_13Input = { readonly sessionID: Endpoint3_13Request["params"]["sessionID"] }
|
||||
const Endpoint3_13 = (raw: RawClient["server.session"]) => (input: Endpoint3_13Input) =>
|
||||
type Endpoint3_12Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||
type Endpoint3_12Input = { readonly sessionID: Endpoint3_12Request["params"]["sessionID"] }
|
||||
const Endpoint3_12 = (raw: RawClient["server.session"]) => (input: Endpoint3_12Input) =>
|
||||
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint3_14Request = Parameters<RawClient["server.session"]["session.history"]>[0]
|
||||
type Endpoint3_14Input = {
|
||||
readonly sessionID: Endpoint3_14Request["params"]["sessionID"]
|
||||
readonly limit?: Endpoint3_14Request["query"]["limit"]
|
||||
readonly after?: Endpoint3_14Request["query"]["after"]
|
||||
type Endpoint3_13Request = Parameters<RawClient["server.session"]["session.history"]>[0]
|
||||
type Endpoint3_13Input = {
|
||||
readonly sessionID: Endpoint3_13Request["params"]["sessionID"]
|
||||
readonly limit?: Endpoint3_13Request["query"]["limit"]
|
||||
readonly after?: Endpoint3_13Request["query"]["after"]
|
||||
}
|
||||
const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14Input) =>
|
||||
const Endpoint3_13 = (raw: RawClient["server.session"]) => (input: Endpoint3_13Input) =>
|
||||
raw["session.history"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
query: { limit: input["limit"], after: input["after"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint3_15Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||
type Endpoint3_15Input = {
|
||||
readonly sessionID: Endpoint3_15Request["params"]["sessionID"]
|
||||
readonly after?: Endpoint3_15Request["query"]["after"]
|
||||
type Endpoint3_14Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||
type Endpoint3_14Input = {
|
||||
readonly sessionID: Endpoint3_14Request["params"]["sessionID"]
|
||||
readonly after?: Endpoint3_14Request["query"]["after"]
|
||||
}
|
||||
const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) =>
|
||||
const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14Input) =>
|
||||
Stream.unwrap(
|
||||
raw["session.events"]({ params: { sessionID: input["sessionID"] }, query: { after: input["after"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
@@ -201,17 +191,17 @@ const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15I
|
||||
),
|
||||
)
|
||||
|
||||
type Endpoint3_16Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||
type Endpoint3_16Input = { readonly sessionID: Endpoint3_16Request["params"]["sessionID"] }
|
||||
const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16Input) =>
|
||||
type Endpoint3_15Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||
type Endpoint3_15Input = { readonly sessionID: Endpoint3_15Request["params"]["sessionID"] }
|
||||
const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) =>
|
||||
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint3_17Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
type Endpoint3_17Input = {
|
||||
readonly sessionID: Endpoint3_17Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint3_17Request["params"]["messageID"]
|
||||
type Endpoint3_16Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
type Endpoint3_16Input = {
|
||||
readonly sessionID: Endpoint3_16Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint3_16Request["params"]["messageID"]
|
||||
}
|
||||
const Endpoint3_17 = (raw: RawClient["server.session"]) => (input: Endpoint3_17Input) =>
|
||||
const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16Input) =>
|
||||
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
@@ -224,18 +214,17 @@ const adaptGroup3 = (raw: RawClient["server.session"]) => ({
|
||||
get: Endpoint3_3(raw),
|
||||
switchAgent: Endpoint3_4(raw),
|
||||
switchModel: Endpoint3_5(raw),
|
||||
rename: Endpoint3_6(raw),
|
||||
prompt: Endpoint3_7(raw),
|
||||
compact: Endpoint3_8(raw),
|
||||
wait: Endpoint3_9(raw),
|
||||
stage: Endpoint3_10(raw),
|
||||
clear: Endpoint3_11(raw),
|
||||
commit: Endpoint3_12(raw),
|
||||
context: Endpoint3_13(raw),
|
||||
history: Endpoint3_14(raw),
|
||||
events: Endpoint3_15(raw),
|
||||
interrupt: Endpoint3_16(raw),
|
||||
message: Endpoint3_17(raw),
|
||||
prompt: Endpoint3_6(raw),
|
||||
compact: Endpoint3_7(raw),
|
||||
wait: Endpoint3_8(raw),
|
||||
stage: Endpoint3_9(raw),
|
||||
clear: Endpoint3_10(raw),
|
||||
commit: Endpoint3_11(raw),
|
||||
context: Endpoint3_12(raw),
|
||||
history: Endpoint3_13(raw),
|
||||
events: Endpoint3_14(raw),
|
||||
interrupt: Endpoint3_15(raw),
|
||||
message: Endpoint3_16(raw),
|
||||
})
|
||||
|
||||
type Endpoint4_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
|
||||
|
||||
@@ -15,8 +15,6 @@ import type {
|
||||
SessionsSwitchAgentOutput,
|
||||
SessionsSwitchModelInput,
|
||||
SessionsSwitchModelOutput,
|
||||
SessionsRenameInput,
|
||||
SessionsRenameOutput,
|
||||
SessionsPromptInput,
|
||||
SessionsPromptOutput,
|
||||
SessionsCompactInput,
|
||||
@@ -369,18 +367,6 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
rename: (input: SessionsRenameInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionsRenameOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/rename`,
|
||||
body: { title: input["title"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
prompt: (input: SessionsPromptInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionsPromptOutput }>(
|
||||
{
|
||||
@@ -422,7 +408,7 @@ export function make(options: ClientOptions) {
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`,
|
||||
body: { messageID: input["messageID"], files: input["files"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 409, 500, 400, 401],
|
||||
declaredStatuses: [404, 500, 400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
@@ -433,7 +419,7 @@ export function make(options: ClientOptions) {
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 409, 500, 400, 401],
|
||||
declaredStatuses: [404, 500, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
@@ -444,7 +430,7 @@ export function make(options: ClientOptions) {
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 409, 400, 401],
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
|
||||
@@ -58,14 +58,6 @@ export type MessageNotFoundError = {
|
||||
export const isMessageNotFoundError = (value: unknown): value is MessageNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "MessageNotFoundError"
|
||||
|
||||
export type SessionBusyError = {
|
||||
readonly _tag: "SessionBusyError"
|
||||
readonly sessionID: string
|
||||
readonly message: string
|
||||
}
|
||||
export const isSessionBusyError = (value: unknown): value is SessionBusyError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SessionBusyError"
|
||||
|
||||
export type UnknownError = {
|
||||
readonly _tag: "UnknownError"
|
||||
readonly message: string
|
||||
@@ -389,13 +381,6 @@ export type SessionsSwitchModelInput = {
|
||||
|
||||
export type SessionsSwitchModelOutput = void
|
||||
|
||||
export type SessionsRenameInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly title: { readonly title: string }["title"]
|
||||
}
|
||||
|
||||
export type SessionsRenameOutput = void
|
||||
|
||||
export type SessionsPromptInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly id?: {
|
||||
@@ -412,7 +397,6 @@ export type SessionsPromptInput = {
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly system?: string
|
||||
}
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
readonly resume?: boolean | null
|
||||
@@ -431,7 +415,6 @@ export type SessionsPromptInput = {
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly system?: string
|
||||
}
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
readonly resume?: boolean | null
|
||||
@@ -450,7 +433,6 @@ export type SessionsPromptInput = {
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly system?: string
|
||||
}
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
readonly resume?: boolean | null
|
||||
@@ -469,7 +451,6 @@ export type SessionsPromptInput = {
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly system?: string
|
||||
}
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
readonly resume?: boolean | null
|
||||
@@ -494,7 +475,6 @@ export type SessionsPromptOutput = {
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly system?: string
|
||||
}
|
||||
readonly delivery: "steer" | "queue"
|
||||
readonly timeCreated: number
|
||||
@@ -574,7 +554,6 @@ export type SessionsContextOutput = {
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly system?: string
|
||||
readonly type: "user"
|
||||
}
|
||||
| {
|
||||
@@ -744,14 +723,6 @@ export type SessionsHistoryOutput = {
|
||||
readonly subdirectory?: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly type: "session.next.renamed"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly timestamp: number; readonly sessionID: string; readonly title: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
@@ -775,7 +746,6 @@ export type SessionsHistoryOutput = {
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly system?: string
|
||||
}
|
||||
readonly delivery: "steer" | "queue"
|
||||
}
|
||||
@@ -803,7 +773,6 @@ export type SessionsHistoryOutput = {
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly system?: string
|
||||
}
|
||||
readonly delivery: "steer" | "queue"
|
||||
}
|
||||
@@ -1212,14 +1181,6 @@ export type SessionsEventsOutput =
|
||||
readonly subdirectory?: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.renamed"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly timestamp: number; readonly sessionID: string; readonly title: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
@@ -1243,7 +1204,6 @@ export type SessionsEventsOutput =
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly system?: string
|
||||
}
|
||||
readonly delivery: "steer" | "queue"
|
||||
}
|
||||
@@ -1271,7 +1231,6 @@ export type SessionsEventsOutput =
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly system?: string
|
||||
}
|
||||
readonly delivery: "steer" | "queue"
|
||||
}
|
||||
@@ -1673,7 +1632,6 @@ export type SessionsMessageOutput = {
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly system?: string
|
||||
readonly type: "user"
|
||||
}
|
||||
| {
|
||||
@@ -1846,7 +1804,6 @@ export type MessagesListOutput = {
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly system?: string
|
||||
readonly type: "user"
|
||||
}
|
||||
| {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { safeEqual } from "@opencode-ai/console-core/util/crypto.js"
|
||||
|
||||
const DISCORD_ALERT_ROLE_ID = "1511795723262365887"
|
||||
const DISCORD_ALERT_ROLE_ID = "1520924666359713863"
|
||||
|
||||
const basePayload = z.object({
|
||||
name: z.string().optional(),
|
||||
|
||||
@@ -201,7 +201,10 @@ export async function handler(
|
||||
if (v === "$model") return headers.set(k, model)
|
||||
if (v === "$request") return headers.set(k, requestId)
|
||||
if (v === "$project") return headers.set(k, projectId)
|
||||
if (v === "$workspace" && authInfo?.workspaceID) return headers.set(k, authInfo.workspaceID)
|
||||
if (v === "$workspace") {
|
||||
if (authInfo?.workspaceID) headers.set(k, authInfo.workspaceID)
|
||||
return
|
||||
}
|
||||
headers.set(k, v)
|
||||
})
|
||||
headers.delete("host")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as MoveSession from "./move-session"
|
||||
|
||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
import { EventV2 } from "../event"
|
||||
import { Git } from "../git"
|
||||
import { Location } from "../location"
|
||||
@@ -146,3 +147,9 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Git.node, EventV2.node, ProjectV2.node, SessionStore.node],
|
||||
})
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
import { Layer } from "effect"
|
||||
import { buildLocationServiceMap } from "../location-services"
|
||||
import { LocationServiceMap } from "../location-service-map"
|
||||
import { LayerNode } from "./layer-node"
|
||||
import { makeGlobalNode } from "./app-node"
|
||||
|
||||
export function build<A, E>(root: LayerNode.Node<A, E, any>, replacements?: readonly LayerNode.Replacement[]) {
|
||||
const replacementMap = new Map(replacements?.map((item) => [item.source, item.replacement]))
|
||||
export function build<A, E>(root: LayerNode.Node<A, E, any>, replacements: LayerNode.Replacements = []) {
|
||||
let allReplacements = replacements
|
||||
|
||||
if (!LayerNode.hasUnbound(root, LocationServiceMap.node)) {
|
||||
// If the location service map is not needed, we shouldn't pull it
|
||||
// in. Compile the graph normally
|
||||
return LayerNode.compile(root, replacementMap)
|
||||
// Only build the location service map if it's actually needed
|
||||
if (LayerNode.hasUnbound(root, LocationServiceMap.node) && !hasReplacement(replacements, LocationServiceMap.node)) {
|
||||
const locationMap = buildLocationServiceMap(replacements)
|
||||
const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] })
|
||||
allReplacements = replacements.concat([[LocationServiceMap.node, locationMapNode]])
|
||||
}
|
||||
|
||||
const locationMap = buildLocationServiceMap(replacementMap)
|
||||
const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] })
|
||||
return LayerNode.compile(root, allReplacements)
|
||||
}
|
||||
|
||||
const app = LayerNode.bind(root, LocationServiceMap.node, locationMapNode)
|
||||
|
||||
return LayerNode.compile(app, replacementMap)
|
||||
function hasReplacement(replacements: LayerNode.Replacements, node: LayerNode.Node<unknown, unknown, any>) {
|
||||
return replacements.some(([source]) => source.name === node.name)
|
||||
}
|
||||
|
||||
export * as AppNodeBuilder from "./app-node-builder"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
File to save in: ~/.local/share/opencode/worktree/012780/location-layer-tiers/packages/core/src/effect/
|
||||
@@ -111,20 +111,52 @@ export function group<const Items extends readonly AnyNode[]>(
|
||||
return { kind: "group", name: "group", dependencies }
|
||||
}
|
||||
|
||||
export type Replacement = {
|
||||
readonly source: Layer.Any
|
||||
readonly replacement: Layer.Any
|
||||
}
|
||||
export type Replacement = readonly [source: AnyNode, replacement: AnyNode | Layer.Any]
|
||||
export type Replacements = readonly Replacement[]
|
||||
|
||||
type CheckReplacementErrors<SourceError, ReplacementError> = [Exclude<ReplacementError, SourceError>] extends [never]
|
||||
? unknown
|
||||
: { readonly "New replacement errors": Exclude<ReplacementError, SourceError> }
|
||||
|
||||
export function replace<A, E, R, E2>(
|
||||
source: Layer.Layer<A, E, R>,
|
||||
replacement: Layer.Layer<NoInfer<A>, E2, never> & CheckReplacementErrors<E, NoInfer<E2>>,
|
||||
): Replacement {
|
||||
return { source, replacement }
|
||||
type CheckReplacement<Item> = Item extends readonly [Node<infer A, infer E, infer T>, infer Replacement]
|
||||
? Replacement extends Node<NoInfer<A>, infer E2, T>
|
||||
? CheckReplacementErrors<E, NoInfer<E2>>
|
||||
: Replacement extends Layer.Layer<NoInfer<A>, infer E2, never>
|
||||
? CheckReplacementErrors<E, NoInfer<E2>>
|
||||
: { readonly "Invalid replacement": Replacement }
|
||||
: { readonly "Invalid replacement": Item }
|
||||
|
||||
type CheckReplacements<Items extends Replacements> = {
|
||||
readonly [K in keyof Items]: CheckReplacement<Items[K]>
|
||||
}
|
||||
|
||||
type ValidReplacements<Items extends Replacements> = Items & CheckReplacements<Items>
|
||||
|
||||
function replacementNode(source: AnyNode, replacement: AnyNode | Layer.Any) {
|
||||
const replacementNode = isNode(replacement)
|
||||
? replacement
|
||||
: make({
|
||||
...nodeMakeIdentity(source),
|
||||
layer: replacement as Layer.Layer<unknown, unknown>,
|
||||
deps: [],
|
||||
tag: source.tag,
|
||||
})
|
||||
if (source.name !== replacementNode.name) {
|
||||
throw new Error(`Cannot replace ${source.name} with ${replacementNode.name}`)
|
||||
}
|
||||
if (source.tag !== replacementNode.tag) {
|
||||
throw new Error(`Cannot replace ${source.name} across tags`)
|
||||
}
|
||||
return replacementNode
|
||||
}
|
||||
|
||||
function nodeMakeIdentity(node: AnyNode): NodeIdentity {
|
||||
if (node.service !== undefined) return { service: node.service }
|
||||
return { name: node.name }
|
||||
}
|
||||
|
||||
function isNode(input: Layer.Any | AnyNode): input is AnyNode {
|
||||
return "kind" in input && "dependencies" in input
|
||||
}
|
||||
|
||||
// Tree -----------------------------------------------------------------------
|
||||
@@ -176,32 +208,38 @@ function walk<Result>(
|
||||
return recur(root)
|
||||
}
|
||||
|
||||
export function hoist<A, E, T extends Tag>(
|
||||
export function hoist<A, E, T extends Tag, const Items extends Replacements = readonly []>(
|
||||
root: Node<A, E, any>,
|
||||
tag: T,
|
||||
replacements?: ValidReplacements<Items>,
|
||||
): {
|
||||
readonly node: Node<A, E>
|
||||
readonly hoisted: Node<unknown, E>
|
||||
} {
|
||||
const hoisted = new Map<string, AnyNode>()
|
||||
const replacementMap = replacementMapFrom(replacements)
|
||||
|
||||
const node = walk<AnyNode>(root, (node, context) => {
|
||||
if (node.kind === "group") {
|
||||
return { ...node, dependencies: node.dependencies.map(context.visit) }
|
||||
}
|
||||
if (node.tag === tag) {
|
||||
const existing = hoisted.get(node.name)
|
||||
if (existing && existing !== node) {
|
||||
throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`)
|
||||
const node = walk<AnyNode>(
|
||||
root,
|
||||
(node, context) => {
|
||||
if (node.kind === "group") {
|
||||
return { ...node, dependencies: node.dependencies.map(context.visit) }
|
||||
}
|
||||
hoisted.set(node.name, node)
|
||||
return group([])
|
||||
}
|
||||
if (node.kind === "unbound") {
|
||||
return node
|
||||
}
|
||||
return { ...node, dependencies: node.dependencies.map(context.visit) }
|
||||
})
|
||||
if (node.tag === tag) {
|
||||
const existing = hoisted.get(node.name)
|
||||
if (existing && existing !== node) {
|
||||
throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`)
|
||||
}
|
||||
hoisted.set(node.name, node)
|
||||
return group([])
|
||||
}
|
||||
if (node.kind === "unbound") {
|
||||
return node
|
||||
}
|
||||
return { ...node, dependencies: node.dependencies.map(context.visit) }
|
||||
},
|
||||
{ resolve: (node) => replacementMap.get(node.name) ?? node },
|
||||
)
|
||||
|
||||
return {
|
||||
node: node as Node<A, E>,
|
||||
@@ -209,10 +247,11 @@ export function hoist<A, E, T extends Tag>(
|
||||
}
|
||||
}
|
||||
|
||||
export function compile<A, E>(
|
||||
export function compile<A, E, const Items extends Replacements = readonly []>(
|
||||
root: Node<A, E, any>,
|
||||
replacements?: ReadonlyMap<Layer.Any, Layer.Any>,
|
||||
replacements?: ValidReplacements<Items>,
|
||||
): Layer.Layer<A, E> {
|
||||
const replacementMap = replacementMapFrom(replacements)
|
||||
const cache = new Map<AnyNode, RuntimeLayer>()
|
||||
const compileNode = (node: AnyNode) =>
|
||||
walk<RuntimeLayer>(
|
||||
@@ -220,18 +259,22 @@ export function compile<A, E>(
|
||||
(node, context) => {
|
||||
if (node.kind === "unbound") throw new Error(`Unbound layer node: ${node.name}`)
|
||||
const dependencies = node.dependencies.flatMap(flatten).map(context.visit)
|
||||
const implementation = (replacements?.get(node.implementation!) ?? node.implementation!) as RuntimeLayer
|
||||
const implementation = node.implementation! as RuntimeLayer
|
||||
return dependencies.length === 0
|
||||
? implementation
|
||||
: implementation.pipe(Layer.provide(dependencies as [RuntimeLayer, ...RuntimeLayer[]]))
|
||||
},
|
||||
{ cache },
|
||||
{ cache, resolve: (node) => replacementMap.get(node.name) ?? node },
|
||||
)
|
||||
const layers = flatten(root).map((node) => compileNode(node))
|
||||
const layer = layers.reduce<RuntimeLayer>((result, layer) => layer.pipe(Layer.provideMerge(result)), Layer.empty)
|
||||
return layer as Layer.Layer<A, E>
|
||||
}
|
||||
|
||||
function replacementMapFrom(replacements?: Replacements) {
|
||||
return new Map(replacements?.map(([source, replacement]) => [source.name, replacementNode(source, replacement)]))
|
||||
}
|
||||
|
||||
export function hasUnbound(root: Node<unknown, unknown, any>, source: AnyNode): boolean {
|
||||
if (source.kind !== "unbound") throw new Error(`Cannot check non-unbound layer node: ${source.name}`)
|
||||
return walk<boolean>(root, (node, context) => {
|
||||
@@ -240,32 +283,6 @@ export function hasUnbound(root: Node<unknown, unknown, any>, source: AnyNode):
|
||||
})
|
||||
}
|
||||
|
||||
export function bind<A, E, T extends Tag | undefined>(
|
||||
root: Node<A, E, T>,
|
||||
source: AnyNode,
|
||||
replacement: AnyNode,
|
||||
): Node<A, E, T> {
|
||||
if (source.kind !== "unbound") throw new Error(`Cannot bind non-unbound layer node: ${source.name}`)
|
||||
if (source.name !== replacement.name) {
|
||||
throw new Error(`Cannot bind ${source.name} to ${replacement.name}`)
|
||||
}
|
||||
if (source.tag !== replacement.tag) {
|
||||
throw new Error(`Cannot bind ${source.name} across tags`)
|
||||
}
|
||||
return walk<AnyNode>(
|
||||
root,
|
||||
(target, context) => {
|
||||
if (target.kind === "unbound") return target
|
||||
const dependencies: AnyNode[] = []
|
||||
const clone = { ...target, dependencies }
|
||||
context.cache.set(target, clone)
|
||||
dependencies.push(...target.dependencies.map(context.visit))
|
||||
return clone
|
||||
},
|
||||
{ detectCycles: false, resolve: (node) => (node === source ? replacement : node) },
|
||||
) as Node<A, E, T>
|
||||
}
|
||||
|
||||
function flatten(node: AnyNode): readonly AnyNode[] {
|
||||
return node.kind === "group" ? node.dependencies.flatMap(flatten) : [node]
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ import { Reference } from "./reference"
|
||||
import { ReferenceGuidance } from "./reference/guidance"
|
||||
import * as SessionRunnerLLM from "./session/runner/llm"
|
||||
import { SessionRunnerModel } from "./session/runner/model"
|
||||
import { SessionRuntime } from "./session/runtime"
|
||||
import { SessionTodo } from "./session/todo"
|
||||
import { SkillV2 } from "./skill"
|
||||
import { SkillGuidance } from "./skill/guidance"
|
||||
@@ -76,7 +75,6 @@ export const locationServices = LayerNode.group([
|
||||
BuiltInTools.node,
|
||||
SessionRunnerModel.node,
|
||||
Snapshot.node,
|
||||
SessionRuntime.node,
|
||||
SessionRunnerLLM.node,
|
||||
])
|
||||
|
||||
@@ -84,17 +82,16 @@ export type LocationServices = LayerNode.Output<typeof locationServices>
|
||||
export type LocationError = LayerNode.Error<typeof locationServices>
|
||||
|
||||
export function buildLocationServiceMap(
|
||||
replacements?: ReadonlyMap<Layer.Any, Layer.Any>,
|
||||
replacements: LayerNode.Replacements = [],
|
||||
): Layer.Layer<LocationServiceMap.Service> {
|
||||
return Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
LayerMap.make(
|
||||
(ref: Location.Ref) => {
|
||||
const location = LayerNode.hoist(
|
||||
LayerNode.bind(locationServices, Location.node, Location.boundNode(ref)),
|
||||
Node.tags.values.global,
|
||||
)
|
||||
return LayerNode.compile(location.node, replacements).pipe(
|
||||
const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]])
|
||||
const location = LayerNode.hoist(locationServices, Node.tags.values.global, allReplacements)
|
||||
|
||||
return LayerNode.compile(location.node).pipe(
|
||||
Layer.fresh,
|
||||
Layer.tap(() =>
|
||||
Effect.logInfo("booting location services", {
|
||||
@@ -102,7 +99,7 @@ export function buildLocationServiceMap(
|
||||
workspaceID: ref.workspaceID,
|
||||
}),
|
||||
),
|
||||
Layer.provide(LayerNode.compile(location.hoisted, replacements)),
|
||||
Layer.provide(LayerNode.compile(location.hoisted)),
|
||||
)
|
||||
},
|
||||
{ idleTimeToLive: "60 minutes" },
|
||||
|
||||
@@ -13,10 +13,7 @@ import { Integration } from "./integration"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
import { PluginHost } from "./plugin/host"
|
||||
import { Reference } from "./reference"
|
||||
import { SessionV2 } from "./session"
|
||||
import { SessionRuntime } from "./session/runtime"
|
||||
import { SkillV2 } from "./skill"
|
||||
import { Location } from "./location"
|
||||
import { State } from "./state"
|
||||
|
||||
export const ID = Plugin.ID
|
||||
@@ -152,8 +149,6 @@ export const locationLayer = layer.pipe(
|
||||
Layer.provideMerge(CommandV2.locationLayer),
|
||||
Layer.provideMerge(Integration.locationLayer),
|
||||
Layer.provideMerge(Reference.locationLayer),
|
||||
Layer.provideMerge(SessionV2.defaultLayer),
|
||||
Layer.provideMerge(SessionRuntime.layer),
|
||||
Layer.provideMerge(SkillV2.locationLayer),
|
||||
)
|
||||
|
||||
@@ -168,9 +163,6 @@ export const node = makeLocationNode({
|
||||
CommandV2.node,
|
||||
Integration.node,
|
||||
Reference.node,
|
||||
SessionV2.node,
|
||||
SessionRuntime.node,
|
||||
SkillV2.node,
|
||||
Location.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -13,11 +13,7 @@ import { PluginV2 } from "../plugin"
|
||||
import { ProviderV2 } from "../provider"
|
||||
import { Reference } from "../reference"
|
||||
import type { DeepMutable } from "../schema"
|
||||
import { SessionV2 } from "../session"
|
||||
import { SessionMessage } from "../session/message"
|
||||
import { SessionRuntime } from "../session/runtime"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { Location } from "../location"
|
||||
|
||||
const mutable = <T>(value: T) => value as DeepMutable<T>
|
||||
|
||||
@@ -28,10 +24,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
const commands = yield* CommandV2.Service
|
||||
const integration = yield* Integration.Service
|
||||
const reference = yield* Reference.Service
|
||||
const session = yield* SessionV2.Service
|
||||
const sessionRuntime = yield* SessionRuntime.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const location = yield* Location.Service
|
||||
|
||||
return {
|
||||
options: {},
|
||||
@@ -212,60 +205,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
}),
|
||||
),
|
||||
},
|
||||
session: {
|
||||
create: (input) =>
|
||||
session.create(
|
||||
input.parentID
|
||||
? {
|
||||
id: input.id ? SessionV2.ID.make(input.id) : undefined,
|
||||
parentID: SessionV2.ID.make(input.parentID),
|
||||
title: input.title,
|
||||
agent: input.agent ? AgentV2.ID.make(input.agent) : undefined,
|
||||
model: input.model
|
||||
? {
|
||||
id: ModelV2.ID.make(input.model.id),
|
||||
providerID: ProviderV2.ID.make(input.model.providerID),
|
||||
variant: input.model.variant ? ModelV2.VariantID.make(input.model.variant) : undefined,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: {
|
||||
id: input.id ? SessionV2.ID.make(input.id) : undefined,
|
||||
location: { directory: location.directory, workspaceID: location.workspaceID },
|
||||
title: input.title,
|
||||
agent: input.agent ? AgentV2.ID.make(input.agent) : undefined,
|
||||
model: input.model
|
||||
? {
|
||||
id: ModelV2.ID.make(input.model.id),
|
||||
providerID: ProviderV2.ID.make(input.model.providerID),
|
||||
variant: input.model.variant ? ModelV2.VariantID.make(input.model.variant) : undefined,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
),
|
||||
get: (sessionID) => session.get(SessionV2.ID.make(sessionID)),
|
||||
messages: (input) =>
|
||||
session.messages({
|
||||
sessionID: SessionV2.ID.make(input.sessionID),
|
||||
limit: input.limit,
|
||||
order: input.order,
|
||||
cursor: input.cursor
|
||||
? { id: SessionMessage.ID.make(input.cursor.id), direction: input.cursor.direction }
|
||||
: undefined,
|
||||
}),
|
||||
context: (sessionID) => session.context(SessionV2.ID.make(sessionID)),
|
||||
prompt: (input) =>
|
||||
sessionRuntime.prompt({
|
||||
id: input.id ? SessionMessage.ID.make(input.id) : undefined,
|
||||
sessionID: SessionV2.ID.make(input.sessionID),
|
||||
prompt: input.prompt,
|
||||
delivery: input.delivery,
|
||||
resume: input.resume,
|
||||
}),
|
||||
resume: (sessionID) => sessionRuntime.resume(SessionV2.ID.make(sessionID)),
|
||||
wait: (sessionID) => sessionRuntime.wait(SessionV2.ID.make(sessionID)),
|
||||
interrupt: (sessionID) => sessionRuntime.interrupt(SessionV2.ID.make(sessionID)),
|
||||
},
|
||||
skill: {
|
||||
reload: skill.reload,
|
||||
transform: (callback) =>
|
||||
|
||||
@@ -81,16 +81,6 @@ export function fromPromise(plugin: Plugin) {
|
||||
transform: transform(host.reference),
|
||||
reload: () => run(host.reference.reload()),
|
||||
},
|
||||
session: {
|
||||
create: (input) => run(host.session.create(input)),
|
||||
get: (sessionID) => run(host.session.get(sessionID)),
|
||||
messages: (input) => run(host.session.messages(input)),
|
||||
context: (sessionID) => run(host.session.context(sessionID)),
|
||||
prompt: (input) => run(host.session.prompt(input)),
|
||||
resume: (sessionID) => run(host.session.resume(sessionID)),
|
||||
wait: (sessionID) => run(host.session.wait(sessionID)),
|
||||
interrupt: (sessionID) => run(host.session.interrupt(sessionID)),
|
||||
},
|
||||
skill: {
|
||||
transform: transform(host.skill),
|
||||
reload: () => run(host.skill.reload()),
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export * as PublicEventManifest from "./public-event-manifest"
|
||||
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
|
||||
export const Definitions = EventManifest.ServerDefinitions
|
||||
export const Latest = Event.latest(Definitions)
|
||||
@@ -25,9 +25,10 @@ import { ProjectTable } from "./project/sql"
|
||||
import path from "path"
|
||||
import { fromRow } from "./session/info"
|
||||
import { SessionRunner } from "./session/runner/index"
|
||||
import { SessionRuntimeCoordinator } from "./session/runtime-coordinator"
|
||||
import { SessionStore } from "./session/store"
|
||||
import { SessionExecution } from "./session/execution"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { LocationServiceMap } from "./location-service-map"
|
||||
import { MessageDecodeError } from "./session/error"
|
||||
import { SessionEvent } from "./session/event"
|
||||
import { SessionInput } from "./session/input"
|
||||
@@ -75,14 +76,12 @@ const ListAllInput = Schema.Struct(ListInputBase)
|
||||
export const ListInput = Schema.Union([ListDirectoryInput, ListProjectInput, ListAllInput])
|
||||
export type ListInput = typeof ListInput.Type
|
||||
|
||||
type CreateBaseInput = {
|
||||
type CreateInput = {
|
||||
id?: SessionSchema.ID
|
||||
title?: string
|
||||
agent?: AgentV2.ID
|
||||
model?: ModelV2.Ref
|
||||
location: Location.Ref
|
||||
}
|
||||
type CreateInput = CreateBaseInput &
|
||||
({ location: Location.Ref; parentID?: never } | { parentID: SessionSchema.ID; location?: never })
|
||||
|
||||
type CompactInput = {
|
||||
sessionID: SessionSchema.ID
|
||||
@@ -96,7 +95,7 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Ses
|
||||
export class OperationUnavailableError extends Schema.TaggedErrorClass<OperationUnavailableError>()(
|
||||
"Session.OperationUnavailableError",
|
||||
{
|
||||
operation: Schema.Literals(["move", "shell", "skill", "switchAgent", "compact"]),
|
||||
operation: Schema.Literals(["move", "shell", "skill", "switchAgent", "compact", "wait"]),
|
||||
},
|
||||
) {}
|
||||
|
||||
@@ -106,9 +105,6 @@ export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictE
|
||||
sessionID: SessionSchema.ID,
|
||||
messageID: SessionMessage.ID,
|
||||
}) {}
|
||||
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
}) {}
|
||||
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
|
||||
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
|
||||
|
||||
@@ -116,7 +112,7 @@ export type Error = NotFoundError | MessageDecodeError | OperationUnavailableErr
|
||||
|
||||
export interface Interface {
|
||||
readonly list: (input?: ListInput) => Effect.Effect<SessionSchema.Info[]>
|
||||
readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info, NotFoundError>
|
||||
readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info>
|
||||
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError>
|
||||
readonly messages: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
@@ -148,7 +144,6 @@ export interface Interface {
|
||||
sessionID: SessionSchema.ID
|
||||
model: ModelV2.Ref
|
||||
}) => Effect.Effect<void, NotFoundError>
|
||||
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
|
||||
readonly prompt: (input: {
|
||||
id?: SessionMessage.ID
|
||||
sessionID: SessionSchema.ID
|
||||
@@ -169,7 +164,7 @@ export interface Interface {
|
||||
resume?: boolean
|
||||
}) => Effect.Effect<void, OperationUnavailableError>
|
||||
readonly compact: (input: CompactInput) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
||||
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
||||
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
||||
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
|
||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
|
||||
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
@@ -178,9 +173,9 @@ export interface Interface {
|
||||
sessionID: SessionSchema.ID
|
||||
messageID: SessionMessage.ID
|
||||
files?: boolean
|
||||
}) => 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>
|
||||
}) => Effect.Effect<Revert.State, NotFoundError | MessageNotFoundError | Snapshot.Error>
|
||||
readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | Snapshot.Error>
|
||||
readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,8 +188,9 @@ export const layer = Layer.effect(
|
||||
const db = database.db
|
||||
const events = yield* EventV2.Service
|
||||
const projects = yield* ProjectV2.Service
|
||||
const runtimeCoordinator = yield* SessionRuntimeCoordinator.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
@@ -213,12 +209,7 @@ export const layer = Layer.effect(
|
||||
const sessionID = input.id ?? SessionSchema.ID.create()
|
||||
const recorded = yield* store.get(sessionID)
|
||||
if (recorded) return recorded
|
||||
const parent = input.parentID ? yield* store.get(input.parentID) : undefined
|
||||
if (input.parentID && parent === undefined) return yield* new NotFoundError({ sessionID: input.parentID })
|
||||
const location = parent?.location ?? input.location
|
||||
if (location === undefined)
|
||||
return yield* Effect.die(new Error("V2Session.create requires either location or an existing parentID"))
|
||||
const project = yield* projects.resolve(location.directory)
|
||||
const project = yield* projects.resolve(input.location.directory)
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
|
||||
@@ -231,11 +222,10 @@ export const layer = Layer.effect(
|
||||
slug: Slug.create(),
|
||||
version: InstallationVersion,
|
||||
projectID: project.id,
|
||||
parentID: input.parentID,
|
||||
directory: location.directory,
|
||||
path: path.relative(project.directory, location.directory).replaceAll("\\", "/"),
|
||||
workspaceID: location.workspaceID ? WorkspaceV2.ID.make(location.workspaceID) : undefined,
|
||||
title: input.title ?? `New session - ${new Date(now).toISOString()}`,
|
||||
directory: input.location.directory,
|
||||
path: path.relative(project.directory, input.location.directory).replaceAll("\\", "/"),
|
||||
workspaceID: input.location.workspaceID ? WorkspaceV2.ID.make(input.location.workspaceID) : undefined,
|
||||
title: `New session - ${new Date(now).toISOString()}`,
|
||||
agent: input.agent,
|
||||
model: input.model
|
||||
? {
|
||||
@@ -248,22 +238,24 @@ export const layer = Layer.effect(
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: now, updated: now },
|
||||
})
|
||||
const projected = yield* events.publish(SessionV1.Event.Created, { sessionID, info }, { location }).pipe(
|
||||
Effect.as({ type: "created" } as const),
|
||||
Effect.catchDefect((defect) => {
|
||||
if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
|
||||
return Effect.die(defect)
|
||||
}
|
||||
// Concurrent creation lost the projection race. The existing Session identity wins.
|
||||
return store
|
||||
.get(sessionID)
|
||||
.pipe(
|
||||
Effect.flatMap((session) =>
|
||||
session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
const projected = yield* events
|
||||
.publish(SessionV1.Event.Created, { sessionID, info }, { location: input.location })
|
||||
.pipe(
|
||||
Effect.as({ type: "created" } as const),
|
||||
Effect.catchDefect((defect) => {
|
||||
if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
|
||||
return Effect.die(defect)
|
||||
}
|
||||
// Concurrent creation lost the projection race. The existing Session identity wins.
|
||||
return store
|
||||
.get(sessionID)
|
||||
.pipe(
|
||||
Effect.flatMap((session) =>
|
||||
session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
if (projected.type === "existing") return projected.session
|
||||
// TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice.
|
||||
return yield* result.get(sessionID).pipe(Effect.orDie)
|
||||
@@ -387,8 +379,7 @@ export const layer = Layer.effect(
|
||||
)
|
||||
if (!SessionInput.equivalent(admitted, expected))
|
||||
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||
if (input.resume !== false)
|
||||
return yield* Effect.die("SessionV2.prompt with resume moved to SessionRuntime.Service")
|
||||
if (input.resume !== false) yield* execution.wake(admitted.sessionID)
|
||||
return admitted
|
||||
}),
|
||||
),
|
||||
@@ -423,40 +414,41 @@ export const layer = Layer.effect(
|
||||
model: input.model,
|
||||
})
|
||||
}),
|
||||
rename: Effect.fn("V2Session.rename")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
yield* events.publish(SessionEvent.Renamed, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
title: input.title,
|
||||
})
|
||||
}),
|
||||
compact: Effect.fn("V2Session.compact")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
return yield* new OperationUnavailableError({ operation: "compact" })
|
||||
}),
|
||||
wait: Effect.fn("V2Session.wait")(function* (sessionID) {
|
||||
yield* result.get(sessionID)
|
||||
return yield* Effect.die("SessionV2.wait moved to SessionRuntime.Service")
|
||||
return yield* new OperationUnavailableError({ operation: "wait" })
|
||||
}),
|
||||
active: runtimeCoordinator.active,
|
||||
active: execution.active,
|
||||
resume: Effect.fn("V2Session.resume")(function* (sessionID) {
|
||||
yield* result.get(sessionID)
|
||||
return yield* Effect.die("SessionV2.resume moved to SessionRuntime.Service")
|
||||
yield* execution.resume(sessionID)
|
||||
}),
|
||||
interrupt: Effect.fn("V2Session.interrupt")(() => Effect.die("SessionV2.interrupt moved to SessionRuntime.Service")),
|
||||
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
|
||||
Effect.uninterruptible(execution.interrupt(sessionID)),
|
||||
),
|
||||
revert: {
|
||||
stage: Effect.fn("V2Session.revert.stage")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
return yield* Effect.die("SessionV2.revert.stage moved to SessionRuntime.Service")
|
||||
const session = yield* result.get(input.sessionID)
|
||||
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
|
||||
Effect.provideService(Database.Service, database),
|
||||
Effect.provideService(EventV2.Service, events),
|
||||
Effect.provide(locations.get(session.location)),
|
||||
)
|
||||
}),
|
||||
clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) {
|
||||
yield* result.get(sessionID)
|
||||
return yield* Effect.die("SessionV2.revert.clear moved to SessionRuntime.Service")
|
||||
const session = yield* result.get(sessionID)
|
||||
yield* SessionRevert.clear(session).pipe(
|
||||
Effect.provideService(EventV2.Service, events),
|
||||
Effect.provide(locations.get(session.location)),
|
||||
)
|
||||
}),
|
||||
commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) {
|
||||
const session = yield* result.get(sessionID)
|
||||
return yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
|
||||
yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
|
||||
}),
|
||||
},
|
||||
})
|
||||
@@ -471,7 +463,6 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.provide(SessionRuntimeCoordinator.defaultLayer),
|
||||
Layer.orDie,
|
||||
)
|
||||
|
||||
@@ -479,7 +470,6 @@ const resolvePrompt = (input: PromptInput.Prompt) =>
|
||||
Prompt.make({
|
||||
text: input.text,
|
||||
agents: input.agents,
|
||||
system: input.system,
|
||||
files: input.files?.map((file) => {
|
||||
const dataMime = file.uri.match(/^data:([^;,]+)[;,]/i)?.[1]
|
||||
const target = URL.canParse(file.uri) ? new URL(file.uri).pathname : (file.name ?? file.uri)
|
||||
@@ -497,8 +487,9 @@ export const node = makeGlobalNode({
|
||||
Database.node,
|
||||
EventV2.node,
|
||||
ProjectV2.node,
|
||||
SessionRuntimeCoordinator.node,
|
||||
SessionExecution.node,
|
||||
SessionStore.node,
|
||||
LocationServiceMap.node,
|
||||
SessionProjector.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -15,8 +15,6 @@ export interface Interface {
|
||||
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. */
|
||||
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
/** Routes execution from a Session ID to the runner owned by that Session's Location. */
|
||||
@@ -32,6 +30,5 @@ export const noopLayer = Layer.succeed(
|
||||
resume: () => Effect.void,
|
||||
wake: () => Effect.void,
|
||||
interrupt: () => Effect.void,
|
||||
awaitIdle: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -33,7 +33,6 @@ export const layer = Layer.effect(
|
||||
interrupt: coordinator.interrupt,
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -123,7 +123,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
)
|
||||
},
|
||||
"session.next.moved": () => Effect.void,
|
||||
"session.next.renamed": () => Effect.void,
|
||||
"session.next.prompted": (event) => {
|
||||
return adapter.appendMessage(
|
||||
SessionMessage.User.make({
|
||||
@@ -133,7 +132,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
text: event.data.prompt.text,
|
||||
files: event.data.prompt.files,
|
||||
agents: event.data.prompt.agents,
|
||||
system: event.data.prompt.system,
|
||||
time: { created: event.data.timestamp },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -347,14 +347,6 @@ export const layer = Layer.effectDiscard(
|
||||
yield* run(db, event)
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.Renamed, (event) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({ title: event.data.title, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
yield* events.project(SessionEvent.Prompted, (event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
|
||||
|
||||
@@ -12,8 +12,6 @@ export interface Coordinator<Key, E> {
|
||||
readonly wake: (key: Key) => Effect.Effect<void>
|
||||
/** Stops active execution and waits for its cleanup. */
|
||||
readonly interrupt: (key: Key) => 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>
|
||||
}
|
||||
|
||||
type Entry<E> = {
|
||||
@@ -102,15 +100,5 @@ export const make = <Key, E>(options: {
|
||||
return Fiber.interrupt(entry.owner)
|
||||
})
|
||||
|
||||
// Each successful drain reuses its entry.done across coalesced wakes, so one await
|
||||
// already spans steered and queued continuation. Re-check after it settles to cover a
|
||||
// fresh wake (or a failure/stopping successor) that installs a new entry.
|
||||
const awaitIdle = (key: Key): Effect.Effect<void> =>
|
||||
Effect.suspend(() => {
|
||||
const entry = active.get(key)
|
||||
if (entry === undefined) return Effect.void
|
||||
return Deferred.await(entry.done).pipe(Effect.exit, Effect.andThen(awaitIdle(key)))
|
||||
})
|
||||
|
||||
return { active: Effect.sync(() => new Set(active.keys())), run, wake, interrupt, awaitIdle }
|
||||
return { active: Effect.sync(() => new Set(active.keys())), run, wake, interrupt }
|
||||
})
|
||||
|
||||
@@ -194,16 +194,13 @@ export const layer = Layer.effect(
|
||||
const model = yield* models.resolve(session)
|
||||
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
|
||||
const context = entries.map((entry) => entry.message)
|
||||
// Mirror V1 (session/llm/request.ts): append the current turn's per-request system string after the
|
||||
// agent prompt and durable baseline. The current turn's user prompt is the latest user message in context.
|
||||
const turnSystem = context.findLast((message) => message.type === "user")?.system
|
||||
const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps
|
||||
const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agent.info?.permissions)
|
||||
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
|
||||
const request = LLM.request({
|
||||
model,
|
||||
providerOptions: { openai: { promptCacheKey } },
|
||||
system: [agent.info?.system, system.baseline, turnSystem]
|
||||
system: [agent.info?.system, system.baseline]
|
||||
.filter((part): part is string => part !== undefined && part.length > 0)
|
||||
.map(SystemPart.make),
|
||||
messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])],
|
||||
@@ -292,18 +289,19 @@ export const layer = Layer.effect(
|
||||
}
|
||||
if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers)
|
||||
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
|
||||
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
|
||||
const toolsInterrupted = settled._tag === "Failure" && Cause.hasInterrupts(settled.cause)
|
||||
if (settled._tag === "Failure" && isQuestionRejected(settled.cause)) {
|
||||
yield* FiberSet.clear(toolFibers)
|
||||
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
yield* withPublication(publisher.failAssistant("Provider turn interrupted"))
|
||||
return yield* Effect.interrupt
|
||||
}
|
||||
if (streamInterrupted || toolsInterrupted) {
|
||||
if (
|
||||
(stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) ||
|
||||
(settled._tag === "Failure" && Cause.hasInterrupts(settled.cause))
|
||||
) {
|
||||
yield* FiberSet.clear(toolFibers)
|
||||
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
yield* withPublication(publisher.failAssistant("Provider turn interrupted"))
|
||||
if (publisher.hasActiveAssistant())
|
||||
yield* withPublication(publisher.failAssistant("Provider turn interrupted"))
|
||||
}
|
||||
if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) {
|
||||
const failure = Cause.squash(settled.cause)
|
||||
@@ -311,7 +309,7 @@ export const layer = Layer.effect(
|
||||
yield* withPublication(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
|
||||
}
|
||||
const stepSettlement = publisher.stepSettlement()
|
||||
if (stepSettlement && !streamInterrupted && !toolsInterrupted && !publisher.hasProviderError()) {
|
||||
if (stepSettlement && !publisher.hasProviderError()) {
|
||||
const endSnapshot = yield* snapshots.capture()
|
||||
const files =
|
||||
startSnapshot && endSnapshot
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
export * as SessionRuntimeCoordinator from "./runtime-coordinator"
|
||||
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
import { SessionRunner } from "./runner"
|
||||
import { SessionRunCoordinator } from "./run-coordinator"
|
||||
import { SessionSchema } from "./schema"
|
||||
|
||||
type Drain = (force: boolean) => Effect.Effect<void, SessionRunner.RunError>
|
||||
|
||||
export interface Interface {
|
||||
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
|
||||
readonly run: (sessionID: SessionSchema.ID, drain: Drain) => Effect.Effect<void, SessionRunner.RunError>
|
||||
readonly wake: (sessionID: SessionSchema.ID, drain: Drain) => Effect.Effect<void>
|
||||
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
readonly wait: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionRuntimeCoordinator") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const drains = new Map<SessionSchema.ID, Drain>()
|
||||
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError>({
|
||||
drain: Effect.fnUntraced(function* (sessionID, force) {
|
||||
const drain = drains.get(sessionID)
|
||||
if (!drain) return yield* Effect.die(`No SessionRuntime drain registered for ${sessionID}`)
|
||||
return yield* drain(force)
|
||||
}),
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
active: coordinator.active,
|
||||
run: (sessionID, drain) =>
|
||||
Effect.sync(() => drains.set(sessionID, drain)).pipe(Effect.andThen(coordinator.run(sessionID))),
|
||||
wake: (sessionID, drain) =>
|
||||
Effect.sync(() => drains.set(sessionID, drain)).pipe(Effect.andThen(coordinator.wake(sessionID))),
|
||||
interrupt: coordinator.interrupt,
|
||||
wait: coordinator.awaitIdle,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||
@@ -1,165 +0,0 @@
|
||||
export * as SessionRuntime from "./runtime"
|
||||
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { Location } from "../location"
|
||||
import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
import { SessionMessage } from "./message"
|
||||
import { Prompt } from "./prompt"
|
||||
import { SessionInput } from "./input"
|
||||
import { SessionRevert } from "./revert"
|
||||
import { SessionRunner } from "./runner"
|
||||
import * as SessionRunnerLLM from "./runner/llm"
|
||||
import { SessionRuntimeCoordinator } from "./runtime-coordinator"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { Snapshot } from "../snapshot"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { SessionV2 } from "../session"
|
||||
import {
|
||||
BusyError,
|
||||
MessageNotFoundError,
|
||||
NotFoundError,
|
||||
PromptConflictError,
|
||||
type RevertState,
|
||||
} from "../session"
|
||||
|
||||
export interface Interface {
|
||||
readonly prompt: (input: {
|
||||
id?: SessionMessage.ID
|
||||
sessionID: SessionSchema.ID
|
||||
prompt: PromptInput.Prompt
|
||||
delivery?: SessionInput.Delivery
|
||||
resume?: boolean
|
||||
}) => Effect.Effect<SessionInput.Admitted, NotFoundError | PromptConflictError>
|
||||
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
||||
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
|
||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
|
||||
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
readonly revert: {
|
||||
readonly stage: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
messageID: SessionMessage.ID
|
||||
files?: boolean
|
||||
}) => Effect.Effect<RevertState, 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>
|
||||
}
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionRuntime") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const db = database.db
|
||||
const events = yield* EventV2.Service
|
||||
const location = yield* Location.Service
|
||||
const sessions = yield* SessionV2.Service
|
||||
const runner = yield* SessionRunner.Service
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const coordinator = yield* SessionRuntimeCoordinator.Service
|
||||
|
||||
const local = Effect.fn("SessionRuntime.local")(function* (sessionID: SessionSchema.ID) {
|
||||
const session = yield* sessions.get(sessionID)
|
||||
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
|
||||
return yield* new NotFoundError({ sessionID })
|
||||
return session
|
||||
})
|
||||
|
||||
const drain = (sessionID: SessionSchema.ID) =>
|
||||
Effect.fnUntraced(function* (force: boolean) {
|
||||
yield* local(sessionID).pipe(Effect.orDie)
|
||||
return yield* runner.run({ sessionID, force })
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
prompt: Effect.fn("SessionRuntime.prompt")((input) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
yield* local(input.sessionID)
|
||||
const prompt = resolvePrompt(input.prompt)
|
||||
const messageID = input.id ?? SessionMessage.ID.create()
|
||||
const delivery = input.delivery ?? "steer"
|
||||
const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
|
||||
const admitted = yield* SessionInput.admit(db, events, {
|
||||
id: messageID,
|
||||
sessionID: input.sessionID,
|
||||
prompt,
|
||||
delivery,
|
||||
}).pipe(
|
||||
Effect.catchDefect((defect) =>
|
||||
defect instanceof SessionInput.LifecycleConflict
|
||||
? new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||
: Effect.die(defect),
|
||||
),
|
||||
)
|
||||
if (!SessionInput.equivalent(admitted, expected))
|
||||
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||
if (input.resume !== false) yield* coordinator.wake(admitted.sessionID, drain(admitted.sessionID))
|
||||
return admitted
|
||||
}),
|
||||
),
|
||||
),
|
||||
wait: Effect.fn("SessionRuntime.wait")(function* (sessionID) {
|
||||
yield* local(sessionID)
|
||||
yield* coordinator.wait(sessionID)
|
||||
}),
|
||||
active: coordinator.active,
|
||||
resume: Effect.fn("SessionRuntime.resume")(function* (sessionID) {
|
||||
yield* local(sessionID)
|
||||
yield* coordinator.run(sessionID, drain(sessionID))
|
||||
}),
|
||||
interrupt: Effect.fn("SessionRuntime.interrupt")(function* (sessionID) {
|
||||
yield* local(sessionID)
|
||||
yield* Effect.uninterruptible(coordinator.interrupt(sessionID))
|
||||
}),
|
||||
revert: {
|
||||
stage: Effect.fn("SessionRuntime.revert.stage")(function* (input) {
|
||||
const session = yield* local(input.sessionID)
|
||||
if ((yield* coordinator.active).has(input.sessionID)) return yield* new BusyError({ sessionID: input.sessionID })
|
||||
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
|
||||
Effect.provideService(Database.Service, database),
|
||||
Effect.provideService(EventV2.Service, events),
|
||||
Effect.provideService(Snapshot.Service, snapshot),
|
||||
)
|
||||
}),
|
||||
clear: Effect.fn("SessionRuntime.revert.clear")(function* (sessionID) {
|
||||
const session = yield* local(sessionID)
|
||||
if ((yield* coordinator.active).has(sessionID)) return yield* new BusyError({ sessionID })
|
||||
return yield* SessionRevert.clear(session).pipe(
|
||||
Effect.provideService(EventV2.Service, events),
|
||||
Effect.provideService(Snapshot.Service, snapshot),
|
||||
)
|
||||
}),
|
||||
commit: Effect.fn("SessionRuntime.revert.commit")(function* (sessionID) {
|
||||
const session = yield* local(sessionID)
|
||||
if ((yield* coordinator.active).has(sessionID)) return yield* new BusyError({ sessionID })
|
||||
return yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
|
||||
}),
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const resolvePrompt = (input: PromptInput.Prompt) =>
|
||||
Prompt.make({
|
||||
text: input.text,
|
||||
agents: input.agents,
|
||||
files: input.files?.map((file) => {
|
||||
const dataMime = file.uri.match(/^data:([^;,]+)[;,]/i)?.[1]
|
||||
const target = URL.canParse(file.uri) ? new URL(file.uri).pathname : (file.name ?? file.uri)
|
||||
return {
|
||||
...file,
|
||||
mime: dataMime ?? (target.endsWith("/") ? "application/x-directory" : FSUtil.mimeType(target)),
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Database.node, EventV2.node, Location.node, SessionV2.node, SessionRunnerLLM.node, Snapshot.node, SessionRuntimeCoordinator.node],
|
||||
})
|
||||
@@ -196,6 +196,8 @@ export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Global.node, Config.node] })
|
||||
|
||||
export const nodeWithoutConfig = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Global.node] })
|
||||
|
||||
/** Runs retention scanning once globally rather than once per active Location. */
|
||||
export const cleanupLayer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -4,11 +4,13 @@ import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { Patch } from "../patch"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
@@ -194,6 +196,12 @@ export const layer = Layer.effectDiscard(
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "tool/apply-patch",
|
||||
layer,
|
||||
deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node],
|
||||
})
|
||||
|
||||
function patchFile(change: Prepared): typeof FileDiff.Info.Type {
|
||||
const counts = diffLines(change.before, change.after).reduce(
|
||||
(result, item) => ({
|
||||
|
||||
@@ -5,11 +5,13 @@ import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Duration, Effect, Layer, Schema } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { Config } from "../config"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { AppProcess } from "../process"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { PositiveInt } from "../schema"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
@@ -193,3 +195,9 @@ export const layer = Layer.effectDiscard(
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "tool/bash",
|
||||
layer,
|
||||
deps: [ToolRegistry.node, LocationMutation.node, FSUtil.node, AppProcess.node, Config.node, PermissionV2.node],
|
||||
})
|
||||
|
||||
@@ -10,10 +10,12 @@ import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
@@ -213,3 +215,9 @@ export const layer = Layer.effectDiscard(
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "tool/edit",
|
||||
layer,
|
||||
deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node],
|
||||
})
|
||||
|
||||
@@ -2,8 +2,10 @@ export * as QuestionTool from "./question"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { QuestionV2 } from "../question"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
@@ -84,3 +86,9 @@ export const layer = Layer.effectDiscard(
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "tool/question",
|
||||
layer,
|
||||
deps: [ToolRegistry.node, PermissionV2.node, QuestionV2.node],
|
||||
})
|
||||
|
||||
@@ -2,12 +2,14 @@ export * as ReadTool from "./read"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { Image } from "../image"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { AbsolutePath } from "../schema"
|
||||
import { ReadToolFileSystem } from "./read-filesystem"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
@@ -107,3 +109,9 @@ export const layer = Layer.effectDiscard(
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "tool/read",
|
||||
layer,
|
||||
deps: [ToolRegistry.node, ReadToolFileSystem.node, LocationMutation.node, Image.node, PermissionV2.node],
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as ToolRegistry from "./registry"
|
||||
|
||||
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/llm"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import type { AgentV2 } from "../agent"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { SessionMessage } from "../session/message"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
|
||||
@@ -3,9 +3,11 @@ export * as SkillTool from "./skill"
|
||||
import path from "path"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
@@ -99,3 +101,9 @@ export const layer = Layer.effectDiscard(
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "tool/skill",
|
||||
layer,
|
||||
deps: [ToolRegistry.node, FSUtil.node, SkillV2.node, PermissionV2.node],
|
||||
})
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
export * as SubagentTool from "./subagent"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { DateTime, Effect, Layer, Schema, Scope } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { BackgroundJob } from "../background-job"
|
||||
import { EventV2 } from "../event"
|
||||
import { LocationServiceMap } from "../location-service-map"
|
||||
import { SessionV2 } from "../session"
|
||||
import { SessionRuntime } from "../session/runtime"
|
||||
import { SessionEvent } from "../session/event"
|
||||
import { SessionMessage } from "../session/message"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
import { ApplicationTools } from "./application-tools"
|
||||
import { Tool } from "./tool"
|
||||
|
||||
export const name = "subagent"
|
||||
|
||||
const NO_TEXT = "Subagent completed without a text response."
|
||||
const BACKGROUND_STARTED =
|
||||
"The subagent is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll, or proactively check on its progress."
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
agent: Schema.String.annotate({ description: "The configured agent to run as the subagent" }),
|
||||
description: Schema.String.annotate({ description: "A short description of the subagent's task" }),
|
||||
prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }),
|
||||
background: Schema.Boolean.pipe(Schema.optional).annotate({
|
||||
description:
|
||||
"Run the subagent in the background and return immediately. You will be notified when it completes. DO NOT poll its progress.",
|
||||
}),
|
||||
})
|
||||
|
||||
export const Output = Schema.Struct({
|
||||
sessionID: SessionSchema.ID,
|
||||
status: Schema.Literals(["completed", "running"]),
|
||||
output: Schema.String,
|
||||
})
|
||||
|
||||
export const description = [
|
||||
"Spawn a subagent: a child session running a configured agent with fresh context.",
|
||||
"Foreground (default) runs the subagent to completion and returns its final response.",
|
||||
"Background mode (background=true) launches it asynchronously and returns immediately; you are notified when it finishes.",
|
||||
"Use background only for independent work that can run while you continue elsewhere.",
|
||||
].join("\n")
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const tools = yield* ApplicationTools.Service
|
||||
const sessions = yield* SessionV2.Service
|
||||
const jobs = yield* BackgroundJob.Service
|
||||
const events = yield* EventV2.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
// Concatenate the child's final completed assistant text. Distinguishes "completed with no
|
||||
// text" (generic string) from "failed" (the run effect fails, surfaced as a job error).
|
||||
const latestAssistantText = Effect.fn("SubagentTool.latestAssistantText")(function* (sessionID: SessionSchema.ID) {
|
||||
const messages = yield* sessions.messages({ sessionID, order: "desc", limit: 20 })
|
||||
const assistant = messages.find(
|
||||
(message) =>
|
||||
message.type === "assistant" && message.time.completed !== undefined && message.error === undefined,
|
||||
)
|
||||
if (assistant === undefined || assistant.type !== "assistant") return NO_TEXT
|
||||
const text = assistant.content
|
||||
.filter((part): part is Extract<typeof part, { type: "text" }> => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("")
|
||||
return text.length > 0 ? text : NO_TEXT
|
||||
})
|
||||
|
||||
const injectCompletion = Effect.fn("SubagentTool.injectCompletion")(function* (
|
||||
parentID: SessionSchema.ID,
|
||||
childID: SessionSchema.ID,
|
||||
description: string,
|
||||
state: "completed" | "error" | "cancelled",
|
||||
text: string,
|
||||
) {
|
||||
yield* events.publish(SessionEvent.Synthetic, {
|
||||
sessionID: parentID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: yield* DateTime.now,
|
||||
text: `<subagent id="${childID}" state="${state}" description="${description}">\n${text}\n</subagent>`,
|
||||
})
|
||||
})
|
||||
|
||||
const injectWhenDone = Effect.fn("SubagentTool.injectWhenDone")(function* (
|
||||
parentID: SessionSchema.ID,
|
||||
childID: SessionSchema.ID,
|
||||
description: string,
|
||||
) {
|
||||
yield* jobs.wait({ id: childID }).pipe(
|
||||
Effect.flatMap((result) => {
|
||||
if (result.info?.status === "completed")
|
||||
return injectCompletion(parentID, childID, description, "completed", result.info.output ?? NO_TEXT)
|
||||
if (result.info?.status === "error")
|
||||
return injectCompletion(parentID, childID, description, "error", result.info.error ?? "Subagent failed")
|
||||
if (result.info?.status === "cancelled")
|
||||
return injectCompletion(parentID, childID, description, "cancelled", "Subagent cancelled")
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
})
|
||||
|
||||
yield* tools
|
||||
.register({
|
||||
[name]: Tool.make({
|
||||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const parent = yield* sessions
|
||||
.get(context.sessionID)
|
||||
.pipe(
|
||||
Effect.mapError(() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` })),
|
||||
)
|
||||
const locationLayer = locations.get(parent.location)
|
||||
const agents = yield* AgentV2.Service.pipe(Effect.provide(locationLayer))
|
||||
const runtime = yield* SessionRuntime.Service.pipe(Effect.provide(locationLayer))
|
||||
const agent = yield* agents.resolve(input.agent)
|
||||
if (agent === undefined) return yield* new ToolFailure({ message: `Unknown agent: ${input.agent}` })
|
||||
if (agent.mode === "primary")
|
||||
return yield* new ToolFailure({ message: `Agent ${input.agent} cannot run as a subagent` })
|
||||
|
||||
// Model selection is policy/config/session state, not an LLM-facing tool argument.
|
||||
const model = agent.model ?? parent.model
|
||||
const child = yield* sessions
|
||||
.create({
|
||||
parentID: context.sessionID,
|
||||
title: input.description,
|
||||
agent: AgentV2.ID.make(input.agent),
|
||||
model,
|
||||
// TODO(opencode kkdvxn): derive restricted subagent permissions from the parent
|
||||
// session (V1 deriveSubagentSessionPermission). MVP uses the agent's own permissions.
|
||||
})
|
||||
.pipe(
|
||||
Effect.mapError(() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` })),
|
||||
)
|
||||
|
||||
const background = input.background === true
|
||||
|
||||
const run = Effect.gen(function* () {
|
||||
// The child session owns its agent/model (set at create); prompt only admits input.
|
||||
yield* runtime.prompt({ sessionID: child.id, prompt: { text: input.prompt }, resume: false })
|
||||
yield* runtime.resume(child.id)
|
||||
return yield* latestAssistantText(child.id)
|
||||
})
|
||||
|
||||
const info = yield* jobs.start({
|
||||
id: child.id,
|
||||
type: name,
|
||||
title: input.description,
|
||||
metadata: {},
|
||||
onPromote: injectWhenDone(context.sessionID, child.id, input.description),
|
||||
run,
|
||||
})
|
||||
|
||||
if (background) {
|
||||
if ((yield* jobs.promote(info.id)) === undefined)
|
||||
yield* injectWhenDone(context.sessionID, child.id, input.description)
|
||||
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
|
||||
}
|
||||
|
||||
const result = yield* Effect.raceFirst(
|
||||
jobs.wait({ id: child.id }).pipe(Effect.map((waited) => waited.info)),
|
||||
jobs.waitForPromotion(child.id),
|
||||
).pipe(
|
||||
Effect.onInterrupt(() =>
|
||||
Effect.all([runtime.interrupt(child.id), jobs.cancel(child.id)], { discard: true }),
|
||||
),
|
||||
)
|
||||
if (result?.metadata?.background === true)
|
||||
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
|
||||
if (result?.status === "error")
|
||||
return yield* new ToolFailure({ message: result.error ?? "Subagent failed" })
|
||||
if (result?.status === "cancelled") return yield* new ToolFailure({ message: "Subagent cancelled" })
|
||||
return { sessionID: child.id, status: "completed" as const, output: result?.output ?? NO_TEXT }
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
||||
// Registered at the app root via ApplicationTools, not as a Location node: SessionV2 sits above
|
||||
// LocationServiceMap, so a location-scoped subagent node would create a static dependency cycle.
|
||||
// Agent lookup is resolved through the parent Session's location when the tool executes.
|
||||
export const node = makeGlobalNode({
|
||||
name: "subagent-tool",
|
||||
layer,
|
||||
deps: [ApplicationTools.node, SessionV2.node, BackgroundJob.node, EventV2.node, LocationServiceMap.node],
|
||||
})
|
||||
@@ -2,8 +2,10 @@ export * as TodoWriteTool from "./todowrite"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { SessionTodo } from "../session/todo"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
@@ -52,3 +54,9 @@ export const layer = Layer.effectDiscard(
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "tool/todowrite",
|
||||
layer,
|
||||
deps: [ToolRegistry.node, PermissionV2.node, SessionTodo.node],
|
||||
})
|
||||
|
||||
@@ -5,8 +5,11 @@ import { Duration, Effect, Layer, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Parser } from "htmlparser2"
|
||||
import TurndownService from "turndown"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { LayerNodePlatform } from "../effect/app-node-platform"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { collectBoundedResponseBody } from "./http-body"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
@@ -177,6 +180,12 @@ export const layer = Layer.effectDiscard(
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "tool/webfetch",
|
||||
layer,
|
||||
deps: [ToolRegistry.node, PermissionV2.node, LayerNodePlatform.httpClient],
|
||||
})
|
||||
|
||||
export function extractTextFromHTML(html: string) {
|
||||
let text = ""
|
||||
let skipDepth = 0
|
||||
|
||||
@@ -3,6 +3,8 @@ export * as WebSearchTool from "./websearch"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Context, Duration, Effect, Layer, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { LayerNodePlatform } from "../effect/app-node-platform"
|
||||
import { truthy } from "../flag/flag"
|
||||
import { InstallationVersion } from "../installation/version"
|
||||
import { PositiveInt } from "../schema"
|
||||
@@ -11,6 +13,7 @@ import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
import { collectBoundedResponseBody } from "./http-body"
|
||||
import { checksum } from "../util/encode"
|
||||
import { ToolRegistry } from "./registry"
|
||||
|
||||
export const name = "websearch"
|
||||
export const NO_RESULTS = "No search results found. Please try a different query."
|
||||
@@ -80,6 +83,8 @@ export const defaultConfigLayer = Layer.sync(ConfigService, () =>
|
||||
}),
|
||||
)
|
||||
|
||||
export const configNode = makeLocationNode({ service: ConfigService, layer: defaultConfigLayer, deps: [] })
|
||||
|
||||
export function selectProvider(
|
||||
sessionID: string,
|
||||
flags: Pick<Config, "enableExa" | "enableParallel"> = { enableExa: false, enableParallel: false },
|
||||
@@ -247,3 +252,9 @@ export const layer = Layer.effectDiscard(
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "tool/websearch",
|
||||
layer,
|
||||
deps: [ToolRegistry.node, PermissionV2.node, LayerNodePlatform.httpClient, configNode],
|
||||
})
|
||||
|
||||
@@ -8,9 +8,11 @@ export * as WriteTool from "./write"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
@@ -91,3 +93,9 @@ export const layer = Layer.effectDiscard(
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "tool/write",
|
||||
layer,
|
||||
deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, PermissionV2.node],
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Scope } from "effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -8,7 +9,7 @@ import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { agentHost, host } from "./plugin/host"
|
||||
|
||||
const it = testEffect(AgentV2.locationLayer)
|
||||
const it = testEffect(AppNodeBuilder.build(AgentV2.node))
|
||||
|
||||
describe("AgentV2", () => {
|
||||
it.effect("starts without agents", () =>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
@@ -9,19 +10,14 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { Tools } from "@opencode-ai/core/tool/tools"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer, Schema, Scope } from "effect"
|
||||
import { Deferred, Effect, Exit, Fiber, Schema, Scope } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const permission = Layer.mock(PermissionV2.Service, {
|
||||
assert: () => Effect.void,
|
||||
})
|
||||
const applications = ApplicationTools.layer
|
||||
const registry = ToolRegistry.layer.pipe(
|
||||
Layer.provide(permission),
|
||||
Layer.provide(applications),
|
||||
Layer.provide(ToolOutputStore.defaultLayer),
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([ApplicationTools.node, ToolRegistry.node, ToolRegistry.toolsNode]), [
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
]),
|
||||
)
|
||||
const it = testEffect(Layer.mergeAll(applications, registry))
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_application_tool")
|
||||
const agent = AgentV2.ID.make("build")
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { BackgroundJob } from "@opencode-ai/core/background-job"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Deferred, Effect, Exit, Scope } from "effect"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
const jobsLayer = LayerNode.compile(BackgroundJob.node)
|
||||
|
||||
describe("BackgroundJob", () => {
|
||||
it.live("tracks process-local work through explicit observation", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -25,7 +28,7 @@ describe("BackgroundJob", () => {
|
||||
timedOut: false,
|
||||
info: { status: "completed", output: "done" },
|
||||
})
|
||||
}).pipe(Effect.provide(BackgroundJob.layer)),
|
||||
}).pipe(Effect.provide(jobsLayer)),
|
||||
)
|
||||
|
||||
it.live("publishes jobs before starting immediately settling work", () =>
|
||||
@@ -55,7 +58,7 @@ describe("BackgroundJob", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
}).pipe(Effect.provide(BackgroundJob.layer)),
|
||||
}).pipe(Effect.provide(jobsLayer)),
|
||||
)
|
||||
|
||||
it.live("increments pending work before starting immediately settling extensions", () =>
|
||||
@@ -80,7 +83,7 @@ describe("BackgroundJob", () => {
|
||||
})
|
||||
}),
|
||||
)
|
||||
}).pipe(Effect.provide(BackgroundJob.layer)),
|
||||
}).pipe(Effect.provide(jobsLayer)),
|
||||
)
|
||||
|
||||
it.live("interrupts live work without promising settlement after the owning process-local scope closes", () =>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
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"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
@@ -21,13 +23,11 @@ const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
|
||||
)
|
||||
const it = testEffect(
|
||||
Catalog.locationLayer.pipe(
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provideMerge(locationLayer),
|
||||
Layer.provideMerge(Credential.defaultLayer),
|
||||
),
|
||||
const catalogLayer = AppNodeBuilder.build(
|
||||
LayerNode.group([Catalog.node, EventV2.node, Credential.node, Integration.node, Policy.node]),
|
||||
[[Location.node, locationLayer]],
|
||||
)
|
||||
const it = testEffect(catalogLayer)
|
||||
|
||||
describe("CatalogV2", () => {
|
||||
it.effect("publishes an updated event after catalog changes", () =>
|
||||
@@ -47,11 +47,8 @@ describe("CatalogV2", () => {
|
||||
|
||||
it.effect("derives availability from active credentials without changing provider state", () => {
|
||||
const integrationID = Integration.ID.make("test")
|
||||
const layer = Catalog.locationLayer.pipe(
|
||||
Layer.fresh,
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provideMerge(locationLayer),
|
||||
Layer.provideMerge(Credential.defaultLayer.pipe(Layer.fresh)),
|
||||
const localCatalogLayer = Layer.fresh(
|
||||
AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node]), [[Location.node, locationLayer]]),
|
||||
)
|
||||
|
||||
return Effect.gen(function* () {
|
||||
@@ -73,17 +70,16 @@ describe("CatalogV2", () => {
|
||||
})
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
|
||||
}).pipe(Effect.provide(layer))
|
||||
}).pipe(Effect.provide(localCatalogLayer))
|
||||
})
|
||||
|
||||
it.effect("derives availability from a provider's integration", () => {
|
||||
const integrationID = Integration.ID.make("gateway")
|
||||
const providerID = ProviderV2.ID.make("remote")
|
||||
const layer = Catalog.locationLayer.pipe(
|
||||
Layer.fresh,
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provideMerge(locationLayer),
|
||||
Layer.provideMerge(Credential.defaultLayer.pipe(Layer.fresh)),
|
||||
const localCatalogLayer = Layer.fresh(
|
||||
AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node, Integration.node]), [
|
||||
[Location.node, locationLayer],
|
||||
]),
|
||||
)
|
||||
|
||||
return Effect.gen(function* () {
|
||||
@@ -102,7 +98,7 @@ describe("CatalogV2", () => {
|
||||
})
|
||||
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([providerID])
|
||||
}).pipe(Effect.provide(layer))
|
||||
}).pipe(Effect.provide(localCatalogLayer))
|
||||
})
|
||||
|
||||
it.effect("projects environment connections without a catalog plugin", () =>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CommandV2 } from "@opencode-ai/core/command"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(CommandV2.locationLayer)
|
||||
const it = testEffect(AppNodeBuilder.build(CommandV2.node))
|
||||
|
||||
describe("CommandV2", () => {
|
||||
it.effect("applies command transforms and preserves later overrides", () =>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -12,7 +14,7 @@ import { tmpdir } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { agentHost, host } from "../plugin/host"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(AgentV2.locationLayer, FSUtil.defaultLayer))
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([AgentV2.node, FSUtil.node])))
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
|
||||
describe("ConfigAgentPlugin.Plugin", () => {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { CommandV2 } from "@opencode-ai/core/command"
|
||||
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"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
@@ -13,7 +15,7 @@ import { tmpdir } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { host } from "../plugin/host"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(CommandV2.locationLayer, FSUtil.defaultLayer))
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([CommandV2.node, FSUtil.node])))
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
|
||||
describe("ConfigCommandPlugin.Plugin", () => {
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Effect, Layer, Schema } from "effect"
|
||||
import { FastCheck } from "effect/testing"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigProvider } from "@opencode-ai/core/config/provider"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
|
||||
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
@@ -25,21 +27,19 @@ function testLayer(
|
||||
projectDirectory = directory,
|
||||
vcs?: Project.Vcs,
|
||||
) {
|
||||
return Config.locationLayer.pipe(
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Global.layerWith({ config: globalDirectory })),
|
||||
Layer.provide(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
{ projectDirectory: AbsolutePath.make(projectDirectory), vcs },
|
||||
),
|
||||
),
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
{ projectDirectory: AbsolutePath.make(projectDirectory), vcs },
|
||||
),
|
||||
),
|
||||
)
|
||||
return AppNodeBuilder.build(LayerNode.group([Config.node, Policy.node]), [
|
||||
[Location.node, locationLayer],
|
||||
[Global.node, Global.layerWith({ config: globalDirectory })],
|
||||
])
|
||||
}
|
||||
|
||||
const provider = {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Credential.defaultLayer)
|
||||
const it = testEffect(LayerNode.compile(Credential.node))
|
||||
|
||||
describe("Credential", () => {
|
||||
it.effect("stores, updates, lists, and removes credentials", () =>
|
||||
|
||||
@@ -15,6 +15,8 @@ import eventSourcedSessionInputMigration from "@opencode-ai/core/database/migrat
|
||||
import contextEpochAgentMigration from "@opencode-ai/core/database/migration/20260605042240_add_context_epoch_agent"
|
||||
import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera"
|
||||
import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input"
|
||||
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"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
@@ -268,7 +270,6 @@ describe("DatabaseMigration", () => {
|
||||
yield* DatabaseMigration.applyOnly(db, [simplifySessionInputMigration])
|
||||
|
||||
const database = Layer.succeed(Database.Service, { db })
|
||||
const events = EventV2.layer.pipe(Layer.provide(database))
|
||||
yield* EventV2.Service.use((service) =>
|
||||
service.publish(SessionV1.Event.Updated, {
|
||||
sessionID: SessionSchema.ID.make("session"),
|
||||
@@ -284,7 +285,7 @@ describe("DatabaseMigration", () => {
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
Layer.merge(events, SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))),
|
||||
AppNodeBuilder.build(LayerNode.group([EventV2.node, SessionProjector.node]), [[Database.node, database]]),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -56,16 +56,23 @@ const checkError: Layer.Layer<B, LayerError, never> = closedWithError
|
||||
void checkClosed
|
||||
void checkError
|
||||
|
||||
LayerNode.replace(aLayer, Layer.succeed(A, A.of({})))
|
||||
LayerNode.compile(a, [[a, Layer.succeed(A, A.of({}))]])
|
||||
LayerNode.compile(a, [[a, make({ service: A, layer: Layer.succeed(A, A.of({})), deps: [] })]])
|
||||
|
||||
// @ts-expect-error Replacement must provide A
|
||||
LayerNode.replace(aLayer, Layer.succeed(B, B.of({})))
|
||||
LayerNode.compile(a, [[a, Layer.succeed(B, B.of({}))]])
|
||||
|
||||
// @ts-expect-error Node replacement must provide A
|
||||
const invalidNodeReplacement = () => LayerNode.compile(a, [[a, b]])
|
||||
void invalidNodeReplacement
|
||||
|
||||
// @ts-expect-error Replacement cannot introduce a new error
|
||||
LayerNode.replace(aLayer, Layer.effect(A, Effect.fail(new OtherError())))
|
||||
LayerNode.compile(a, [[a, Layer.effect(A, Effect.fail(new OtherError()))]])
|
||||
|
||||
// @ts-expect-error Replacement must be closed
|
||||
LayerNode.replace(bLayer, bLayer)
|
||||
const invalidNodeErrorReplacement = () =>
|
||||
// @ts-expect-error Node replacement cannot introduce a new error
|
||||
LayerNode.compile(a, [[a, make({ service: A, layer: Layer.effect(A, Effect.fail(new OtherError())), deps: [] })]])
|
||||
void invalidNodeErrorReplacement
|
||||
|
||||
class TagA extends Context.Service<TagA, {}>()("test/TagA") {}
|
||||
class TagB extends Context.Service<TagB, {}>()("test/TagB") {}
|
||||
|
||||
@@ -13,7 +13,7 @@ class App extends Context.Service<App, { readonly run: Effect.Effect<string[]> }
|
||||
const tags = LayerNode.tags({ app: [] })
|
||||
const make = tags.make("app")
|
||||
const build = <A, E>(root: LayerNode.Node<A, E, any>, replacements?: readonly LayerNode.Replacement[]) =>
|
||||
LayerNode.compile(root, new Map(replacements?.map((item) => [item.source, item.replacement]))) as Layer.Layer<A, E>
|
||||
LayerNode.compile(root, replacements) as Layer.Layer<A, E>
|
||||
const valueLayer = Layer.succeed(Value, Value.of({ value: "production" }))
|
||||
const greetingLayer = Layer.effect(
|
||||
Greeting,
|
||||
@@ -63,21 +63,20 @@ describe("layer node", () => {
|
||||
expect(await Effect.runPromise(program)).toEqual(["first", "second"])
|
||||
})
|
||||
|
||||
test("requires unbound nodes to be bound before compilation", async () => {
|
||||
test("requires unbound nodes to be replaced before compilation", async () => {
|
||||
const unbound = LayerNode.unbound(Value, tags.values.app)
|
||||
const greeting = make({ service: Greeting, layer: greetingLayer, deps: [unbound] })
|
||||
const tree = LayerNode.group([greeting])
|
||||
expect(() => LayerNode.compile(tree)).toThrow("Unbound layer node: test/LayerNodeValue")
|
||||
const bound = LayerNode.bind(tree, unbound, value)
|
||||
const layer = LayerNode.compile(bound) as Layer.Layer<Greeting>
|
||||
const layer = LayerNode.compile(tree, [[unbound, value]]) as Layer.Layer<Greeting>
|
||||
const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(layer))
|
||||
expect(await Effect.runPromise(program)).toBe("hello production")
|
||||
})
|
||||
|
||||
test("replaces a layer by identity", async () => {
|
||||
test("replaces a node with a closed layer", async () => {
|
||||
const replacement = Layer.succeed(Value, Value.of({ value: "simulation" }))
|
||||
const program = Effect.map(Greeting, (item) => item.value).pipe(
|
||||
Effect.provide(build(LayerNode.group([greeting]), [LayerNode.replace(valueLayer, replacement)])),
|
||||
Effect.provide(build(LayerNode.group([greeting]), [[value, replacement]])),
|
||||
)
|
||||
expect(await Effect.runPromise(program)).toBe("hello simulation")
|
||||
})
|
||||
@@ -94,7 +93,7 @@ describe("layer node", () => {
|
||||
const left = make({ service: Left, layer: leftLayer, deps: [value] })
|
||||
const right = make({ service: Right, layer: rightLayer, deps: [value] })
|
||||
const replacement = Layer.succeed(Value, Value.of({ value: "replaced" }))
|
||||
const layer = build(LayerNode.group([left, right]), [LayerNode.replace(valueLayer, replacement)])
|
||||
const layer = build(LayerNode.group([left, right]), [[value, replacement]])
|
||||
const program = Effect.gen(function* () {
|
||||
return [(yield* Left).value, (yield* Right).value]
|
||||
}).pipe(Effect.provide(layer))
|
||||
@@ -103,22 +102,47 @@ describe("layer node", () => {
|
||||
|
||||
test("does not acquire an unused replacement", async () => {
|
||||
let acquisitions = 0
|
||||
const other = Layer.succeed(Value, Value.of({ value: "other" }))
|
||||
const other = make({ service: Left, layer: Layer.succeed(Left, Left.of({ value: "other" })), deps: [] })
|
||||
const replacement = Layer.effect(
|
||||
Value,
|
||||
Left,
|
||||
Effect.sync(() => {
|
||||
acquisitions++
|
||||
return Value.of({ value: "replacement" })
|
||||
return Left.of({ value: "replacement" })
|
||||
}),
|
||||
)
|
||||
await Effect.runPromise(
|
||||
Effect.map(Greeting, (item) => item.value).pipe(
|
||||
Effect.provide(build(LayerNode.group([greeting]), [LayerNode.replace(other, replacement)])),
|
||||
Effect.provide(build(LayerNode.group([greeting]), [[other, replacement]])),
|
||||
),
|
||||
)
|
||||
expect(acquisitions).toBe(0)
|
||||
})
|
||||
|
||||
test("replaces a node without acquiring its dependencies", async () => {
|
||||
let acquisitions = 0
|
||||
const dependencyLayer = Layer.effect(
|
||||
Value,
|
||||
Effect.sync(() => {
|
||||
acquisitions++
|
||||
return Value.of({ value: "dependency" })
|
||||
}),
|
||||
)
|
||||
const dependency = make({ service: Value, layer: dependencyLayer, deps: [] })
|
||||
const original = make({ service: Greeting, layer: greetingLayer, deps: [dependency] })
|
||||
const replacement = make({
|
||||
service: Greeting,
|
||||
layer: Layer.succeed(Greeting, Greeting.of({ value: "replacement" })),
|
||||
deps: [],
|
||||
})
|
||||
|
||||
const program = Effect.map(Greeting, (item) => item.value).pipe(
|
||||
Effect.provide(build(LayerNode.group([original]), [[original, replacement]])),
|
||||
)
|
||||
|
||||
expect(await Effect.runPromise(program)).toBe("replacement")
|
||||
expect(acquisitions).toBe(0)
|
||||
})
|
||||
|
||||
test("hoists and compiles tagged graphs", async () => {
|
||||
const tags = LayerNode.tags({ location: ["global"], global: [] })
|
||||
const global = tags.make("global")
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Context, Effect, Layer, LayerMap, Option } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Node } from "@opencode-ai/core/effect/app-node"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import type { LocationError, LocationServices } from "@opencode-ai/core/location-services"
|
||||
@@ -31,7 +31,7 @@ describe("node build", () => {
|
||||
expect(await Effect.runPromise(program)).toBe("plain")
|
||||
})
|
||||
|
||||
test("detects cycles through a bound location service map", () => {
|
||||
test("detects cycles through a replaced location service map", async () => {
|
||||
const a = Node.makeGlobalNode({
|
||||
service: CycleA,
|
||||
layer: Layer.effect(CycleA, Effect.as(LocationServiceMap.Service, CycleA.of({}))),
|
||||
@@ -45,26 +45,28 @@ describe("node build", () => {
|
||||
),
|
||||
deps: [a],
|
||||
})
|
||||
const mapEffect = Effect.gen(function* () {
|
||||
const service = yield* CycleB
|
||||
return yield* LayerMap.make(
|
||||
(ref: Location.Ref) =>
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of({
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
project: { id: Project.ID.global, directory: service.directory },
|
||||
}),
|
||||
),
|
||||
{ idleTimeToLive: "1 minute" },
|
||||
)
|
||||
}) as unknown as Effect.Effect<LayerMap.LayerMap<Location.Ref, LocationServices, LocationError>, never, CycleB>
|
||||
const mapLayer = Layer.effect(LocationServiceMap.Service, mapEffect)
|
||||
const mapLayer = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* CycleB
|
||||
return yield* LayerMap.make(
|
||||
(ref: Location.Ref) =>
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of({
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
project: { id: Project.ID.global, directory: service.directory },
|
||||
}),
|
||||
),
|
||||
{ idleTimeToLive: "1 minute" },
|
||||
)
|
||||
}) as unknown as Effect.Effect<LayerMap.LayerMap<Location.Ref, LocationServices, LocationError>, never, CycleB>,
|
||||
)
|
||||
const map = Node.makeGlobalNode({ service: LocationServiceMap.Service, layer: mapLayer, deps: [b] })
|
||||
const graph = LayerNode.bind(LayerNode.group([a]), LocationServiceMap.node, map)
|
||||
|
||||
expect(() => AppNodeBuilder.build(graph)).toThrow("Cycle detected in layer tree")
|
||||
expect(() => AppNodeBuilder.build(LayerNode.group([a]), [[LocationServiceMap.node, map]])).toThrow(
|
||||
"Cycle detected in layer tree",
|
||||
)
|
||||
})
|
||||
|
||||
test("shares top-level project with location services", async () => {
|
||||
@@ -81,10 +83,10 @@ describe("node build", () => {
|
||||
})
|
||||
}),
|
||||
)
|
||||
const layer = AppNodeBuilder.build(LayerNode.group([Project.node, LocationServiceMap.node]), [
|
||||
LayerNode.replace(Project.layer, projectLayer),
|
||||
])
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) })
|
||||
const layer = AppNodeBuilder.build(LayerNode.group([Project.node, LocationServiceMap.node]), [
|
||||
[Project.node, projectLayer],
|
||||
])
|
||||
const program = Effect.gen(function* () {
|
||||
yield* Project.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
||||
import { SessionV1 } from "@opencode-ai/schema/session-v1"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -76,9 +78,10 @@ const durableData = (sessionID: Session.ID, text: string) => ({
|
||||
messageID: SessionV1.MessageID.ascending(`msg_${text}`),
|
||||
})
|
||||
|
||||
const eventLayer = Layer.mergeAll(EventV2.layerWith().pipe(Layer.provide(Database.defaultLayer)), Database.defaultLayer)
|
||||
const it = testEffect(eventLayer.pipe(Layer.provideMerge(locationLayer)))
|
||||
const itWithoutLocation = testEffect(eventLayer)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, Location.node]), [[Location.node, locationLayer]]),
|
||||
)
|
||||
const itWithoutLocation = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node])))
|
||||
|
||||
describe("EventV2", () => {
|
||||
it.effect("publishes events with the current location", () =>
|
||||
@@ -462,7 +465,7 @@ describe("EventV2", () => {
|
||||
pause
|
||||
? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead)))
|
||||
: Effect.void,
|
||||
}).pipe(Layer.provide(Database.defaultLayer))
|
||||
}).pipe(Layer.provide(LayerNode.compile(Database.node)))
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
@@ -477,7 +480,7 @@ describe("EventV2", () => {
|
||||
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
|
||||
[0, durableData(aggregateID, "during handoff")],
|
||||
])
|
||||
}).pipe(Effect.provide(Layer.mergeAll(Database.defaultLayer, eventLayer)))
|
||||
}).pipe(Effect.provide(Layer.merge(LayerNode.compile(Database.node), eventLayer)))
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
@@ -11,14 +13,17 @@ import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
function provide(directory: string, filesystem = FSUtil.defaultLayer) {
|
||||
function provide(directory: string, filesystemLayer = LayerNode.compile(FSUtil.node)) {
|
||||
const activeLocation = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
||||
)
|
||||
const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
|
||||
const mutation = FileMutation.layer.pipe(Layer.provide(filesystem))
|
||||
return Effect.provide(Layer.mergeAll(resolution, mutation))
|
||||
return Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [
|
||||
[Location.node, activeLocation],
|
||||
[FSUtil.node, filesystemLayer],
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
|
||||
@@ -359,5 +364,5 @@ function instrumentWrites(run: <E>(write: Effect.Effect<void, E>, target: string
|
||||
run(filesystem.writeFileString(target, content, options), target),
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(FSUtil.defaultLayer))
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { Effect, Layer, FileSystem } from "effect"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import path from "path"
|
||||
|
||||
const live = Layer.merge(FSUtil.defaultLayer, NodeFileSystem.layer)
|
||||
const live = LayerNode.compile(LayerNode.group([FSUtil.node, LayerNodePlatform.filesystem]))
|
||||
const { effect: it } = testEffect(live)
|
||||
|
||||
describe("FSUtil", () => {
|
||||
|
||||
@@ -4,10 +4,11 @@ import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { ConfigProvider, Deferred, Duration, Effect, Fiber, Layer, Option, Stream } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
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"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { Git } from "@opencode-ai/core/git"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
@@ -18,7 +19,7 @@ const describeWatcher = Watcher.hasNativeBinding() && !process.env.CI ? describe
|
||||
|
||||
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
|
||||
|
||||
const it = testEffect(Layer.mergeAll(FSUtil.defaultLayer, EventV2.defaultLayer))
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, EventV2.node])))
|
||||
|
||||
const configLayer = Layer.succeed(
|
||||
Config.Service,
|
||||
@@ -40,10 +41,10 @@ function provide(directory: string, vcs?: Location.Interface["vcs"]) {
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
|
||||
)
|
||||
return Effect.provide(
|
||||
Watcher.layer.pipe(
|
||||
Layer.provide(configLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(locationLayer),
|
||||
AppNodeBuilder.build(Watcher.node, [
|
||||
[Config.node, configLayer],
|
||||
[Location.node, locationLayer],
|
||||
]).pipe(
|
||||
Layer.provide(flagsLayer),
|
||||
),
|
||||
)
|
||||
@@ -196,7 +197,7 @@ describeWatcher("Watcher", () => {
|
||||
yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "gone")).pipe(
|
||||
Effect.provideService(EventV2.Service, events),
|
||||
)
|
||||
}).pipe(Effect.provide(Layer.mergeAll(FSUtil.defaultLayer, EventV2.defaultLayer))),
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([FSUtil.node, EventV2.node])))),
|
||||
)
|
||||
|
||||
it.live("ignores .git/index changes", () =>
|
||||
@@ -228,10 +229,7 @@ describeWatcher("Watcher", () => {
|
||||
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
|
||||
expect(
|
||||
yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)),
|
||||
).toEqual({
|
||||
file: head,
|
||||
event: "change",
|
||||
})
|
||||
).toMatchObject({ file: head })
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Effect } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
|
||||
@@ -30,7 +30,7 @@ const testGlobal = Global.layerWith({
|
||||
log: os.tmpdir(),
|
||||
})
|
||||
|
||||
const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(FSUtil.defaultLayer))
|
||||
const testLayer = AppNodeBuilder.build(EffectFlock.node, [[Global.node, testGlobal]])
|
||||
|
||||
async function job() {
|
||||
if (msg.ready) await fs.writeFile(msg.ready, String(process.pid))
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { InstructionContext } from "@opencode-ai/core/instruction-context"
|
||||
@@ -15,6 +17,15 @@ import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
const instructionLayer = (
|
||||
input: { config: string; locationServiceLayer: Layer.Layer<Location.Service>; filesystemLayer?: Layer.Layer<FSUtil.Service> },
|
||||
) =>
|
||||
AppNodeBuilder.build(LayerNode.group([SystemContextRegistry.node, InstructionContext.node]), [
|
||||
[Global.node, Global.layerWith({ config: input.config })],
|
||||
[Location.node, input.locationServiceLayer],
|
||||
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
|
||||
])
|
||||
|
||||
describe("InstructionContext", () => {
|
||||
it.live("loads global and upward project AGENTS.md files as one aggregate context", () =>
|
||||
Effect.acquireRelease(
|
||||
@@ -41,19 +52,19 @@ describe("InstructionContext", () => {
|
||||
|
||||
const load = SystemContextRegistry.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
|
||||
Effect.provide(FSUtil.defaultLayer),
|
||||
Effect.provide(Global.layerWith({ config: global })),
|
||||
Effect.provide(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
{ projectDirectory: AbsolutePath.make(project) },
|
||||
instructionLayer({
|
||||
config: global,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
{ projectDirectory: AbsolutePath.make(project) },
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -107,14 +118,14 @@ describe("InstructionContext", () => {
|
||||
yield* Effect.promise(() => fs.writeFile(file, ""))
|
||||
const context = yield* SystemContextRegistry.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
|
||||
Effect.provide(FSUtil.defaultLayer),
|
||||
Effect.provide(Global.layerWith({ config: path.join(tmp.path, "global") })),
|
||||
Effect.provide(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
|
||||
),
|
||||
instructionLayer({
|
||||
config: path.join(tmp.path, "global"),
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -133,14 +144,18 @@ describe("InstructionContext", () => {
|
||||
FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) }),
|
||||
),
|
||||
),
|
||||
).pipe(Layer.provide(FSUtil.defaultLayer))
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
const context = yield* SystemContextRegistry.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
|
||||
Effect.provide(failingFS),
|
||||
Effect.provide(Global.layerWith({ config: "/global" })),
|
||||
Effect.provide(
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))),
|
||||
instructionLayer({
|
||||
config: "/global",
|
||||
filesystemLayer: failingFS,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -169,14 +184,18 @@ describe("InstructionContext", () => {
|
||||
}),
|
||||
),
|
||||
),
|
||||
).pipe(Layer.provide(FSUtil.defaultLayer))
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
const context = yield* SystemContextRegistry.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
|
||||
Effect.provide(racingFS),
|
||||
Effect.provide(Global.layerWith({ config: "/global" })),
|
||||
Effect.provide(
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))),
|
||||
instructionLayer({
|
||||
config: "/global",
|
||||
filesystemLayer: racingFS,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -208,20 +227,21 @@ describe("InstructionContext", () => {
|
||||
}),
|
||||
),
|
||||
),
|
||||
).pipe(Layer.provide(FSUtil.defaultLayer))
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
|
||||
yield* SystemContextRegistry.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
|
||||
Effect.provide(observingFS),
|
||||
Effect.provide(Global.layerWith({ config: "/global" })),
|
||||
Effect.provide(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: AbsolutePath.make("/repo/") }, { projectDirectory: AbsolutePath.make("/repo") }),
|
||||
instructionLayer({
|
||||
config: "/global",
|
||||
filesystemLayer: observingFS,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: AbsolutePath.make("/repo/") }, { projectDirectory: AbsolutePath.make("/repo") }),
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -241,18 +261,20 @@ describe("InstructionContext", () => {
|
||||
|
||||
yield* SystemContextRegistry.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
|
||||
Effect.provide(
|
||||
Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.pipe(
|
||||
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
|
||||
instructionLayer({
|
||||
config: "/global",
|
||||
filesystemLayer: Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.pipe(
|
||||
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))),
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
|
||||
),
|
||||
).pipe(Layer.provide(FSUtil.defaultLayer)),
|
||||
),
|
||||
Effect.provide(Global.layerWith({ config: "/global" })),
|
||||
Effect.provide(
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))),
|
||||
}),
|
||||
),
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
@@ -271,23 +293,22 @@ describe("InstructionContext", () => {
|
||||
let scanned = false
|
||||
yield* SystemContextRegistry.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
|
||||
Effect.provide(
|
||||
Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.pipe(
|
||||
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
|
||||
instructionLayer({
|
||||
config: "/global",
|
||||
filesystemLayer: Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.pipe(
|
||||
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))),
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: AbsolutePath.make("/outside") }, { projectDirectory: AbsolutePath.make("/repo") }),
|
||||
),
|
||||
),
|
||||
).pipe(Layer.provide(FSUtil.defaultLayer)),
|
||||
),
|
||||
Effect.provide(Global.layerWith({ config: "/global" })),
|
||||
Effect.provide(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: AbsolutePath.make("/outside") }, { projectDirectory: AbsolutePath.make("/repo") }),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Layer } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
@@ -13,20 +12,12 @@ import { it } from "./lib/effect"
|
||||
|
||||
const provide = (directory: string) =>
|
||||
Effect.provide(
|
||||
LayerNode.compile(
|
||||
LayerNode.bind(
|
||||
FileSystem.node,
|
||||
LayerNode.compile(FileSystem.node, [
|
||||
[
|
||||
Location.node,
|
||||
makeLocationNode({
|
||||
service: Location.Service,
|
||||
layer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
||||
),
|
||||
deps: [],
|
||||
}),
|
||||
),
|
||||
),
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
],
|
||||
]),
|
||||
)
|
||||
|
||||
const withTmp = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect, Equal, Hash, Layer, Schema } from "effect"
|
||||
import { DateTime, Effect, Equal, Hash, Schema } from "effect"
|
||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
@@ -30,25 +32,8 @@ import { Reference } from "../src/reference"
|
||||
import { ToolRegistry } from "../src/tool/registry"
|
||||
import { ApplicationTools } from "../src/tool/application-tools"
|
||||
|
||||
const applicationTools = ApplicationTools.layer
|
||||
const it = testEffect(
|
||||
Layer.merge(
|
||||
Layer.mergeAll(applicationTools, Database.defaultLayer, EventV2.defaultLayer),
|
||||
locationServiceMapLayer.pipe(
|
||||
Layer.provide(applicationTools),
|
||||
Layer.provide(
|
||||
Layer.mergeAll(
|
||||
Project.defaultLayer,
|
||||
EventV2.defaultLayer,
|
||||
Credential.defaultLayer.pipe(Layer.fresh),
|
||||
Npm.defaultLayer,
|
||||
ModelsDev.defaultLayer,
|
||||
FSUtil.defaultLayer,
|
||||
Global.defaultLayer,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
AppNodeBuilder.build(LayerNode.group([ApplicationTools.node, Database.node, EventV2.node, LocationServiceMap.node])),
|
||||
)
|
||||
|
||||
describe("LocationServiceMap", () => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
@@ -13,20 +12,12 @@ import { it } from "./lib/effect"
|
||||
|
||||
function provide(directory: string) {
|
||||
return Effect.provide(
|
||||
LayerNode.compile(
|
||||
LayerNode.bind(
|
||||
LocationMutation.node,
|
||||
LayerNode.compile(LocationMutation.node, [
|
||||
[
|
||||
Location.node,
|
||||
makeLocationNode({
|
||||
service: Location.Service,
|
||||
layer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
||||
),
|
||||
deps: [],
|
||||
}),
|
||||
),
|
||||
),
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
],
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -21,7 +22,7 @@ const projectLayer = Layer.succeed(
|
||||
commit: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
const it = testEffect(Location.layer(ref).pipe(Layer.provide(projectLayer)))
|
||||
const it = testEffect(AppNodeBuilder.build(Location.boundNode(ref), [[Project.node, projectLayer]]))
|
||||
|
||||
describe("Location", () => {
|
||||
it.effect("resolves the current project and vcs information", () =>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test"
|
||||
import { Effect, Layer, Ref } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { it } from "./lib/effect"
|
||||
import { readFile, rm, writeFile, utimes, mkdir } from "fs/promises"
|
||||
import path from "path"
|
||||
@@ -87,13 +88,13 @@ const makeMockClient = (state: Ref.Ref<MockState>) =>
|
||||
)
|
||||
|
||||
const buildLayer = (state: Ref.Ref<MockState>) =>
|
||||
// Layer.fresh is required: ModelsDev.layer is a module-level Layer constant,
|
||||
// Layer.fresh is required because the ModelsDev implementation is a module-level Layer constant,
|
||||
// and Effect.provide uses a process-global MemoMap by default — without fresh,
|
||||
// every test would reuse the cachedInvalidateWithTTL state from the first run.
|
||||
Layer.fresh(ModelsDev.layer).pipe(
|
||||
Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.fresh(
|
||||
AppNodeBuilder.build(ModelsDev.node, [
|
||||
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
|
||||
]),
|
||||
)
|
||||
|
||||
const writeCacheText = (text: string, mtimeMs?: number) =>
|
||||
|
||||
@@ -3,57 +3,34 @@ import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Git } from "@opencode-ai/core/git"
|
||||
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"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const project = Project.layer.pipe(
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(ProjectDirectories.defaultLayer),
|
||||
)
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(locationServiceMapLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(project),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
Layer.provide(SessionExecution.noopLayer),
|
||||
)
|
||||
const layer = MoveSession.layer.pipe(
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(project),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
)
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
layer,
|
||||
Database.defaultLayer,
|
||||
EventV2.defaultLayer,
|
||||
ProjectDirectories.defaultLayer,
|
||||
project,
|
||||
SessionProjector.defaultLayer,
|
||||
SessionStore.defaultLayer,
|
||||
sessions,
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
MoveSession.node,
|
||||
Database.node,
|
||||
EventV2.node,
|
||||
ProjectDirectories.node,
|
||||
Project.node,
|
||||
SessionProjector.node,
|
||||
SessionStore.node,
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Effect, Option } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
|
||||
const win = process.platform === "win32"
|
||||
@@ -21,12 +19,7 @@ const writePackage = (dir: string, pkg: Record<string, unknown>) =>
|
||||
)
|
||||
|
||||
const npmLayer = (cache: string) =>
|
||||
Npm.layer.pipe(
|
||||
Layer.provide(EffectFlock.layer),
|
||||
Layer.provide(FSUtil.layer),
|
||||
Layer.provide(Global.layerWith({ cache, state: path.join(cache, "state") })),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
)
|
||||
AppNodeBuilder.build(Npm.node, [[Global.node, Global.layerWith({ cache, state: path.join(cache, "state") })]])
|
||||
|
||||
describe("Npm.sanitize", () => {
|
||||
test("keeps normal scoped package specs unchanged", () => {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer } from "effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
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"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
@@ -11,9 +13,7 @@ import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { location } from "./fixture/location"
|
||||
@@ -23,24 +23,12 @@ const current = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
|
||||
)
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(locationServiceMapLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
Layer.provide(Project.defaultLayer),
|
||||
Layer.provide(SessionExecution.noopLayer),
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, EventV2.node, SessionStore.node, PermissionSaved.node, AgentV2.node, PermissionV2.node]),
|
||||
[[Location.node, current]],
|
||||
),
|
||||
)
|
||||
const layer = PermissionV2.locationLayer.pipe(
|
||||
Layer.provideMerge(Database.defaultLayer),
|
||||
Layer.provideMerge(SessionStore.defaultLayer),
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provideMerge(current),
|
||||
Layer.provideMerge(sessions),
|
||||
Layer.provideMerge(SessionExecution.noopLayer),
|
||||
Layer.provideMerge(PermissionSaved.defaultLayer),
|
||||
)
|
||||
const it = testEffect(layer)
|
||||
|
||||
function setup(rules: PermissionV2.Ruleset = []) {
|
||||
return Effect.gen(function* () {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { CommandV2 } from "@opencode-ai/core/command"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { CommandPlugin } from "@opencode-ai/core/plugin/command"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -10,12 +11,9 @@ import { host } from "./host"
|
||||
|
||||
const directory = AbsolutePath.make("/repo/packages/app")
|
||||
const project = AbsolutePath.make("/repo")
|
||||
const locationLayer = Layer.succeed(Location.Service, Location.Service.of(location({ directory }, { projectDirectory: project })))
|
||||
const it = testEffect(
|
||||
CommandV2.locationLayer.pipe(
|
||||
Layer.provide(
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory }, { projectDirectory: project }))),
|
||||
),
|
||||
),
|
||||
AppNodeBuilder.build(CommandV2.node, [[Location.node, locationLayer]]),
|
||||
)
|
||||
|
||||
describe("CommandPlugin.Plugin", () => {
|
||||
|
||||
@@ -1,37 +1,69 @@
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { CommandV2 } from "@opencode-ai/core/command"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { SkillV2 } from "@opencode-ai/core/skill"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { tempLocationLayer } from "../fixture/location"
|
||||
|
||||
export const PluginTestLayer = Layer.mergeAll(FileSystem.locationLayer, PluginV2.locationLayer).pipe(
|
||||
Layer.provideMerge(
|
||||
Layer.mergeAll(
|
||||
Credential.defaultLayer,
|
||||
EventV2.defaultLayer,
|
||||
FetchHttpClient.layer,
|
||||
FSUtil.defaultLayer,
|
||||
Global.defaultLayer,
|
||||
Layer.succeed(
|
||||
Npm.Service,
|
||||
Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
install: () => Effect.void,
|
||||
which: () => Effect.succeed(undefined),
|
||||
}),
|
||||
),
|
||||
RepositoryCache.defaultLayer,
|
||||
SkillDiscovery.defaultLayer,
|
||||
Ripgrep.defaultLayer,
|
||||
tempLocationLayer,
|
||||
),
|
||||
),
|
||||
type PluginTestServices =
|
||||
| FileSystem.Service
|
||||
| FSUtil.Service
|
||||
| Location.Service
|
||||
| Npm.Service
|
||||
| Credential.Service
|
||||
| EventV2.Service
|
||||
| AgentV2.Service
|
||||
| AISDK.Service
|
||||
| Catalog.Service
|
||||
| CommandV2.Service
|
||||
| Integration.Service
|
||||
| Reference.Service
|
||||
| SkillV2.Service
|
||||
| PluginV2.Service
|
||||
| HttpClient.HttpClient
|
||||
|
||||
const npmLayer = Layer.succeed(
|
||||
Npm.Service,
|
||||
Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
install: () => Effect.void,
|
||||
which: () => Effect.succeed(undefined),
|
||||
}),
|
||||
)
|
||||
|
||||
export const PluginTestLayer = AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
FileSystem.node,
|
||||
FSUtil.node,
|
||||
Location.node,
|
||||
Npm.node,
|
||||
Credential.node,
|
||||
LayerNodePlatform.httpClient,
|
||||
PluginV2.node,
|
||||
AgentV2.node,
|
||||
AISDK.node,
|
||||
Catalog.node,
|
||||
CommandV2.node,
|
||||
Integration.node,
|
||||
Reference.node,
|
||||
SkillV2.node,
|
||||
]),
|
||||
[
|
||||
[Location.node, tempLocationLayer],
|
||||
[Npm.node, npmLayer],
|
||||
],
|
||||
) as Layer.Layer<PluginTestServices>
|
||||
|
||||
@@ -3,30 +3,25 @@ import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
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"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev"
|
||||
import { Policy } from "@opencode-ai/core/policy"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { catalogHost, host, integrationHost } from "./host"
|
||||
|
||||
const events = EventV2.defaultLayer
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })),
|
||||
)
|
||||
const policy = Policy.layer.pipe(Layer.provide(locationLayer))
|
||||
const connections = Credential.defaultLayer.pipe(Layer.fresh)
|
||||
const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections))
|
||||
const catalog = Catalog.layer.pipe(
|
||||
Layer.provide(Layer.mergeAll(events, locationLayer, policy, connections, integrations)),
|
||||
)
|
||||
const layer = Layer.mergeAll(catalog.pipe(Layer.provide(connections)), integrations, connections, events, locationLayer)
|
||||
const layer = AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.node, EventV2.node]), [
|
||||
[Location.node, locationLayer],
|
||||
])
|
||||
const it = testEffect(layer)
|
||||
|
||||
describe("ModelsDevPlugin", () => {
|
||||
@@ -65,7 +60,7 @@ describe("ModelsDevPlugin", () => {
|
||||
connections: [],
|
||||
}),
|
||||
])
|
||||
}).pipe(Effect.provide(ModelsDev.defaultLayer)),
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))),
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
Flag.OPENCODE_MODELS_PATH = previous.path
|
||||
|
||||
@@ -6,6 +6,7 @@ import os from "os"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
@@ -17,7 +18,7 @@ import { PluginTestLayer } from "./fixture"
|
||||
const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
|
||||
const fixtureProviderPath = fileURLToPath(fixtureProvider)
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const itWithAISDK = testEffect(AISDK.locationLayer.pipe(Layer.provideMerge(PluginTestLayer)))
|
||||
const itWithAISDK = testEffect(Layer.mergeAll(PluginTestLayer, AppNodeBuilder.build(AISDK.node)))
|
||||
|
||||
function npmEntrypoint(entrypoint?: string) {
|
||||
return Npm.Service.of({
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { Policy } from "@opencode-ai/core/policy"
|
||||
import { VariantPlugin } from "@opencode-ai/core/plugin/variant"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -14,21 +12,11 @@ import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { catalogHost, host } from "./host"
|
||||
|
||||
const events = EventV2.defaultLayer
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })),
|
||||
)
|
||||
const connections = Credential.defaultLayer.pipe(Layer.fresh)
|
||||
const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections))
|
||||
const catalog = Catalog.layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.mergeAll(events, locationLayer, Policy.layer.pipe(Layer.provide(locationLayer)), connections, integrations),
|
||||
),
|
||||
)
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(catalog.pipe(Layer.provide(connections)), integrations, connections, events, locationLayer),
|
||||
)
|
||||
const it = testEffect(AppNodeBuilder.build(Catalog.node, [[Location.node, locationLayer]]))
|
||||
|
||||
describe("VariantPlugin", () => {
|
||||
it.effect("adds GLM 5.2 variants after catalog sources", () =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Policy } from "@opencode-ai/core/policy"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -7,11 +8,9 @@ import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
Policy.locationLayer.pipe(
|
||||
Layer.provide(
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
|
||||
),
|
||||
),
|
||||
AppNodeBuilder.build(Policy.node, [
|
||||
[Location.node, Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") })))],
|
||||
]),
|
||||
)
|
||||
|
||||
describe("Policy", () => {
|
||||
|
||||
@@ -3,9 +3,10 @@ import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { Effect, Fiber, Stream } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Git } from "@opencode-ai/core/git"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
@@ -16,15 +17,8 @@ import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const copyLayer = ProjectCopy.layer.pipe(
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(ProjectDirectories.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
)
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(copyLayer, Database.defaultLayer, EventV2.defaultLayer, ProjectDirectories.defaultLayer),
|
||||
AppNodeBuilder.build(LayerNode.group([ProjectCopy.node, Database.node, EventV2.node, ProjectDirectories.node])),
|
||||
)
|
||||
|
||||
function abs(input: string) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Cause, Deferred, Effect, Exit, Layer, Queue } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
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"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Pty } from "@opencode-ai/core/pty"
|
||||
@@ -17,11 +19,10 @@ const locationLayer = Layer.succeed(
|
||||
)
|
||||
const configLayer = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
|
||||
const it = testEffect(
|
||||
Pty.layer.pipe(
|
||||
Layer.provide(configLayer),
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provideMerge(locationLayer),
|
||||
),
|
||||
AppNodeBuilder.build(LayerNode.group([Pty.node, EventV2.node]), [
|
||||
[Config.node, configLayer],
|
||||
[Location.node, locationLayer],
|
||||
]),
|
||||
)
|
||||
const ptyTest = process.platform === "win32" ? it.live.skip : it.live
|
||||
|
||||
@@ -205,8 +206,9 @@ describe("pty", () => {
|
||||
|
||||
const configuredShell = process.platform === "win32" ? undefined : Bun.which("bash")
|
||||
const configuredIt = testEffect(
|
||||
Pty.layer.pipe(
|
||||
Layer.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Pty.node, EventV2.node]), [
|
||||
[
|
||||
Config.node,
|
||||
Layer.mock(Config.Service)({
|
||||
entries: () =>
|
||||
Effect.succeed(
|
||||
@@ -215,10 +217,9 @@ const configuredIt = testEffect(
|
||||
: [],
|
||||
),
|
||||
}),
|
||||
),
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provideMerge(locationLayer),
|
||||
),
|
||||
],
|
||||
[Location.node, locationLayer],
|
||||
]),
|
||||
)
|
||||
const configuredTest = process.platform === "win32" ? configuredIt.live.skip : configuredIt.live
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { PtyID } from "@opencode-ai/core/pty/schema"
|
||||
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
|
||||
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(PtyTicket.layer)
|
||||
const itExpiring = testEffect(Layer.effect(PtyTicket.Service, PtyTicket.make(5)))
|
||||
const it = testEffect(LayerNode.compile(PtyTicket.node))
|
||||
const itExpiring = testEffect(
|
||||
LayerNode.compile(PtyTicket.node, [[PtyTicket.node, Layer.effect(PtyTicket.Service, PtyTicket.make(5))]]),
|
||||
)
|
||||
|
||||
describe("PTY websocket tickets", () => {
|
||||
it.live("consumes tickets once", () =>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user