mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 22:26:40 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f81d771e47 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Simplify interrupt continuation: the steer-scoped resume decision now lives in SessionExecution as a post-cleanup inbox check, and the run coordinator drops its continuation state machine. Wakes arriving during cancellation cleanup now restart a normal full drain, and interrupting an idle session with continue now resumes pending steering input.
|
||||
@@ -16,8 +16,9 @@ import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const messagePageSize = 200
|
||||
const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
|
||||
const initialPageSize = 20
|
||||
const historyPageSize = 200
|
||||
const messages = Array.from({ length: initialPageSize + 1 }, (_, index) => {
|
||||
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
|
||||
return [
|
||||
userMessage(undefined, { id, created: 1700000000000 + index * 2_000 }),
|
||||
@@ -25,7 +26,7 @@ const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
|
||||
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
|
||||
parentID: id,
|
||||
created: 1700000001000 + index * 2_000,
|
||||
completed: index < messagePageSize / 2,
|
||||
completed: index < initialPageSize,
|
||||
}),
|
||||
]
|
||||
}).flat()
|
||||
@@ -159,18 +160,21 @@ for (const scenario of scenarios) {
|
||||
await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible()
|
||||
const viewport = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await viewport.hover()
|
||||
const deadline = Date.now() + 30_000
|
||||
const deadline = Date.now() + 10_000
|
||||
while (requests.filter((request) => request.phase === "start").length < 2) {
|
||||
if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary")
|
||||
await page.mouse.wheel(0, -1_200)
|
||||
await page.mouse.wheel(0, -240)
|
||||
await page.waitForTimeout(20)
|
||||
}
|
||||
expect(requests.filter((request) => request.phase === "end")).toHaveLength(1)
|
||||
expect(sequence.slice(0, 3)).toEqual([
|
||||
"messages:start:latest",
|
||||
"messages:end:latest",
|
||||
`messages:start:${messages.at(-messagePageSize)!.info.id}`,
|
||||
`messages:start:${messages.at(-initialPageSize)!.info.id}`,
|
||||
])
|
||||
await expect(page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]')).toHaveCount(
|
||||
initialPageSize / 2,
|
||||
)
|
||||
await page.evaluate(() => {
|
||||
;(
|
||||
window as Window & {
|
||||
@@ -182,12 +186,15 @@ for (const scenario of scenarios) {
|
||||
expect(await visibleContentHidden(page)).toBe(false)
|
||||
const beforeHistory = await probeSamples(page)
|
||||
history.resolve()
|
||||
await expect
|
||||
.poll(() => page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]').count())
|
||||
.toBeGreaterThan(initialPageSize / 2)
|
||||
await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2)
|
||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||
await waitForProbeSamples(page, beforeHistory)
|
||||
expect(pages).toEqual([
|
||||
{ before: undefined, limit: messagePageSize },
|
||||
{ before: messages.at(-messagePageSize)!.info.id, limit: messagePageSize },
|
||||
{ before: undefined, limit: initialPageSize },
|
||||
{ before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize },
|
||||
])
|
||||
expect(roots).toEqual([])
|
||||
|
||||
|
||||
@@ -222,7 +222,7 @@ function turn(index: number): Message[] {
|
||||
return [user, assistantMessage(targetID, index, user.info.id, parts)]
|
||||
}
|
||||
|
||||
const targetMessages = Array.from({ length: 101 }, (_, index) => turn(index)).flat()
|
||||
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
|
||||
const sourceMessages = Array.from({ length: 12 }, (_, index) => [
|
||||
userMessage(sourceID, index + 1000, 120),
|
||||
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
|
||||
|
||||
@@ -727,7 +727,7 @@ function expectCompleteScroll(
|
||||
).toEqual([])
|
||||
expect(new Set(expectedPartIDs).size).toBe(expectedPartIDs.length)
|
||||
expect(new Set(expectedMessageIDs).size).toBe(expectedMessageIDs.length)
|
||||
expect(expectedPartIDs.length).toBe(465)
|
||||
expect(expectedPartIDs.length).toBe(331)
|
||||
}
|
||||
|
||||
async function selectHomeProject(page: Page, projectName: string) {
|
||||
|
||||
@@ -618,7 +618,7 @@ describe("server session", () => {
|
||||
await ctx.store.sync("root")
|
||||
|
||||
expect(ctx.get).toEqual([{ sessionID: "root" }])
|
||||
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 200, order: "desc" }])
|
||||
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
|
||||
expect(ctx.store.data.message.root).toEqual([])
|
||||
})
|
||||
|
||||
@@ -629,32 +629,8 @@ describe("server session", () => {
|
||||
ctx.store.invalidate()
|
||||
await ctx.store.sync("root")
|
||||
|
||||
expect(ctx.store.data.message.root).toEqual([])
|
||||
expect(ctx.get).toHaveLength(2)
|
||||
expect(ctx.messages).toEqual([
|
||||
{ sessionID: "root", limit: 200, order: "desc" },
|
||||
{ sessionID: "root", limit: 200, order: "desc" },
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps a fixed page size after the local message cache exceeds the API limit", async () => {
|
||||
const client = messageClient(response(), response())
|
||||
const store = createServerSession(client)
|
||||
await store.sync("child")
|
||||
Array.from({ length: 428 }, (_, index) =>
|
||||
store.apply({
|
||||
type: "message.updated",
|
||||
properties: { info: userMessage(`message-${index}`, { time: { created: index } }) },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(store.data.message.child).toHaveLength(428)
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(client.requests).toEqual([
|
||||
{ sessionID: "child", limit: 200, order: "desc" },
|
||||
{ sessionID: "child", limit: 200, order: "desc" },
|
||||
])
|
||||
expect(ctx.messages).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("loads current session content through the current message API", async () => {
|
||||
@@ -679,7 +655,7 @@ describe("server session", () => {
|
||||
|
||||
await store.sync("root")
|
||||
|
||||
expect(requests).toEqual([{ sessionID: "root", limit: 200, order: "desc" }])
|
||||
expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
|
||||
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
|
||||
expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
|
||||
})
|
||||
@@ -755,8 +731,8 @@ describe("server session", () => {
|
||||
await store.sync("root")
|
||||
|
||||
expect(requests).toEqual([
|
||||
{ sessionID: "root", limit: 200, order: "desc" },
|
||||
{ sessionID: "root", limit: 200, cursor: "older" },
|
||||
{ sessionID: "root", limit: 20, order: "desc" },
|
||||
{ sessionID: "root", limit: 20, cursor: "older" },
|
||||
])
|
||||
expect(store.data.message.root.map((message) => message.id)).toEqual([
|
||||
user.id,
|
||||
@@ -765,26 +741,6 @@ describe("server session", () => {
|
||||
expect(assistants.map((item) => store.data.part[item.id]?.[0]?.type)).toEqual(["text", "text", "text"])
|
||||
})
|
||||
|
||||
test("loads older messages by cursor with the fixed page size", async () => {
|
||||
const older = userMessage("message-1")
|
||||
const latest = userMessage("message-2", { time: { created: 2 } })
|
||||
const client = messageClient(
|
||||
response([{ info: latest, parts: [] }], "older"),
|
||||
response([{ info: older, parts: [] }]),
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
await store.sync("child")
|
||||
|
||||
await store.history.loadMore("child")
|
||||
|
||||
expect(client.requests).toEqual([
|
||||
{ sessionID: "child", limit: 200, order: "desc" },
|
||||
{ sessionID: "child", limit: 200, cursor: "older" },
|
||||
])
|
||||
expect(store.data.message.child).toEqual([older, latest])
|
||||
expect(store.history.more("child")).toBe(false)
|
||||
})
|
||||
|
||||
// V2 messages are ordered projections and do not expose V1 assistant parent IDs.
|
||||
describe.skip("V1 assistant parent projections", () => {
|
||||
test("backfills an assistant-only initial page through its user root", async () => {
|
||||
@@ -798,7 +754,7 @@ describe("server session", () => {
|
||||
|
||||
await store.sync("child")
|
||||
|
||||
expect(client.requests).toEqual([{ sessionID: "child", limit: 200, order: "desc" }])
|
||||
expect(client.requests).toEqual([{ sessionID: "child", limit: 20, order: "desc" }])
|
||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
|
||||
expect(store.data.message.child).toEqual([user, ...assistants])
|
||||
expect(store.history.more("child")).toBe(false)
|
||||
|
||||
@@ -30,7 +30,8 @@ type MessageApi = ServerApi["message"]
|
||||
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
const messagePageSize = 200
|
||||
const initialMessagePageSize = 20
|
||||
const historyMessagePageSize = 200
|
||||
const sessionInfoLimit = 2_048
|
||||
const emptyIDs: ReadonlySet<string> = new Set()
|
||||
|
||||
@@ -241,6 +242,7 @@ export function createServerSession(
|
||||
return created
|
||||
}
|
||||
const [meta, setMeta] = createStore({
|
||||
limit: {} as Record<string, number | undefined>,
|
||||
cursor: {} as Record<string, string | undefined>,
|
||||
complete: {} as Record<string, boolean | undefined>,
|
||||
loading: {} as Record<string, boolean | undefined>,
|
||||
@@ -421,6 +423,7 @@ export function createServerSession(
|
||||
setMeta(
|
||||
produce((draft) => {
|
||||
for (const sessionID of sessionIDs) {
|
||||
delete draft.limit[sessionID]
|
||||
delete draft.cursor[sessionID]
|
||||
delete draft.complete[sessionID]
|
||||
delete draft.loading[sessionID]
|
||||
@@ -454,13 +457,11 @@ export function createServerSession(
|
||||
pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }),
|
||||
)
|
||||
|
||||
const fetchMessages = async (sessionID: string, before?: string, onAttempt?: () => void) => {
|
||||
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
|
||||
const request = (cursor?: string) =>
|
||||
(options?.retry ?? retry)(() => {
|
||||
onAttempt?.()
|
||||
return messageApi.list(
|
||||
cursor ? { sessionID, limit: messagePageSize, cursor } : { sessionID, limit: messagePageSize, order: "desc" },
|
||||
)
|
||||
return messageApi.list(cursor ? { sessionID, limit, cursor } : { sessionID, limit, order: "desc" })
|
||||
})
|
||||
const first = await request(before)
|
||||
const pages = [first]
|
||||
@@ -631,13 +632,14 @@ export function createServerSession(
|
||||
}
|
||||
orphanParts.delete(sessionID)
|
||||
}
|
||||
setMeta("limit", sessionID, messages.length)
|
||||
setMeta("cursor", sessionID, merged.cursor)
|
||||
setMeta("complete", sessionID, merged.complete)
|
||||
setMeta("at", sessionID, Date.now())
|
||||
})
|
||||
}
|
||||
|
||||
const loadMessages = async (sessionID: string, before?: string, mode?: "replace" | "prepend") => {
|
||||
const loadMessages = async (sessionID: string, limit: number, before?: string, mode?: "replace" | "prepend") => {
|
||||
if (meta.loading[sessionID]) return
|
||||
const active = generation(sessionID)
|
||||
const load: MessageLoadState = {
|
||||
@@ -656,7 +658,7 @@ export function createServerSession(
|
||||
setMeta("loading", sessionID, true)
|
||||
let applied = false
|
||||
try {
|
||||
const page = await fetchMessages(sessionID, before, () => resetMessageLoad(sessionID, load))
|
||||
const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
|
||||
const first = page.session.reduce<Message | undefined>(
|
||||
(oldest, message) => (!oldest || compareMessages(message, oldest) < 0 ? message : oldest),
|
||||
undefined,
|
||||
@@ -735,30 +737,32 @@ export function createServerSession(
|
||||
}
|
||||
}
|
||||
|
||||
const sync = (sessionID: string, options?: { force?: boolean }) => {
|
||||
const sync = (sessionID: string, options?: { force?: boolean; messageLimit?: number }) => {
|
||||
touch(sessionID)
|
||||
return runInflight(inflight, sessionID, async () => {
|
||||
const cached = data.message[sessionID] !== undefined && meta.complete[sessionID] !== undefined
|
||||
const cached = data.message[sessionID] !== undefined && meta.limit[sessionID] !== undefined
|
||||
const invalid = invalidated.has(sessionID)
|
||||
const revision = invalidationRevision
|
||||
if (cached && data.info[sessionID] && !invalid && !options?.force) return
|
||||
await Promise.all([
|
||||
resolve(sessionID, invalid ? { ...options, force: true } : options),
|
||||
cached && !invalid && !options?.force ? Promise.resolve() : loadMessages(sessionID),
|
||||
cached && !invalid && !options?.force
|
||||
? Promise.resolve()
|
||||
: loadMessages(sessionID, options?.messageLimit ?? meta.limit[sessionID] ?? initialMessagePageSize),
|
||||
])
|
||||
if (invalid && invalidationRevision === revision) invalidated.delete(sessionID)
|
||||
})
|
||||
}
|
||||
|
||||
const prefetch = async (sessionID: string, messageCount: number) => {
|
||||
const prefetch = async (sessionID: string, limit: number) => {
|
||||
touch(sessionID)
|
||||
await inflight.get(sessionID)
|
||||
if (
|
||||
Date.now() - (meta.at[sessionID] ?? 0) <= 15_000 &&
|
||||
(meta.complete[sessionID] || (data.message[sessionID]?.length ?? 0) >= messageCount)
|
||||
(meta.complete[sessionID] || (data.message[sessionID]?.length ?? 0) >= limit)
|
||||
)
|
||||
return
|
||||
await runInflight(inflight, sessionID, () => loadMessages(sessionID))
|
||||
await runInflight(inflight, sessionID, () => loadMessages(sessionID, limit))
|
||||
}
|
||||
|
||||
const eventSessionID = (event: { type: string; properties?: unknown }) => {
|
||||
@@ -1350,11 +1354,11 @@ export function createServerSession(
|
||||
setMeta("at", {})
|
||||
},
|
||||
prefetch,
|
||||
shouldPrefetch(sessionID: string, messageCount: number) {
|
||||
shouldPrefetch(sessionID: string, limit: number) {
|
||||
if (data.message[sessionID] === undefined) return true
|
||||
if (Date.now() - (meta.at[sessionID] ?? 0) > 15_000) return true
|
||||
if (meta.complete[sessionID]) return false
|
||||
return (data.message[sessionID]?.length ?? 0) <= messageCount
|
||||
return (meta.limit[sessionID] ?? 0) <= limit
|
||||
},
|
||||
fresh(sessionID: string, ttl: number) {
|
||||
return Date.now() - (meta.at[sessionID] ?? 0) <= ttl
|
||||
@@ -1436,14 +1440,14 @@ export function createServerSession(
|
||||
history: {
|
||||
more: (sessionID: string) =>
|
||||
data.message[sessionID] !== undefined &&
|
||||
meta.complete[sessionID] !== undefined &&
|
||||
meta.limit[sessionID] !== undefined &&
|
||||
!meta.complete[sessionID] &&
|
||||
!!meta.cursor[sessionID],
|
||||
loading: (sessionID: string) => meta.loading[sessionID] ?? false,
|
||||
async loadMore(sessionID: string) {
|
||||
async loadMore(sessionID: string, count = historyMessagePageSize) {
|
||||
touch(sessionID)
|
||||
if (meta.loading[sessionID] || meta.complete[sessionID] || !meta.cursor[sessionID]) return
|
||||
await loadMessages(sessionID, meta.cursor[sessionID], "prepend")
|
||||
await loadMessages(sessionID, count, meta.cursor[sessionID], "prepend")
|
||||
},
|
||||
},
|
||||
evict(sessionID: string) {
|
||||
|
||||
@@ -192,7 +192,7 @@ export type ProviderInfo = {
|
||||
id: string
|
||||
integrationID?: string
|
||||
name: string
|
||||
activation: "auto" | "enabled" | "disabled"
|
||||
disabled?: boolean
|
||||
package: string
|
||||
settings?: { [x: string]: any }
|
||||
headers?: { [x: string]: string }
|
||||
|
||||
@@ -65,8 +65,8 @@ const layer = Layer.effect(
|
||||
const integrations = yield* Integration.Service
|
||||
|
||||
const available = (provider: Provider.Info, integration: Integration.Info | undefined) => {
|
||||
if (provider.activation === "disabled") return false
|
||||
if (provider.activation === "enabled") return true
|
||||
if (provider.disabled) return false
|
||||
if (typeof provider.settings?.apiKey === "string") return true
|
||||
if (integration?.connections.length) return true
|
||||
return provider.integrationID === undefined && !integration
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ConfigAgentV1 } from "../../v1/config/agent.js"
|
||||
import { ConfigMigrateV1 } from "../../v1/config/migrate.js"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Permission } from "../../permission.js"
|
||||
import type { LocationMutation } from "../../location-mutation.js"
|
||||
import type { LocationPath } from "../../location-path.js"
|
||||
import type { ReadTool } from "../../tool/plugin/read.js"
|
||||
import type { EditTool } from "../../tool/plugin/edit.js"
|
||||
import { AbsolutePath } from "../../schema.js"
|
||||
@@ -28,7 +28,7 @@ const decodeAgent = Schema.decodeUnknownOption(ConfigAgent.Info)
|
||||
const decodeLegacyAgent = Schema.decodeUnknownOption(ConfigAgentV1.Info)
|
||||
const decodeConfig = Schema.decodeUnknownOption(Info)
|
||||
type PathAction =
|
||||
| LocationMutation.ExternalDirectoryAuthorization["action"]
|
||||
| LocationPath.ExternalDirectoryAuthorization["action"]
|
||||
| typeof ReadTool.name
|
||||
| typeof EditTool.name
|
||||
const pathActions = ["external_directory", "read", "edit"] as const satisfies readonly PathAction[]
|
||||
|
||||
@@ -40,7 +40,6 @@ export const Plugin = define({
|
||||
for (const [id, item] of configuredProviders(loaded.entries)) {
|
||||
const providerID = id
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.activation = "enabled"
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
if (item.package !== undefined) provider.package = item.package
|
||||
if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as LocationMutation from "./location-mutation.js"
|
||||
export * as LocationPath from "./location-path.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import path from "path"
|
||||
@@ -12,8 +12,8 @@ export const Kind = Schema.Literals(["file", "directory"])
|
||||
export type Kind = typeof Kind.Type
|
||||
|
||||
/**
|
||||
* Mutation paths do not accept project references. Relative paths resolve
|
||||
* from the active Location. Paths outside it require separate
|
||||
* Tool paths do not accept project references. Relative paths resolve from
|
||||
* the active Location. Paths outside its project require separate
|
||||
* `external_directory` approval.
|
||||
*/
|
||||
export const ResolveInput = Schema.Struct({
|
||||
@@ -49,13 +49,13 @@ export interface Target {
|
||||
export interface Interface {
|
||||
/**
|
||||
* Resolve a path and derive its permission resources. Relative paths resolve
|
||||
* from the Location. Paths outside it require separate `external_directory`
|
||||
* approval. This does not approve the mutation.
|
||||
* from the Location. Paths outside its project require separate
|
||||
* `external_directory` approval. This does not approve access.
|
||||
*/
|
||||
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, FSUtil.Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationMutation") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationPath") {}
|
||||
|
||||
const slash = (value: string) => value.replaceAll("\\", "/")
|
||||
|
||||
@@ -65,9 +65,13 @@ const layer = Layer.effect(
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
|
||||
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
|
||||
const resolve = Effect.fn("LocationPath.resolve")(function* (input: ResolveInput) {
|
||||
const absolute = path.resolve(location.directory, input.path)
|
||||
if (FSUtil.contains(location.directory, absolute)) {
|
||||
const projectRoot = path.parse(location.project.directory).root
|
||||
if (
|
||||
FSUtil.contains(location.directory, absolute) ||
|
||||
(location.project.directory !== projectRoot && FSUtil.contains(location.project.directory, absolute))
|
||||
) {
|
||||
return {
|
||||
absolute,
|
||||
resource: slash(path.relative(location.directory, absolute) || "."),
|
||||
@@ -19,7 +19,7 @@ import { Image } from "./image.js"
|
||||
import { LocationWatcher } from "./filesystem/location-watcher.js"
|
||||
import { Integration } from "./integration.js"
|
||||
import { Location } from "./location.js"
|
||||
import { LocationMutation } from "./location-mutation.js"
|
||||
import { LocationPath } from "./location-path.js"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { ModelResolver } from "./model-resolver.js"
|
||||
import { MCP } from "./mcp/index.js"
|
||||
@@ -76,7 +76,7 @@ const locationServiceNodes = [
|
||||
Skill.node,
|
||||
InstructionBuiltIns.node,
|
||||
InstructionDiscovery.node,
|
||||
LocationMutation.node,
|
||||
LocationPath.node,
|
||||
FileMutation.node,
|
||||
Formatter.node,
|
||||
MCP.node,
|
||||
|
||||
@@ -358,21 +358,17 @@ export const layer = Layer.effect(
|
||||
const connection = yield* integrations.connection.active(
|
||||
provider?.integrationID ?? Integration.ID.make(selected.providerID),
|
||||
)
|
||||
const credential = connection ? yield* integrations.connection.resolve(connection) : undefined
|
||||
const runtimeInfo = yield* withVariant(selected, variant)
|
||||
const model = yield* fromCatalogModel(runtimeInfo, credential, {
|
||||
loadPackage: (specifier) => Provider.loadPackage(specifier, npm),
|
||||
loadAISDK: (model) => aisdk.model(model),
|
||||
})
|
||||
const runtime =
|
||||
provider?.activation === "enabled" &&
|
||||
credential === undefined &&
|
||||
!hasConfiguredAuth(runtimeInfo) &&
|
||||
usesAPIKeyAuth(runtimeInfo.package)
|
||||
? LanguageModel.update(model, { route: model.route.with({ auth: Auth.none }) })
|
||||
: model
|
||||
const model = yield* resolveModel(
|
||||
selected,
|
||||
variant,
|
||||
connection ? yield* integrations.connection.resolve(connection) : undefined,
|
||||
{
|
||||
loadPackage: (specifier) => Provider.loadPackage(specifier, npm),
|
||||
loadAISDK: (model) => aisdk.model(model),
|
||||
},
|
||||
)
|
||||
return {
|
||||
model: runtime,
|
||||
model,
|
||||
ref: Ref.make({
|
||||
id: selected.id,
|
||||
providerID: selected.providerID,
|
||||
@@ -403,35 +399,6 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
function hasConfiguredAuth(model: Info) {
|
||||
return [model.settings?.apiKey, model.settings?.authToken, model.settings?.accessToken].some(
|
||||
(value) => typeof value === "string" && value !== "",
|
||||
)
|
||||
}
|
||||
|
||||
function usesAPIKeyAuth(packageName: string | undefined) {
|
||||
const name = Provider.packageName(packageName)
|
||||
return (
|
||||
name === "@ai-sdk/openai" ||
|
||||
name === "@ai-sdk/anthropic" ||
|
||||
name === "@ai-sdk/openai-compatible" ||
|
||||
name === "@ai-sdk/google" ||
|
||||
name === "@ai-sdk/xai" ||
|
||||
name === "@openrouter/ai-sdk-provider" ||
|
||||
name === "@ai-sdk/azure" ||
|
||||
name === "@opencode-ai/ai/providers/openai" ||
|
||||
name?.startsWith("@opencode-ai/ai/providers/openai/") === true ||
|
||||
name === "@opencode-ai/ai/providers/anthropic" ||
|
||||
name === "@opencode-ai/ai/providers/anthropic-compatible" ||
|
||||
name === "@opencode-ai/ai/providers/openai-compatible" ||
|
||||
name === "@opencode-ai/ai/providers/google" ||
|
||||
name === "@opencode-ai/ai/providers/xai" ||
|
||||
name === "@opencode-ai/ai/providers/openrouter" ||
|
||||
name === "@opencode-ai/ai/providers/azure" ||
|
||||
name?.startsWith("@opencode-ai/ai/providers/azure/") === true
|
||||
)
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
|
||||
@@ -86,7 +86,6 @@ function normalize(input: Record<string, SourceProvider>): readonly Snapshot[] {
|
||||
const info = {
|
||||
id: providerID,
|
||||
name: item.name,
|
||||
activation: "auto",
|
||||
package: Provider.aisdk(item.npm),
|
||||
...(item.api ? { settings: { baseURL: item.api } } : {}),
|
||||
} satisfies Provider.Info
|
||||
|
||||
@@ -32,7 +32,7 @@ import { InstructionDiscovery } from "../instruction-discovery.js"
|
||||
import { Integration } from "../integration.js"
|
||||
import { KV } from "../kv.js"
|
||||
import { Location } from "../location.js"
|
||||
import { LocationMutation } from "../location-mutation.js"
|
||||
import { LocationPath } from "../location-path.js"
|
||||
import { ModelsDev } from "../models-dev.js"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Permission } from "../permission.js"
|
||||
@@ -92,7 +92,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
const integration = yield* Integration.Service
|
||||
const kv = yield* KV.Service
|
||||
const location = yield* Location.Service
|
||||
const locationMutation = yield* LocationMutation.Service
|
||||
const locationMutation = yield* LocationPath.Service
|
||||
const models = yield* ModelsDev.Service
|
||||
const npm = yield* Npm.Service
|
||||
const permission = yield* Permission.Service
|
||||
@@ -129,7 +129,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
Context.make(Integration.Service, integration),
|
||||
Context.make(KV.Service, kv),
|
||||
Context.make(Location.Service, location),
|
||||
Context.make(LocationMutation.Service, locationMutation),
|
||||
Context.make(LocationPath.Service, locationMutation),
|
||||
Context.make(ModelsDev.Service, models),
|
||||
Context.make(Npm.Service, npm),
|
||||
Context.make(Permission.Service, permission),
|
||||
@@ -173,7 +173,7 @@ export const requirements = LayerNode.group([
|
||||
Integration.node,
|
||||
KV.node,
|
||||
Location.node,
|
||||
LocationMutation.node,
|
||||
LocationPath.node,
|
||||
ModelsDev.node,
|
||||
Npm.node,
|
||||
Permission.node,
|
||||
|
||||
@@ -10,7 +10,7 @@ export const LLMGatewayPlugin = define({
|
||||
const configured = new Set((yield* integrations.list()).map((integration) => integration.id))
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.activation === "disabled") continue
|
||||
if (item.provider.disabled) continue
|
||||
if (!Provider.isAISDK(item.provider.package)) continue
|
||||
if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.settings?.baseURL !== "https://api.llmgateway.io/v1") continue
|
||||
|
||||
@@ -178,10 +178,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
if (!item) return
|
||||
const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || item.provider.settings?.apiKey)
|
||||
catalog.provider.update(item.provider.id, (provider) => {
|
||||
if (!hasKey) {
|
||||
provider.activation = "enabled"
|
||||
provider.settings = { ...provider.settings, apiKey: "public" }
|
||||
}
|
||||
if (!hasKey) provider.settings = { ...provider.settings, apiKey: "public" }
|
||||
})
|
||||
if (hasKey) return
|
||||
for (const model of item.models.values()) {
|
||||
|
||||
@@ -68,7 +68,7 @@ every field, examples, config locations, and links to dedicated feature guides.
|
||||
For any request to migrate OpenCode configuration, agents, commands, skills,
|
||||
plugins, integrations, or other behavior from V1 to V2, read the full
|
||||
[migration guide](https://opencode.ai/v2/docs/migrate-v1) before acting. In
|
||||
the repository, its source is `packages/www/content/docs/migrate-v1.mdx`.
|
||||
the repository, its source is `packages/www/content/docs/(Get started)/migrate-v1.mdx`.
|
||||
|
||||
V1 config files and `.opencode/` definitions are intended to remain compatible.
|
||||
The only intentional breaking changes are the server API and plugin API. Native
|
||||
|
||||
@@ -789,10 +789,7 @@ const layer = Layer.effect(
|
||||
return false
|
||||
}),
|
||||
)
|
||||
if (recovered) {
|
||||
yield* execution.wakeActive(input.sessionID)
|
||||
return
|
||||
}
|
||||
if (recovered) return
|
||||
yield* execution.wake(input.sessionID)
|
||||
}),
|
||||
compact: Effect.fn("Session.compact")(function* (input) {
|
||||
@@ -876,7 +873,12 @@ const layer = Layer.effect(
|
||||
),
|
||||
),
|
||||
interrupt: Effect.fn("Session.interrupt")((sessionID, options) =>
|
||||
Effect.uninterruptible(execution.interrupt(sessionID, options)),
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
yield* execution.interrupt(sessionID)
|
||||
if (options?.continue && (yield* SessionInbox.has(db, sessionID, "any"))) yield* execution.wake(sessionID)
|
||||
}),
|
||||
),
|
||||
),
|
||||
revert: {
|
||||
stage: Effect.fn("Session.revert.stage")(function* (input) {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
export * as SessionExecution from "./execution.js"
|
||||
|
||||
import { Cause, Context, Effect, Exit, Layer } from "effect"
|
||||
import { Cause, Context, Effect, Exit, Layer, Stream } from "effect"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Database } from "../database/database.js"
|
||||
import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { SessionEvent } from "./event.js"
|
||||
@@ -12,7 +11,6 @@ import { SessionSchema } from "./schema.js"
|
||||
import { SessionStore } from "./store.js"
|
||||
import { toSessionError } from "./to-session-error.js"
|
||||
import { UserInterruptedError } from "./error.js"
|
||||
import { SessionInbox } from "./inbox.js"
|
||||
|
||||
export interface Interface {
|
||||
/** Snapshots active execution owned by this process. */
|
||||
@@ -21,10 +19,8 @@ export interface Interface {
|
||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
|
||||
/** Registers newly recorded work. Repeated wakeups may coalesce. */
|
||||
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
/** Wakes only an active execution, preserving its current input eligibility. */
|
||||
readonly wakeActive: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
|
||||
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<void>
|
||||
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>
|
||||
}
|
||||
@@ -49,7 +45,6 @@ export const layer = Layer.effect(
|
||||
const store = yield* SessionStore.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const bus = yield* Bus.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const reportLifecycle = <A>(sessionID: SessionSchema.ID, effect: Effect.Effect<A>) =>
|
||||
effect.pipe(
|
||||
Effect.tapCause((cause) =>
|
||||
@@ -76,13 +71,12 @@ export const layer = Layer.effect(
|
||||
sessionID: SessionSchema.ID,
|
||||
force: boolean,
|
||||
continuation?: SessionRunner.Continuation,
|
||||
promotable: SessionInbox.Promotable = "input",
|
||||
): Effect.Effect<void, SessionRunner.RunError> {
|
||||
return Effect.gen(function* () {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||
const result = yield* SessionRunner.Service.use((runner) =>
|
||||
runner.drain({ sessionID, force, continuation, promotable }),
|
||||
runner.drain({ sessionID, force, continuation }),
|
||||
).pipe(
|
||||
Effect.provide(locations.get(session.location)),
|
||||
Effect.tapCause((cause) =>
|
||||
@@ -92,7 +86,7 @@ export const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
if (result.type === "complete") return
|
||||
return yield* drain(sessionID, false, result.continuation, promotable)
|
||||
return yield* drain(sessionID, false, result.continuation)
|
||||
})
|
||||
}
|
||||
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
|
||||
@@ -101,7 +95,7 @@ export const layer = Layer.effect(
|
||||
sessionID,
|
||||
bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(sessionID)),
|
||||
),
|
||||
drain: (sessionID, force, promotable) => drain(sessionID, force, undefined, promotable),
|
||||
drain: (sessionID, force) => drain(sessionID, force),
|
||||
// One terminal observation per busy period, covering every coalesced drain.
|
||||
settled: (sessionID, exit, reason) =>
|
||||
reportLifecycle(
|
||||
@@ -133,20 +127,16 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
),
|
||||
})
|
||||
yield* bus.subscribe(SessionEvent.Moved).pipe(
|
||||
Stream.runForEach((event) => coordinator.wake(event.data.sessionID)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
active: coordinator.active,
|
||||
interrupt: (sessionID, options) =>
|
||||
Effect.gen(function* () {
|
||||
yield* coordinator.interrupt(sessionID, "user")
|
||||
if (!options?.continue) return
|
||||
// Resume only steering input from the interrupted intent. Queued next-turn work
|
||||
// stays parked: a steer-scoped drain never promotes queue-delivery rows.
|
||||
if (yield* SessionInbox.has(db, sessionID, "steer")) yield* coordinator.wake(sessionID, "steer")
|
||||
}),
|
||||
interrupt: (sessionID) => coordinator.interrupt(sessionID, "user"),
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
wakeActive: coordinator.wakeActive,
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
})
|
||||
}),
|
||||
@@ -155,7 +145,7 @@ export const layer = Layer.effect(
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node],
|
||||
deps: [SessionStore.node, LocationServiceMap.node, Bus.node],
|
||||
})
|
||||
|
||||
/** Low-level compatibility layer for callers that only need durable Session recording. */
|
||||
@@ -165,7 +155,6 @@ export const noopLayer = Layer.succeed(
|
||||
active: Effect.succeed(new Set()),
|
||||
resume: () => Effect.void,
|
||||
wake: () => Effect.void,
|
||||
wakeActive: () => Effect.void,
|
||||
interrupt: () => Effect.void,
|
||||
awaitIdle: () => Effect.void,
|
||||
}),
|
||||
|
||||
@@ -349,14 +349,6 @@ export const nextSteer = Effect.fn("SessionInbox.nextSteer")(function* (
|
||||
return row ? fromRow(row) : undefined
|
||||
})
|
||||
|
||||
export const nextPromotable = Effect.fn("SessionInbox.nextPromotable")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
promotable: Promotable,
|
||||
) {
|
||||
return (yield* nextSteer(db, sessionID)) ?? (promotable === "input" ? yield* nextQueued(db, sessionID) : undefined)
|
||||
})
|
||||
|
||||
/**
|
||||
* Which pending rows count: "any" counts every row, while "input" means any
|
||||
* item in either delivery mode.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as SessionRunCoordinator from "./run-coordinator.js"
|
||||
|
||||
import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
|
||||
import type { Promotable } from "./inbox.js"
|
||||
|
||||
/** Serializes execution for each key while allowing different keys to run concurrently. */
|
||||
export interface Coordinator<Key, E, Reason = never> {
|
||||
@@ -10,9 +9,7 @@ export interface Coordinator<Key, E, Reason = never> {
|
||||
/** Starts an execution while idle, or joins the active execution and returns its exit. */
|
||||
readonly run: (key: Key) => Effect.Effect<void, E>
|
||||
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
|
||||
readonly wake: (key: Key, scope?: Promotable) => Effect.Effect<void>
|
||||
/** Rings the current execution's doorbell with its existing scope. Idle keys remain idle. */
|
||||
readonly wakeActive: (key: Key) => Effect.Effect<void>
|
||||
readonly wake: (key: Key) => Effect.Effect<void>
|
||||
/** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */
|
||||
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void>
|
||||
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
|
||||
@@ -22,16 +19,14 @@ export interface Coordinator<Key, E, Reason = never> {
|
||||
/**
|
||||
* One execution is a busy period for one key: one fiber that drains from the first wake
|
||||
* until the key would stay idle. `pendingWake` is the doorbell: work recorded during the
|
||||
* execution rings it with the scope that work needs, and the execution loop drains again
|
||||
* instead of ending. The doorbell closes the gap between a drain's last eligibility check
|
||||
* and the idle transition, since those cannot be one atomic step. `done` resolves joiners
|
||||
* with this execution's exit.
|
||||
* execution rings it, and the execution loop drains again instead of ending. The doorbell
|
||||
* closes the gap between a drain's last eligibility check and the idle transition, since
|
||||
* those cannot be one atomic step. `done` resolves joiners with this execution's exit.
|
||||
*/
|
||||
type Execution<E, Reason> = {
|
||||
readonly done: Deferred.Deferred<void, E>
|
||||
owner?: Fiber.Fiber<void>
|
||||
scope: Promotable
|
||||
pendingWake?: Promotable
|
||||
pendingWake: boolean
|
||||
stopping: boolean
|
||||
interruptionReason?: Reason
|
||||
}
|
||||
@@ -48,7 +43,7 @@ type Execution<E, Reason> = {
|
||||
* ```
|
||||
*/
|
||||
export const make = <Key, E, Reason = never>(options: {
|
||||
readonly drain: (key: Key, force: boolean, scope: Promotable) => Effect.Effect<void, E>
|
||||
readonly drain: (key: Key, force: boolean) => Effect.Effect<void, E>
|
||||
/** Runs once when a process-local busy period begins, before its first drain. */
|
||||
readonly started?: (key: Key) => Effect.Effect<void>
|
||||
/**
|
||||
@@ -62,22 +57,21 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
||||
|
||||
const loop = (key: Key, execution: Execution<E, Reason>, force: boolean): Effect.Effect<void, E> =>
|
||||
Effect.suspend(() => options.drain(key, force, execution.scope)).pipe(
|
||||
Effect.suspend(() => options.drain(key, force)).pipe(
|
||||
Effect.flatMap(() =>
|
||||
Effect.suspend(() => {
|
||||
if (execution.stopping || execution.pendingWake === undefined) return Effect.void
|
||||
execution.scope = execution.pendingWake
|
||||
execution.pendingWake = undefined
|
||||
if (execution.stopping || !execution.pendingWake) return Effect.void
|
||||
execution.pendingWake = false
|
||||
// Trampoline so drains that complete synchronously cannot grow the stack.
|
||||
return Effect.yieldNow.pipe(Effect.andThen(loop(key, execution, false)))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const start = (key: Key, force: boolean, scope: Promotable) => {
|
||||
const start = (key: Key, force: boolean) => {
|
||||
const execution: Execution<E, Reason> = {
|
||||
done: Deferred.makeUnsafe<void, E>(),
|
||||
scope,
|
||||
pendingWake: false,
|
||||
stopping: false,
|
||||
}
|
||||
executions.set(key, execution)
|
||||
@@ -104,7 +98,7 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
// A doorbell that survives the execution loop (rung after the loop decided to end, or
|
||||
// during failure or interruption cleanup) starts a fresh execution for the remaining work.
|
||||
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
|
||||
if (execution.pendingWake) start(key, false, execution.pendingWake)
|
||||
if (execution.pendingWake) start(key, false)
|
||||
else executions.delete(key)
|
||||
Deferred.doneUnsafe(execution.done, exit)
|
||||
}
|
||||
@@ -117,24 +111,17 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
if (execution.stopping) return Deferred.await(execution.done).pipe(Effect.andThen(run(key)))
|
||||
return Deferred.await(execution.done)
|
||||
}
|
||||
return Deferred.await(start(key, true, "input").done)
|
||||
return Deferred.await(start(key, true).done)
|
||||
})
|
||||
|
||||
const wake = (key: Key, scope: Promotable = "input") =>
|
||||
const wake = (key: Key) =>
|
||||
Effect.sync(() => {
|
||||
const execution = executions.get(key)
|
||||
if (execution !== undefined) {
|
||||
// Coalesced wakes keep the widest scope: "input" subsumes "steer".
|
||||
execution.pendingWake = execution.pendingWake === "input" ? "input" : scope
|
||||
execution.pendingWake = true
|
||||
return
|
||||
}
|
||||
start(key, false, scope)
|
||||
})
|
||||
|
||||
const wakeActive = (key: Key) =>
|
||||
Effect.suspend(() => {
|
||||
const execution = executions.get(key)
|
||||
return execution ? wake(key, execution.scope) : Effect.void
|
||||
start(key, false)
|
||||
})
|
||||
|
||||
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
|
||||
@@ -142,9 +129,7 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
const execution = executions.get(key)
|
||||
if (execution?.owner === undefined || execution.stopping) return Effect.void
|
||||
execution.stopping = true
|
||||
// Wakes recorded so far belong to the interrupted intent; the interrupt claims them.
|
||||
// Wakes arriving during cleanup are new admissions and restart normally at settle.
|
||||
execution.pendingWake = undefined
|
||||
execution.pendingWake = false
|
||||
execution.interruptionReason = reason
|
||||
return Fiber.interrupt(execution.owner)
|
||||
})
|
||||
@@ -158,5 +143,5 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
return Deferred.await(execution.done).pipe(Effect.exit, Effect.andThen(awaitIdle(key)))
|
||||
})
|
||||
|
||||
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, wakeActive, interrupt, awaitIdle }
|
||||
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, interrupt, awaitIdle }
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ export * as SessionRunner from "./index.js"
|
||||
import type { AIError } from "@opencode-ai/ai"
|
||||
import { Context, Effect } from "effect"
|
||||
import { SessionSchema } from "../schema.js"
|
||||
import type { Promotable } from "../inbox.js"
|
||||
import type { AgentNotFoundError, MessageDecodeError, StepFailedError, UserInterruptedError } from "../error.js"
|
||||
import { SessionRunnerModel } from "./model.js"
|
||||
import type { Instructions } from "../../instructions/index.js"
|
||||
@@ -30,8 +29,6 @@ export interface Interface {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly force: boolean
|
||||
readonly continuation?: Continuation
|
||||
/** "steer" settles the active intent without promoting queued next-turn work. */
|
||||
readonly promotable?: Promotable
|
||||
}) => Effect.Effect<DrainResult, RunError>
|
||||
}
|
||||
|
||||
|
||||
@@ -128,25 +128,22 @@ const layer = Layer.effect(
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly force: boolean
|
||||
readonly continuation?: Continuation
|
||||
readonly promotable?: SessionInbox.Promotable
|
||||
}) {
|
||||
let force = input.force
|
||||
let continuation = input.continuation
|
||||
const promotable = input.promotable ?? "input"
|
||||
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
|
||||
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, "any")))
|
||||
return { type: "complete" as const }
|
||||
yield* settleStaleToolCalls(input.sessionID)
|
||||
while (true) {
|
||||
if (yield* runPendingCompaction(input.sessionID, promotable)) {
|
||||
if (yield* runPendingCompaction(input.sessionID)) {
|
||||
force = false
|
||||
continue
|
||||
}
|
||||
if (yield* runPendingMove(input.sessionID, promotable)) return { type: "moved" as const }
|
||||
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
|
||||
if (yield* runPendingMove(input.sessionID, "input")) return { type: "moved" as const }
|
||||
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, "input")))
|
||||
return { type: "complete" as const }
|
||||
const result = yield* runSteps(input.sessionID, continuation, promotable)
|
||||
const result = yield* runSteps(input.sessionID, continuation)
|
||||
if (result.type === "moved") return result
|
||||
if (promotable === "steer") return { type: "complete" as const }
|
||||
force = false
|
||||
continuation = undefined
|
||||
}
|
||||
@@ -158,15 +155,14 @@ const layer = Layer.effect(
|
||||
*/
|
||||
const runSteps = Effect.fn("SessionRunner.runSteps")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
continuation: Continuation | undefined,
|
||||
drainPromotable: SessionInbox.Promotable,
|
||||
continuation?: Continuation,
|
||||
) {
|
||||
// Fresh work may promote queued input; resumed turns and later steps absorb steers only.
|
||||
let promotable: SessionInbox.Promotable = continuation ? "steer" : drainPromotable
|
||||
// Fresh work may promote queued input; later steps absorb steers only.
|
||||
let promotable: SessionInbox.Promotable = continuation ? "steer" : "input"
|
||||
let step = continuation?.step ?? 1
|
||||
let next = continuation
|
||||
while (true) {
|
||||
if (yield* runPendingCompaction(sessionID, "steer")) continue
|
||||
if (yield* runPendingCompaction(sessionID)) continue
|
||||
if (yield* runPendingMove(sessionID, "steer")) return { type: "moved" as const, continuation: next }
|
||||
const result = yield* runStep(sessionID, promotable, step)
|
||||
next = result.needsContinuation ? { step: result.step + 1 } : undefined
|
||||
@@ -519,14 +515,14 @@ const layer = Layer.effect(
|
||||
/** Executes a previously admitted manual compaction request, if one is pending. */
|
||||
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
promotable: SessionInbox.Promotable,
|
||||
) {
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const pending = yield* SessionInbox.serialized(
|
||||
sessionID,
|
||||
Effect.gen(function* () {
|
||||
const selected = yield* SessionInbox.nextPromotable(db, sessionID, promotable)
|
||||
const selected =
|
||||
(yield* SessionInbox.nextSteer(db, sessionID)) ?? (yield* SessionInbox.nextQueued(db, sessionID))
|
||||
if (selected?.type !== "compaction") return
|
||||
yield* bus.publishAll([
|
||||
[SessionEvent.InboxDelivered, { sessionID, inboxID: selected.id }],
|
||||
@@ -568,7 +564,9 @@ const layer = Layer.effect(
|
||||
return yield* SessionInbox.serialized(
|
||||
sessionID,
|
||||
Effect.gen(function* () {
|
||||
const pending = yield* SessionInbox.nextPromotable(db, sessionID, promotable)
|
||||
const pending =
|
||||
(yield* SessionInbox.nextSteer(db, sessionID)) ??
|
||||
(promotable === "input" ? yield* SessionInbox.nextQueued(db, sessionID) : undefined)
|
||||
if (pending?.type !== "move") return false
|
||||
yield* modelTransport.close(sessionID)
|
||||
yield* bus.publishAll([
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Environment } from "../../environment/index.js"
|
||||
import { FileMutation } from "../../file-mutation.js"
|
||||
import { Formatter } from "../../formatter.js"
|
||||
import { Location } from "../../location.js"
|
||||
import { LocationMutation } from "../../location-mutation.js"
|
||||
import { LocationPath } from "../../location-path.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { fileDiff } from "./file-diff.js"
|
||||
|
||||
@@ -110,7 +110,7 @@ const findLineOccurrences = (content: string, search: string) => {
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.edit",
|
||||
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const mutation = yield* LocationPath.Service
|
||||
const fileMutation = yield* FileMutation.Service
|
||||
const environment = yield* Environment.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
@@ -148,7 +148,7 @@ export const Plugin = {
|
||||
const external = target.externalDirectory
|
||||
if (external) {
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
...LocationPath.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: permissionSource,
|
||||
|
||||
@@ -7,7 +7,7 @@ import path from "path"
|
||||
import { Environment } from "../../environment/index.js"
|
||||
import { FileSystem } from "../../filesystem.js"
|
||||
import { Location } from "../../location.js"
|
||||
import { LocationMutation } from "../../location-mutation.js"
|
||||
import { LocationPath } from "../../location-path.js"
|
||||
import { Ripgrep } from "../../ripgrep.js"
|
||||
import { RelativePath } from "../../schema.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
@@ -45,7 +45,7 @@ export const Plugin = {
|
||||
const environment = yield* Environment.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const location = yield* Location.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const mutation = yield* LocationPath.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
yield* ctx.tool
|
||||
@@ -64,7 +64,7 @@ export const Plugin = {
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
...LocationPath.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
|
||||
@@ -7,7 +7,7 @@ import path from "path"
|
||||
import { Environment } from "../../environment/index.js"
|
||||
import { FileSystem } from "../../filesystem.js"
|
||||
import { Location } from "../../location.js"
|
||||
import { LocationMutation } from "../../location-mutation.js"
|
||||
import { LocationPath } from "../../location-path.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { Ripgrep } from "../../ripgrep.js"
|
||||
import { RelativePath } from "../../schema.js"
|
||||
@@ -61,7 +61,7 @@ export const Plugin = {
|
||||
const environment = yield* Environment.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const location = yield* Location.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const mutation = yield* LocationPath.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
yield* ctx.tool
|
||||
@@ -79,7 +79,7 @@ export const Plugin = {
|
||||
const target = yield* mutation.resolve({ path: input.path ?? "." })
|
||||
if (target.externalDirectory)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
|
||||
...LocationPath.externalDirectoryPermission(target.externalDirectory),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Environment } from "../../environment/index.js"
|
||||
import { Formatter } from "../../formatter.js"
|
||||
import { FileMutation } from "../../file-mutation.js"
|
||||
import { Location } from "../../location.js"
|
||||
import { LocationMutation } from "../../location-mutation.js"
|
||||
import { LocationPath } from "../../location-path.js"
|
||||
import { Patch } from "@opencode-ai/util/patch"
|
||||
import { Permission } from "../../permission.js"
|
||||
import DESCRIPTION from "../patch.txt"
|
||||
@@ -46,29 +46,29 @@ export const toModelOutput = (output: Output) =>
|
||||
|
||||
type Prepared =
|
||||
| (Extract<Patch.Hunk, { readonly type: "add" }> & {
|
||||
readonly target: LocationMutation.Target
|
||||
readonly target: LocationPath.Target
|
||||
readonly content: string
|
||||
readonly before: string
|
||||
readonly after: string
|
||||
})
|
||||
| (Extract<Patch.Hunk, { readonly type: "delete" }> & {
|
||||
readonly target: LocationMutation.Target
|
||||
readonly target: LocationPath.Target
|
||||
readonly before: string
|
||||
readonly after: string
|
||||
})
|
||||
| (Extract<Patch.Hunk, { readonly type: "update" }> & {
|
||||
readonly target: LocationMutation.Target
|
||||
readonly target: LocationPath.Target
|
||||
readonly content: string
|
||||
readonly before: string
|
||||
readonly after: string
|
||||
readonly moveTarget?: LocationMutation.Target
|
||||
readonly moveTarget?: LocationPath.Target
|
||||
})
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.patch",
|
||||
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const environment = yield* Environment.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const mutation = yield* LocationPath.Service
|
||||
const fileMutation = yield* FileMutation.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const location = yield* Location.Service
|
||||
@@ -116,7 +116,7 @@ export const Plugin = {
|
||||
const target = yield* mutation.resolve({ path: value, kind: "file" })
|
||||
if (!target.externalDirectory) return target
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
|
||||
...LocationPath.externalDirectoryPermission(target.externalDirectory),
|
||||
metadata: {
|
||||
filepath: target.absolute,
|
||||
parentDir: target.externalDirectory.directory,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "../../location.js"
|
||||
import { LocationMutation } from "../../location-mutation.js"
|
||||
import { LocationPath } from "../../location-path.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { SessionInstructions } from "../../session/instructions.js"
|
||||
import { AbsolutePath } from "../../schema.js"
|
||||
@@ -32,7 +32,7 @@ export const Plugin = {
|
||||
id: "opencode.tool.read",
|
||||
effect: Effect.fn("ReadTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const reader = yield* ReadToolFileSystem.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const mutation = yield* LocationPath.Service
|
||||
const permission = yield* Permission.Service
|
||||
const sessionInstructions = yield* SessionInstructions.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
@@ -58,7 +58,7 @@ export const Plugin = {
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
...LocationPath.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
|
||||
import { Deferred, Effect, Schema, Scope } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { Environment } from "../../environment/index.js"
|
||||
import { LocationMutation } from "../../location-mutation.js"
|
||||
import { LocationPath } from "../../location-path.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { PluginRuntime } from "../../plugin/runtime.js"
|
||||
import { NonNegativeInt } from "../../schema.js"
|
||||
@@ -84,7 +84,7 @@ export const Plugin = {
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const environment = yield* Environment.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const mutation = yield* LocationPath.Service
|
||||
const shell = yield* Shell.Service
|
||||
const permission = yield* Permission.Service
|
||||
const config = yield* Config.Service
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Bom } from "@opencode-ai/util/bom"
|
||||
import { Environment } from "../../environment/index.js"
|
||||
import { FileMutation } from "../../file-mutation.js"
|
||||
import { Formatter } from "../../formatter.js"
|
||||
import { LocationMutation } from "../../location-mutation.js"
|
||||
import { LocationPath } from "../../location-path.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { fileDiff } from "./file-diff.js"
|
||||
|
||||
@@ -46,7 +46,7 @@ export const toModelOutput = (output: Output) =>
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.write",
|
||||
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const mutation = yield* LocationPath.Service
|
||||
const fileMutation = yield* FileMutation.Service
|
||||
const environment = yield* Environment.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
@@ -72,7 +72,7 @@ export const Plugin = {
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
...LocationPath.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
|
||||
@@ -102,35 +102,6 @@ describe("Catalog", () => {
|
||||
}).pipe(Effect.provide(localCatalogLayer))
|
||||
})
|
||||
|
||||
it.effect("makes an explicitly enabled provider available without a connection", () => {
|
||||
const integrationID = Integration.ID.make("gateway")
|
||||
const providerID = Provider.ID.make("remote")
|
||||
const localCatalogLayer = Layer.fresh(
|
||||
AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node, Integration.node]), [
|
||||
[Location.node, locationLayer],
|
||||
]),
|
||||
)
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* (yield* Integration.Service).transform((editor) => editor.update(integrationID, () => {}))
|
||||
yield* catalog.transform((editor) =>
|
||||
editor.provider.update(providerID, (provider) => {
|
||||
provider.integrationID = integrationID
|
||||
provider.settings = { baseURL: "https://gateway.example.com/v1" }
|
||||
}),
|
||||
)
|
||||
expect(yield* catalog.provider.available()).toEqual([])
|
||||
|
||||
yield* catalog.transform((editor) =>
|
||||
editor.provider.update(providerID, (provider) => {
|
||||
provider.activation = "enabled"
|
||||
}),
|
||||
)
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([providerID])
|
||||
}).pipe(Effect.provide(localCatalogLayer))
|
||||
})
|
||||
|
||||
it.effect("projects environment connections without a catalog plugin", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
@@ -307,7 +278,7 @@ describe("Catalog", () => {
|
||||
const fallbackModel = Model.ID.make("fallback")
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(disabledProvider, (provider) => {
|
||||
provider.activation = "disabled"
|
||||
provider.disabled = true
|
||||
})
|
||||
catalog.model.update(disabledProvider, disabledModel, () => {})
|
||||
catalog.provider.update(enabledProvider, () => {})
|
||||
|
||||
@@ -342,7 +342,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
names: ["CUSTOM_API_KEY"],
|
||||
})
|
||||
expect((yield* integrations.get(Integration.ID.make("custom")))?.name).toBe("Renamed")
|
||||
expect(provider.activation).toBe("enabled")
|
||||
expect(provider.disabled).toBeUndefined()
|
||||
expect(provider.package).toBe("aisdk:custom-sdk")
|
||||
expect(provider.settings).toEqual({ baseURL: "https://example.test" })
|
||||
expect(provider.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
|
||||
@@ -7,7 +7,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { LocationPath } from "@opencode-ai/core/location-path"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { type EnvironmentFilesTransform, transformEnvironmentFiles } from "./fixture/environment"
|
||||
import { location } from "./fixture/location"
|
||||
@@ -20,7 +20,7 @@ function provide(directory: string, transformFiles: EnvironmentFilesTransform =
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
||||
)
|
||||
return Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [
|
||||
AppNodeBuilder.build(LayerNode.group([LocationPath.node, FileMutation.node]), [
|
||||
[Location.node, activeLocation],
|
||||
[Environment.node, transformEnvironmentFiles(activeLocation, transformFiles)],
|
||||
]),
|
||||
@@ -40,7 +40,7 @@ describe("FileMutation", () => {
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "hello.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "before"))
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
|
||||
const target = yield* (yield* LocationPath.Service).resolve({ path: "hello.txt" })
|
||||
|
||||
expect(yield* (yield* FileMutation.Service).write({ target, content: "after" })).toEqual({
|
||||
operation: "write",
|
||||
@@ -56,7 +56,7 @@ describe("FileMutation", () => {
|
||||
it.live("writes a prospective internal file and creates parent directories", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({
|
||||
const target = yield* (yield* LocationPath.Service).resolve({
|
||||
path: path.join("src", "nested", "hello.txt"),
|
||||
})
|
||||
const result = yield* (yield* FileMutation.Service).write({ target, content: "hello" })
|
||||
@@ -77,8 +77,8 @@ describe("FileMutation", () => {
|
||||
Effect.gen(function* () {
|
||||
const preservedPath = path.join(directory, "preserved.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(preservedPath, "\uFEFFbefore"))
|
||||
const preserved = yield* (yield* LocationMutation.Service).resolve({ path: "preserved.txt" })
|
||||
const created = yield* (yield* LocationMutation.Service).resolve({ path: "created.txt" })
|
||||
const preserved = yield* (yield* LocationPath.Service).resolve({ path: "preserved.txt" })
|
||||
const created = yield* (yield* LocationPath.Service).resolve({ path: "created.txt" })
|
||||
const files = yield* FileMutation.Service
|
||||
|
||||
yield* files.writeTextPreservingBom({ target: preserved, content: "\uFEFFafter" })
|
||||
@@ -95,7 +95,7 @@ describe("FileMutation", () => {
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "external.txt")
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const target = yield* (yield* LocationPath.Service).resolve({ path: targetPath })
|
||||
const result = yield* (yield* FileMutation.Service).write({ target, content: "external" })
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -133,7 +133,7 @@ describe("FileMutation", () => {
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const mutation = yield* LocationPath.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const firstPlan = yield* mutation.resolve({ path: "shared.txt" })
|
||||
const secondPlan = yield* mutation.resolve({ path: "shared.txt" })
|
||||
@@ -222,7 +222,7 @@ describe("FileMutation", () => {
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const mutation = yield* LocationPath.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const firstPlan = yield* mutation.resolve({ path: "first.txt" })
|
||||
const secondPlan = yield* mutation.resolve({ path: "second.txt" })
|
||||
|
||||
+59
-18
@@ -4,18 +4,26 @@ import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { LocationPath } from "@opencode-ai/core/location-path"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { location } from "./fixture/location"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
function provide(directory: string) {
|
||||
function provide(directory: string, projectDirectory = directory) {
|
||||
return Effect.provide(
|
||||
LayerNode.compile(LocationMutation.node, [
|
||||
LayerNode.compile(LocationPath.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
{ projectDirectory: AbsolutePath.make(projectDirectory) },
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
]),
|
||||
)
|
||||
@@ -28,13 +36,13 @@ function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
|
||||
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
|
||||
}
|
||||
|
||||
describe("LocationMutation", () => {
|
||||
describe("LocationPath", () => {
|
||||
it.live("resolves an active relative existing file target", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "hello.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "hello"))
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
|
||||
const target = yield* (yield* LocationPath.Service).resolve({ path: "hello.txt" })
|
||||
|
||||
expect(target).toMatchObject({
|
||||
absolute: targetPath,
|
||||
@@ -49,7 +57,7 @@ describe("LocationMutation", () => {
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "new.txt") })
|
||||
const target = yield* (yield* LocationPath.Service).resolve({ path: path.join("src", "new.txt") })
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.join(directory, "src", "new.txt"),
|
||||
resource: "src/new.txt",
|
||||
@@ -58,10 +66,43 @@ describe("LocationMutation", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not require external authorization inside the project but outside the active directory", () =>
|
||||
withTmp((project) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = path.join(project, "packages", "app")
|
||||
const targetPath = path.join(project, "README.md")
|
||||
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "hello"))
|
||||
|
||||
const locationPath = yield* LocationPath.Service
|
||||
const target = yield* locationPath.resolve({ path: targetPath })
|
||||
|
||||
expect(target).toMatchObject({
|
||||
absolute: targetPath,
|
||||
resource: "../../README.md",
|
||||
})
|
||||
expect(target.externalDirectory).toBeUndefined()
|
||||
}).pipe(provide(path.join(project, "packages", "app"), project)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not treat a filesystem-root project fallback as internal", () =>
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
const locationPath = yield* LocationPath.Service
|
||||
const target = yield* locationPath.resolve({ path: path.join(outside, "target.txt") })
|
||||
|
||||
expect(target.externalDirectory).toBeDefined()
|
||||
}).pipe(provide(directory, path.parse(directory).root)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("requires external-directory authorization for a relative lexical escape", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "../outside.txt" })
|
||||
const target = yield* (yield* LocationPath.Service).resolve({ path: "../outside.txt" })
|
||||
const root = path.dirname(directory)
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.join(root, "outside.txt"),
|
||||
@@ -84,7 +125,7 @@ describe("LocationMutation", () => {
|
||||
await fs.mkdir(outside)
|
||||
await fs.symlink(outside, path.join(directory, "escape"))
|
||||
})
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") })
|
||||
const target = yield* (yield* LocationPath.Service).resolve({ path: path.join("escape", "new.txt") })
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.join(directory, "escape", "new.txt"),
|
||||
resource: "escape/new.txt",
|
||||
@@ -104,7 +145,7 @@ describe("LocationMutation", () => {
|
||||
await fs.symlink(path.join(directory, "actual"), path.join(directory, "linked"))
|
||||
})
|
||||
|
||||
expect(yield* (yield* LocationMutation.Service).resolve({ path: "linked/new.txt" })).toMatchObject({
|
||||
expect(yield* (yield* LocationPath.Service).resolve({ path: "linked/new.txt" })).toMatchObject({
|
||||
absolute: path.join(directory, "linked", "new.txt"),
|
||||
resource: "linked/new.txt",
|
||||
})
|
||||
@@ -116,7 +157,7 @@ describe("LocationMutation", () => {
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "new.txt")
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const target = yield* (yield* LocationPath.Service).resolve({ path: targetPath })
|
||||
expect(target).toMatchObject({
|
||||
absolute: targetPath,
|
||||
resource: "new.txt",
|
||||
@@ -131,7 +172,7 @@ describe("LocationMutation", () => {
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "new.txt")
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const target = yield* (yield* LocationPath.Service).resolve({ path: targetPath })
|
||||
const root = outside
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.join(root, "new.txt"),
|
||||
@@ -152,7 +193,7 @@ describe("LocationMutation", () => {
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "existing.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const target = yield* (yield* LocationPath.Service).resolve({ path: targetPath })
|
||||
expect(target).toMatchObject({ absolute: targetPath })
|
||||
expect(target.externalDirectory?.directory).toBe(outside)
|
||||
}).pipe(provide(directory)),
|
||||
@@ -164,7 +205,7 @@ describe("LocationMutation", () => {
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: outside, kind: "file" })
|
||||
const target = yield* (yield* LocationPath.Service).resolve({ path: outside, kind: "file" })
|
||||
expect(target.externalDirectory).toMatchObject({
|
||||
directory: path.dirname(outside),
|
||||
resource: path.join(path.dirname(outside), "*").replaceAll("\\", "/"),
|
||||
@@ -179,7 +220,7 @@ describe("LocationMutation", () => {
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "new", "nested", "file.txt")
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const target = yield* (yield* LocationPath.Service).resolve({ path: targetPath })
|
||||
const parent = path.dirname(targetPath)
|
||||
expect(target.externalDirectory).toMatchObject({
|
||||
directory: parent,
|
||||
@@ -190,9 +231,9 @@ describe("LocationMutation", () => {
|
||||
),
|
||||
)
|
||||
|
||||
test("ignores unknown mutation input fields", () => {
|
||||
expect(Object.keys(LocationMutation.ResolveInput.fields)).toEqual(["path", "kind"])
|
||||
expect(Schema.decodeUnknownSync(LocationMutation.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({
|
||||
test("ignores unknown path input fields", () => {
|
||||
expect(Object.keys(LocationPath.ResolveInput.fields)).toEqual(["path", "kind"])
|
||||
expect(Schema.decodeUnknownSync(LocationPath.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({
|
||||
path: "README.md",
|
||||
})
|
||||
})
|
||||
@@ -2,16 +2,13 @@ import { describe, expect } from "bun:test"
|
||||
import { LLM, LanguageModel } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import { compileRequest } from "@opencode-ai/ai/route/client"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Compatibility, ID, Info, VariantID } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { ModelResolver } from "@opencode-ai/core/model-resolver"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
interface ModelOptions {
|
||||
@@ -272,109 +269,6 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses no native API-key auth for an explicitly enabled provider without credentials", () => {
|
||||
const selected = model(Provider.aisdk("@ai-sdk/google"), {
|
||||
providerID: Provider.ID.make("gateway"),
|
||||
settings: { baseURL: "https://gateway.example.com/v1" },
|
||||
headers: { "cf-access-token": "access-token" },
|
||||
})
|
||||
const provider = Provider.Info.make({
|
||||
...Provider.Info.empty(selected.providerID),
|
||||
activation: "enabled",
|
||||
package: selected.package ?? "",
|
||||
settings: selected.settings,
|
||||
headers: selected.headers,
|
||||
})
|
||||
const catalog = Layer.mock(Catalog.Service, {
|
||||
provider: {
|
||||
get: () => Effect.succeed(provider),
|
||||
all: () => Effect.die("unused"),
|
||||
available: () => Effect.die("unused"),
|
||||
},
|
||||
model: {
|
||||
get: () => Effect.succeed(selected),
|
||||
all: () => Effect.die("unused"),
|
||||
available: () => Effect.die("unused"),
|
||||
default: () => Effect.die("unused"),
|
||||
small: () => Effect.die("unused"),
|
||||
},
|
||||
})
|
||||
const integrations = Layer.mock(Integration.Service, {
|
||||
connection: {
|
||||
active: () => Effect.succeed(undefined),
|
||||
resolve: () => Effect.die("unused"),
|
||||
key: () => Effect.die("unused"),
|
||||
update: () => Effect.die("unused"),
|
||||
remove: () => Effect.die("unused"),
|
||||
},
|
||||
oauth: {
|
||||
connect: () => Effect.die("unused"),
|
||||
status: () => Effect.die("unused"),
|
||||
complete: () => Effect.die("unused"),
|
||||
cancel: () => Effect.die("unused"),
|
||||
},
|
||||
command: {
|
||||
connect: () => Effect.die("unused"),
|
||||
status: () => Effect.die("unused"),
|
||||
cancel: () => Effect.die("unused"),
|
||||
},
|
||||
})
|
||||
const npm = Layer.mock(Npm.Service, {
|
||||
add: () => Effect.die("unused"),
|
||||
which: () => Effect.die("unused"),
|
||||
})
|
||||
const aisdk = Layer.mock(AISDK.Service, {
|
||||
hook: {
|
||||
sdk: () => Effect.die("unused"),
|
||||
language: () => Effect.die("unused"),
|
||||
},
|
||||
model: () => Effect.die("unused"),
|
||||
})
|
||||
const layer = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)))
|
||||
|
||||
return withEnv({ GOOGLE_GENERATIVE_AI_API_KEY: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const resolver = yield* ModelResolver.Service
|
||||
const resolved = yield* resolver.resolveModel(selected)
|
||||
|
||||
const headers = yield* resolved.model.route.auth.apply({
|
||||
request: LLM.request({ model: resolved.model, prompt: "Hello" }),
|
||||
method: "POST",
|
||||
url: "https://gateway.example.com/v1",
|
||||
body: "{}",
|
||||
headers: Headers.fromInput(resolved.model.route.defaults.headers),
|
||||
})
|
||||
|
||||
expect(headers["cf-access-token"]).toBe("access-token")
|
||||
expect(headers.authorization).toBeUndefined()
|
||||
expect(headers["x-goog-api-key"]).toBeUndefined()
|
||||
}).pipe(Effect.provide(layer)),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("keeps native provider environment auth strict when no API key is configured", () =>
|
||||
withEnv({ GOOGLE_GENERATIVE_AI_API_KEY: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/google"), {
|
||||
settings: { baseURL: "https://google.example.com/v1" },
|
||||
}),
|
||||
)
|
||||
const exit = yield* Effect.exit(
|
||||
resolved.route.auth.apply({
|
||||
request: LLM.request({ model: resolved, prompt: "Hello" }),
|
||||
method: "POST",
|
||||
url: "https://google.example.com/v1",
|
||||
body: "{}",
|
||||
headers: Headers.empty,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(exit._tag).toBe("Failure")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
|
||||
@@ -47,7 +47,6 @@ const fixtureSnapshot = [
|
||||
info: {
|
||||
id: Provider.ID.make("acme"),
|
||||
name: "Acme",
|
||||
activation: "auto",
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
},
|
||||
models: [
|
||||
@@ -110,7 +109,6 @@ const fixture2Snapshot = [
|
||||
info: {
|
||||
id: Provider.ID.make("beta"),
|
||||
name: "Beta",
|
||||
activation: "auto",
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
},
|
||||
models: [
|
||||
|
||||
@@ -64,7 +64,6 @@ describe("ModelsDevPlugin", () => {
|
||||
info: {
|
||||
id: providerID,
|
||||
name: "Acme",
|
||||
activation: "auto",
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
settings: { baseURL: "https://api.acme.test/v1" },
|
||||
},
|
||||
@@ -240,7 +239,6 @@ describe("ModelsDevPlugin", () => {
|
||||
info: {
|
||||
id: providerID,
|
||||
name: "Acme",
|
||||
activation: "auto",
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
},
|
||||
environment: [],
|
||||
@@ -332,7 +330,6 @@ describe("ModelsDevPlugin", () => {
|
||||
info: {
|
||||
id: providerID,
|
||||
name: "Acme",
|
||||
activation: "auto",
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
settings: { baseURL: "https://${ACME_HOST}/${UNDECLARED_HOST}/v1" },
|
||||
},
|
||||
@@ -388,7 +385,6 @@ describe("ModelsDevPlugin", () => {
|
||||
info: {
|
||||
id: Provider.ID.make(id),
|
||||
name,
|
||||
activation: "auto",
|
||||
package: Provider.aisdk(packageName),
|
||||
},
|
||||
environment: id === "azure" ? ["AZURE_RESOURCE_NAME", environment] : [environment],
|
||||
|
||||
@@ -60,14 +60,14 @@ describe("LLMGatewayPlugin", () => {
|
||||
})
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(Provider.ID.make("llmgateway"), (provider) => {
|
||||
provider.activation = "disabled"
|
||||
provider.disabled = true
|
||||
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = { baseURL: "https://api.llmgateway.io/v1" }
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
expect((yield* catalog.provider.get(Provider.ID.make("llmgateway")))?.activation).toBe("disabled")
|
||||
expect((yield* catalog.provider.get(Provider.ID.make("llmgateway")))?.disabled).toBe(true)
|
||||
expect((yield* catalog.provider.get(Provider.ID.make("llmgateway")))?.headers).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -347,8 +347,6 @@ describe("OpencodePlugin", () => {
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.opencode)).settings?.apiKey).toBe("public")
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.opencode)).activation).toBe("enabled")
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(Provider.ID.opencode)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.opencode, Model.ID.make("free"))).enabled).toBe(true)
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -14,10 +14,8 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||
import { UserInterruptedError } from "@opencode-ai/core/session/error"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionInbox } from "@opencode-ai/core/session/inbox"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionRunner } from "@opencode-ai/core/session/runner/index"
|
||||
import { SessionInboxTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { Context, Deferred, Effect, Exit, Fiber, Layer, LayerMap, Scope } from "effect"
|
||||
import { eq } from "drizzle-orm"
|
||||
@@ -292,113 +290,6 @@ describe("SessionExecution lifecycle", () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe("SessionExecution interrupt continuation", () => {
|
||||
it.effect("resumes only steering input after an interrupt with continue", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const sessionID = Session.ID.make("ses_continue_steer")
|
||||
yield* seedSessions(database, [sessionID])
|
||||
yield* seedInbox(database, sessionID, ["steer", "queue"])
|
||||
|
||||
const draining = yield* Deferred.make<void>()
|
||||
const drains: Array<{ force: boolean; promotable?: SessionInbox.Promotable }> = []
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
const context = yield* buildExecution(scope, (input) =>
|
||||
Effect.suspend(() => {
|
||||
drains.push({ force: input.force, promotable: input.promotable })
|
||||
if (drains.length > 1) return Effect.void
|
||||
return Deferred.succeed(draining, undefined).pipe(Effect.andThen(Effect.never))
|
||||
}),
|
||||
)
|
||||
const execution = Context.get(context, SessionExecution.Service)
|
||||
yield* execution.resume(sessionID).pipe(Effect.forkScoped)
|
||||
yield* Deferred.await(draining)
|
||||
|
||||
yield* execution.interrupt(sessionID, { continue: true })
|
||||
yield* execution.awaitIdle(sessionID)
|
||||
|
||||
// The successor drain is steer-scoped: queued next-turn work stays parked.
|
||||
expect(drains).toEqual([
|
||||
{ force: true, promotable: "input" },
|
||||
{ force: false, promotable: "steer" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("stays parked after an interrupt with continue when only queued work remains", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const sessionID = Session.ID.make("ses_continue_parked")
|
||||
yield* seedSessions(database, [sessionID])
|
||||
yield* seedInbox(database, sessionID, ["queue"])
|
||||
|
||||
const draining = yield* Deferred.make<void>()
|
||||
const drains: Array<SessionInbox.Promotable | undefined> = []
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
const context = yield* buildExecution(scope, (input) =>
|
||||
Effect.suspend(() => {
|
||||
drains.push(input.promotable)
|
||||
return Deferred.succeed(draining, undefined).pipe(Effect.andThen(Effect.never))
|
||||
}),
|
||||
)
|
||||
const execution = Context.get(context, SessionExecution.Service)
|
||||
yield* execution.resume(sessionID).pipe(Effect.forkScoped)
|
||||
yield* Deferred.await(draining)
|
||||
|
||||
yield* execution.interrupt(sessionID, { continue: true })
|
||||
yield* execution.awaitIdle(sessionID)
|
||||
|
||||
expect(drains).toEqual(["input"])
|
||||
expect(yield* execution.active).toEqual(new Set())
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("an idle interrupt with continue resumes pending steers", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const sessionID = Session.ID.make("ses_continue_idle")
|
||||
yield* seedSessions(database, [sessionID])
|
||||
yield* seedInbox(database, sessionID, ["steer"])
|
||||
|
||||
const drains: Array<{ force: boolean; promotable?: SessionInbox.Promotable }> = []
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
const context = yield* buildExecution(scope, (input) =>
|
||||
Effect.sync(() => void drains.push({ force: input.force, promotable: input.promotable })),
|
||||
)
|
||||
const execution = Context.get(context, SessionExecution.Service)
|
||||
|
||||
yield* execution.interrupt(sessionID, { continue: true })
|
||||
yield* execution.awaitIdle(sessionID)
|
||||
|
||||
expect(drains).toEqual([{ force: false, promotable: "steer" }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
function seedInbox(
|
||||
database: Database.Service["Service"],
|
||||
sessionID: Session.ID,
|
||||
deliveries: ReadonlyArray<SessionInbox.Delivery>,
|
||||
) {
|
||||
return database.db
|
||||
.insert(SessionInboxTable)
|
||||
.values(
|
||||
deliveries.map((delivery, index) => ({
|
||||
id: SessionMessage.ID.create(),
|
||||
session_id: sessionID,
|
||||
type: "compaction" as const,
|
||||
payload: {},
|
||||
delivery,
|
||||
enqueued_seq: index + 1,
|
||||
})),
|
||||
)
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
function seedSessions(
|
||||
database: Database.Service["Service"],
|
||||
sessionIDs: ReadonlyArray<Session.ID>,
|
||||
|
||||
@@ -13,7 +13,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { LocationPath } from "@opencode-ai/core/location-path"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
@@ -43,7 +43,7 @@ const readToolNode = makeLocationNode({
|
||||
deps: [
|
||||
Tool.node,
|
||||
ReadToolFileSystem.node,
|
||||
LocationMutation.node,
|
||||
LocationPath.node,
|
||||
Image.node,
|
||||
Permission.node,
|
||||
SessionInstructions.node,
|
||||
@@ -65,7 +65,7 @@ const testLayer = AppNodeBuilder.build(
|
||||
Session.node,
|
||||
Location.node,
|
||||
FSUtil.node,
|
||||
LocationMutation.node,
|
||||
LocationPath.node,
|
||||
ReadToolFileSystem.node,
|
||||
readToolNode,
|
||||
Tool.node,
|
||||
|
||||
@@ -31,7 +31,6 @@ import { testEffect } from "./lib/effect"
|
||||
|
||||
const executionCalls: Session.ID[] = []
|
||||
const interruptCalls: Session.ID[] = []
|
||||
const interruptContinuations: Array<boolean | undefined> = []
|
||||
const wakeCalls: Session.ID[] = []
|
||||
const activeSessions = new Set<Session.ID>()
|
||||
const execution = Layer.succeed(
|
||||
@@ -42,16 +41,14 @@ const execution = Layer.succeed(
|
||||
Effect.sync(() => {
|
||||
executionCalls.push(sessionID)
|
||||
}),
|
||||
interrupt: (sessionID, options) =>
|
||||
interrupt: (sessionID) =>
|
||||
Effect.sync(() => {
|
||||
interruptCalls.push(sessionID)
|
||||
interruptContinuations.push(options?.continue)
|
||||
}),
|
||||
wake: (sessionID) =>
|
||||
Effect.sync(() => {
|
||||
wakeCalls.push(sessionID)
|
||||
}),
|
||||
wakeActive: () => Effect.void,
|
||||
awaitIdle: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
@@ -180,18 +177,31 @@ describe("Session.prompt", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forwards interrupt continuation policy", () =>
|
||||
it.effect("continues after interruption when pending work remains", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* Session.Service
|
||||
yield* session.synthetic({ sessionID, text: "Continue after interrupt", resume: false })
|
||||
interruptCalls.length = 0
|
||||
wakeCalls.length = 0
|
||||
|
||||
yield* session.interrupt(sessionID, { continue: true })
|
||||
|
||||
expect(interruptCalls).toEqual([sessionID])
|
||||
expect(wakeCalls).toEqual([sessionID])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not continue after interruption without pending work", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* Session.Service
|
||||
interruptCalls.length = 0
|
||||
interruptContinuations.length = 0
|
||||
wakeCalls.length = 0
|
||||
|
||||
yield* session.interrupt(sessionID, { continue: true })
|
||||
|
||||
expect(interruptCalls).toEqual([sessionID])
|
||||
expect(interruptContinuations).toEqual([true])
|
||||
expect(wakeCalls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import { SessionInbox } from "@opencode-ai/core/session/inbox"
|
||||
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
@@ -270,28 +269,6 @@ describe("SessionRunCoordinator", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("a settlement-window wake starts a fresh execution with its own scope", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const settling = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const scopes: SessionInbox.Promotable[] = []
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: (_key, _force, scope) => Effect.sync(() => scopes.push(scope)),
|
||||
settled: () => Deferred.succeed(settling, undefined).pipe(Effect.andThen(Deferred.await(release))),
|
||||
})
|
||||
|
||||
yield* coordinator.wake("session", "steer")
|
||||
yield* Deferred.await(settling)
|
||||
yield* coordinator.wake("session", "input")
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* coordinator.awaitIdle("session")
|
||||
|
||||
expect(scopes).toEqual(["steer", "input"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("interrupts active execution and clears its pending wake", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
@@ -365,126 +342,6 @@ describe("SessionRunCoordinator", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("coalesces drain scopes with input taking precedence", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const scopes: SessionInbox.Promotable[] = []
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: (_key, _force, scope) =>
|
||||
Effect.gen(function* () {
|
||||
scopes.push(scope)
|
||||
if (scopes.length !== 1) return
|
||||
yield* Deferred.succeed(firstStarted, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}),
|
||||
})
|
||||
|
||||
yield* coordinator.wake("session", "steer")
|
||||
yield* Deferred.await(firstStarted)
|
||||
yield* coordinator.wake("session", "steer")
|
||||
yield* coordinator.wake("session", "input")
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* coordinator.awaitIdle("session")
|
||||
|
||||
expect(scopes).toEqual(["steer", "input"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("does not carry a completed input scope into a steer drain", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const scopes: SessionInbox.Promotable[] = []
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: (_key, _force, scope) =>
|
||||
Effect.gen(function* () {
|
||||
scopes.push(scope)
|
||||
if (scopes.length !== 1) return
|
||||
yield* Deferred.succeed(firstStarted, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}),
|
||||
})
|
||||
|
||||
yield* coordinator.wake("session", "input")
|
||||
yield* Deferred.await(firstStarted)
|
||||
yield* coordinator.wake("session", "steer")
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* coordinator.awaitIdle("session")
|
||||
|
||||
expect(scopes).toEqual(["input", "steer"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("an active wake inherits scope without starting idle work", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const scopes: SessionInbox.Promotable[] = []
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: (_key, _force, scope) =>
|
||||
Effect.gen(function* () {
|
||||
scopes.push(scope)
|
||||
if (scopes.length !== 1) return
|
||||
yield* Deferred.succeed(firstStarted, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}),
|
||||
})
|
||||
|
||||
yield* coordinator.wakeActive("session")
|
||||
yield* coordinator.wake("session", "steer")
|
||||
yield* Deferred.await(firstStarted)
|
||||
yield* coordinator.wakeActive("session")
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* coordinator.awaitIdle("session")
|
||||
|
||||
expect(scopes).toEqual(["steer", "steer"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("a cleanup-era wake starts a successor with its own scope", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const cleanupStarted = yield* Deferred.make<void>()
|
||||
const cleanupGate = yield* Deferred.make<void>()
|
||||
const scopes: SessionInbox.Promotable[] = []
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: (_key, _force, scope) =>
|
||||
Effect.gen(function* () {
|
||||
scopes.push(scope)
|
||||
if (scopes.length !== 1) return
|
||||
yield* Deferred.succeed(firstStarted, undefined)
|
||||
yield* Effect.never.pipe(
|
||||
Effect.onInterrupt(() =>
|
||||
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
|
||||
),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
yield* coordinator.wake("session", "input")
|
||||
yield* Deferred.await(firstStarted)
|
||||
const interrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild)
|
||||
yield* Deferred.await(cleanupStarted)
|
||||
// A new admission during cancellation restarts normally: interruption only
|
||||
// claims the wakes recorded before it.
|
||||
yield* coordinator.wake("session", "input")
|
||||
yield* Deferred.succeed(cleanupGate, undefined)
|
||||
yield* Fiber.join(interrupt)
|
||||
yield* coordinator.awaitIdle("session")
|
||||
|
||||
expect(scopes).toEqual(["input", "input"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("starts a resume registered during interruption cleanup", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -126,8 +126,7 @@ const execution = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
|
||||
active: coordinator.active,
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
wakeActive: coordinator.wakeActive,
|
||||
interrupt: (sessionID) => coordinator.interrupt(sessionID),
|
||||
interrupt: coordinator.interrupt,
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -413,8 +413,7 @@ const execution = Layer.effect(
|
||||
active: coordinator.active,
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
wakeActive: coordinator.wakeActive,
|
||||
interrupt: (sessionID) => coordinator.interrupt(sessionID),
|
||||
interrupt: coordinator.interrupt,
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
})
|
||||
}),
|
||||
@@ -1384,44 +1383,6 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps queued input parked across a mid-turn move", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const bus = yield* Bus.Service
|
||||
const { db } = yield* Database.Service
|
||||
yield* admit(session, "Echo before moving")
|
||||
yield* TestLLM.push(
|
||||
TestLLM.tool("call-move", "echo", { text: "moving" }),
|
||||
TestLLM.text("Done", "text-after-move"),
|
||||
TestLLM.text("Handled queue", "text-after-queue"),
|
||||
)
|
||||
const tools = yield* blockTools()
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* tools.started
|
||||
yield* session.prompt({ sessionID, text: "Queued for later", delivery: "queue", resume: false })
|
||||
yield* SessionInbox.admit(db, bus, {
|
||||
id: SessionMessage.ID.create(),
|
||||
sessionID,
|
||||
item: {
|
||||
type: "move",
|
||||
payload: {
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
|
||||
projectID: Project.ID.global,
|
||||
},
|
||||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
|
||||
yield* tools.release
|
||||
yield* Fiber.join(run)
|
||||
|
||||
// The resumed turn absorbs steers only; queued input waits for the turn to end.
|
||||
expect(requests).toHaveLength(3)
|
||||
expect(userTexts(requests[1])).not.toContain("Queued for later")
|
||||
expect(userTexts(requests[2])).toContain("Queued for later")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("seeds a fork with the parent's newest instruction values", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
@@ -3127,24 +3088,6 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("stops a steer-scoped drain before queued input", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const { db } = yield* Database.Service
|
||||
yield* session.prompt({ sessionID, text: "Queue for later", delivery: "queue", resume: false })
|
||||
yield* session.prompt({ sessionID, text: "Steer now", resume: false })
|
||||
yield* TestLLM.push(TestLLM.stop())
|
||||
|
||||
const runner = yield* SessionRunner.Service
|
||||
yield* runner.drain({ sessionID, force: false, promotable: "steer" })
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(userTexts(requests[0])).toEqual(["Steer now"])
|
||||
expect(yield* SessionInbox.has(db, sessionID, "steer")).toBe(false)
|
||||
expect(yield* SessionInbox.has(db, sessionID, "queue")).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("promotes queued input after steering continuation ends", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { Formatter } from "@opencode-ai/core/formatter"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { LocationPath } from "@opencode-ai/core/location-path"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
@@ -27,7 +27,7 @@ const editToolNode = makeLocationNode({
|
||||
layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
|
||||
deps: [
|
||||
Tool.node,
|
||||
LocationMutation.node,
|
||||
LocationPath.node,
|
||||
FileMutation.node,
|
||||
Environment.node,
|
||||
Formatter.node,
|
||||
@@ -84,7 +84,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, editToolNode]),
|
||||
LayerNode.group([Tool.node, Tool.node, LocationPath.node, FileMutation.node, editToolNode]),
|
||||
[
|
||||
[
|
||||
Environment.node,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { Formatter } from "@opencode-ai/core/formatter"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { LocationPath } from "@opencode-ai/core/location-path"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
@@ -27,7 +27,7 @@ const patchToolNode = makeLocationNode({
|
||||
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
|
||||
deps: [
|
||||
Tool.node,
|
||||
LocationMutation.node,
|
||||
LocationPath.node,
|
||||
FileMutation.node,
|
||||
Environment.node,
|
||||
Formatter.node,
|
||||
@@ -99,7 +99,7 @@ const withTool = <A, E, R>(
|
||||
return yield* body(yield* Tool.Service)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, patchToolNode]), [
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, LocationPath.node, FileMutation.node, patchToolNode]), [
|
||||
[
|
||||
Environment.node,
|
||||
transformEnvironmentFiles(activeLocation, (files) => ({
|
||||
@@ -920,7 +920,7 @@ describe("PatchTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("treats a sibling path inside the project worktree as external to the Location", () =>
|
||||
it.live("treats a sibling path inside the project worktree as internal", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
@@ -939,9 +939,8 @@ describe("PatchTool", () => {
|
||||
call("*** Begin Patch\n*** Update File: ../sibling.txt\n@@\n-before\n+after\n*** End Patch"),
|
||||
),
|
||||
).toMatchObject({ status: "completed" })
|
||||
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
|
||||
expect(assertions[0]?.resources).toEqual([path.join(tmp.path, "*").replaceAll("\\", "/")])
|
||||
expect(assertions[1]?.resources).toEqual([target.replaceAll("\\", "/")])
|
||||
expect(assertions.map((input) => input.action)).toEqual(["edit"])
|
||||
expect(assertions[0]?.resources).toEqual(["../sibling.txt"])
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
|
||||
}),
|
||||
tmp.path,
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { LocationPath } from "@opencode-ai/core/location-path"
|
||||
import { location } from "./fixture/location"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { ReadTool } from "@opencode-ai/core/tool/plugin/read"
|
||||
@@ -32,7 +32,7 @@ const readToolNode = makeLocationNode({
|
||||
deps: [
|
||||
Tool.node,
|
||||
ReadToolFileSystem.node,
|
||||
LocationMutation.node,
|
||||
LocationPath.node,
|
||||
Image.node,
|
||||
Permission.node,
|
||||
SessionInstructions.node,
|
||||
@@ -107,8 +107,8 @@ const locationLayer = Layer.succeed(
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) })),
|
||||
)
|
||||
const mutation = Layer.succeed(
|
||||
LocationMutation.Service,
|
||||
LocationMutation.Service.of({
|
||||
LocationPath.Service,
|
||||
LocationPath.Service.of({
|
||||
resolve: (input) => {
|
||||
const absolute = path.resolve(process.cwd(), input.path)
|
||||
const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), absolute)
|
||||
@@ -141,7 +141,7 @@ const readLayer = (imageLayer: Layer.Layer<Image.Service>) =>
|
||||
[Permission.node, permission],
|
||||
[Config.node, config],
|
||||
[Image.node, imageLayer],
|
||||
[LocationMutation.node, mutation],
|
||||
[LocationPath.node, mutation],
|
||||
[FSUtil.node, testFileSystem],
|
||||
[Location.node, locationLayer],
|
||||
[Global.node, Global.layerWith({ data: Global.Path.data })],
|
||||
|
||||
@@ -8,7 +8,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { LocationPath } from "@opencode-ai/core/location-path"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -25,12 +25,12 @@ import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
|
||||
const globToolNode = makeLocationNode({
|
||||
name: "test/glob-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)),
|
||||
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
|
||||
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationPath.node, Permission.node],
|
||||
})
|
||||
const grepToolNode = makeLocationNode({
|
||||
name: "test/grep-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)),
|
||||
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
|
||||
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationPath.node, Permission.node],
|
||||
})
|
||||
const sessionID = Session.ID.make("ses_search_tool_test")
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { LocationPath } from "@opencode-ai/core/location-path"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
@@ -115,7 +115,6 @@ const executionNode = makeGlobalNode({
|
||||
active: Effect.succeed(new Set()),
|
||||
resume: complete,
|
||||
wake: () => Effect.void,
|
||||
wakeActive: () => Effect.void,
|
||||
interrupt: () => Effect.void,
|
||||
awaitIdle: (id) => complete(id).pipe(Effect.exit, Effect.asVoid),
|
||||
})
|
||||
@@ -133,7 +132,7 @@ const shellPluginSupervisor = makeLocationNode({
|
||||
deps: [
|
||||
Config.node,
|
||||
Environment.node,
|
||||
LocationMutation.node,
|
||||
LocationPath.node,
|
||||
Permission.node,
|
||||
PluginRuntime.node,
|
||||
Shell.node,
|
||||
|
||||
@@ -86,7 +86,6 @@ const executionNode = makeGlobalNode({
|
||||
active: Effect.succeed(new Set()),
|
||||
resume: complete,
|
||||
wake: () => Effect.void,
|
||||
wakeActive: () => Effect.void,
|
||||
interrupt: () => Effect.void,
|
||||
awaitIdle: (sessionID) => complete(sessionID).pipe(Effect.exit, Effect.asVoid),
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { LocationPath } from "@opencode-ai/core/location-path"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
@@ -25,7 +25,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
|
||||
const writeToolNode = makeLocationNode({
|
||||
name: "test/write-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)),
|
||||
deps: [Tool.node, LocationMutation.node, FileMutation.node, Environment.node, Formatter.node, Permission.node],
|
||||
deps: [Tool.node, LocationPath.node, FileMutation.node, Environment.node, Formatter.node, Permission.node],
|
||||
})
|
||||
|
||||
const sessionID = Session.ID.make("ses_write_tool_test")
|
||||
@@ -72,7 +72,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]),
|
||||
LayerNode.group([Tool.node, Tool.node, LocationPath.node, FileMutation.node, writeToolNode]),
|
||||
[
|
||||
[
|
||||
Environment.node,
|
||||
|
||||
@@ -3964,7 +3964,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes pending steering input while queued work remains parked.",
|
||||
"description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes if durable inbox work remains after interruption.",
|
||||
"summary": "Interrupt session execution"
|
||||
}
|
||||
},
|
||||
@@ -17728,9 +17728,8 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"activation": {
|
||||
"type": "string",
|
||||
"enum": ["auto", "enabled", "disabled"]
|
||||
"disabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"package": {
|
||||
"type": "string"
|
||||
@@ -17748,7 +17747,7 @@
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": ["id", "name", "activation", "package"],
|
||||
"required": ["id", "name", "package"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ProviderNotFoundError": {
|
||||
|
||||
@@ -660,7 +660,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
identifier: "v2.session.interrupt",
|
||||
summary: "Interrupt session execution",
|
||||
description:
|
||||
"Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes pending steering input while queued work remains parked.",
|
||||
"Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes if durable inbox work remains after interruption.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -25,9 +25,6 @@ export type ID = typeof ID.Type
|
||||
export const Package = Schema.String
|
||||
export type Package = typeof Package.Type
|
||||
|
||||
export const Activation = Schema.Literals(["auto", "enabled", "disabled"])
|
||||
export type Activation = typeof Activation.Type
|
||||
|
||||
export const Overlays = {
|
||||
settings: Schema.Record(Schema.String, Schema.Any).pipe(optional),
|
||||
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
|
||||
@@ -49,13 +46,13 @@ export const Info = Schema.Struct({
|
||||
id: ID,
|
||||
integrationID: Integration.ID.pipe(optional),
|
||||
name: Schema.String,
|
||||
activation: Activation,
|
||||
disabled: Schema.Boolean.pipe(optional),
|
||||
package: Package,
|
||||
...Overlays,
|
||||
})
|
||||
.annotate({ identifier: "Provider.Info" })
|
||||
.pipe(
|
||||
statics(() => ({
|
||||
empty: (id: ID): Info => ({ id, name: id, activation: "auto", package: "" }),
|
||||
empty: (id: ID): Info => ({ id, name: id, package: "" }),
|
||||
})),
|
||||
)
|
||||
|
||||
@@ -120,12 +120,10 @@ describe("contract hygiene", () => {
|
||||
test("model defaults and provider overlays preserve public invariants", () => {
|
||||
const id = Model.ID.make("model")
|
||||
expect(Model.Info.default(Provider.ID.make("provider"), id)).toMatchObject({ modelID: id, variants: [] })
|
||||
expect(Provider.Info.empty(Provider.ID.make("provider")).activation).toBe("auto")
|
||||
expect(
|
||||
Schema.decodeUnknownSync(Provider.Info)({
|
||||
id: "provider",
|
||||
name: "Provider",
|
||||
activation: "auto",
|
||||
package: "native",
|
||||
settings: { arbitrary: 1n },
|
||||
}).settings,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createMemo, createResource, createSignal, onMount, Show } from "solid-js"
|
||||
import path from "path"
|
||||
import type { SessionInfo } from "@opencode-ai/client"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import type { RGBA } from "@opentui/core"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
@@ -55,12 +54,10 @@ export function DialogSessionList() {
|
||||
const response = await client.api.session.list({
|
||||
...(allProjects
|
||||
? {}
|
||||
: current.project.id === Project.ID.global
|
||||
? { directory: current.directory }
|
||||
: {
|
||||
project: current.project.id,
|
||||
subpath: path.relative(current.project.directory, current.directory).replaceAll("\\", "/"),
|
||||
}),
|
||||
: {
|
||||
project: current.project.id,
|
||||
subpath: path.relative(current.project.directory, current.directory).replaceAll("\\", "/"),
|
||||
}),
|
||||
...(query ? { search: query } : {}),
|
||||
limit: 50,
|
||||
order: "desc",
|
||||
|
||||
@@ -71,7 +71,6 @@ import { DialogImagePreview } from "../dialog-image-preview"
|
||||
import { useDirectoryRecents } from "../../prompt/directory-recents"
|
||||
import { directoryRecentValue } from "../../prompt/directory-completion"
|
||||
import { useWorkingDirectoryActions } from "../../ui/working-directory-actions"
|
||||
import { truncateFilePath } from "../../ui/file-path"
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
@@ -1556,12 +1555,6 @@ export function Prompt(props: PromptProps) {
|
||||
const branch = data.location.vcs.info(location)?.branch.current
|
||||
return branch ? `${directory}:${branch}` : directory
|
||||
})
|
||||
const [locationWidth, setLocationWidth] = createSignal(dimensions().width)
|
||||
const locationLabelDisplay = createMemo(() => {
|
||||
const label = locationLabel()
|
||||
if (!label) return
|
||||
return truncateFilePath(label, locationWidth())
|
||||
})
|
||||
const locationActions = useWorkingDirectoryActions({
|
||||
directory: () => footerLocation()?.directory,
|
||||
onMove: () => void move.open(),
|
||||
@@ -1847,15 +1840,7 @@ export function Prompt(props: PromptProps) {
|
||||
<box width="100%" flexDirection="row" justifyContent="space-between" gap={2}>
|
||||
<Slot path="prompt.footer" input={footerInput()}>
|
||||
<Slot path="prompt.footer.status" input={footerInput()}>
|
||||
<box
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
minWidth={0}
|
||||
onSizeChange={function (this: BoxRenderable) {
|
||||
const width = this.width
|
||||
queueMicrotask(() => setLocationWidth(width))
|
||||
}}
|
||||
>
|
||||
<box flexGrow={1} flexShrink={1} minWidth={0}>
|
||||
<Switch>
|
||||
<Match when={status() === "running"}>
|
||||
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
|
||||
@@ -1892,7 +1877,7 @@ export function Prompt(props: PromptProps) {
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Show when={!props.hint && locationLabelDisplay()} fallback={props.hint ?? <text />}>
|
||||
<Show when={!props.hint && locationLabel()} fallback={props.hint ?? <text />}>
|
||||
{(location) => (
|
||||
<text
|
||||
id="prompt.footer.location"
|
||||
|
||||
@@ -5,7 +5,6 @@ export function catalogProvider(id: string, name: string): ProviderListOutput["d
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
activation: "auto",
|
||||
package: "",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,6 @@ describe("truncateFilePath", () => {
|
||||
expect(truncateFilePath(path, 19)).toBe("…/dialog-select.tsx")
|
||||
})
|
||||
|
||||
test("preserves the working directory and branch suffix", () => {
|
||||
expect(truncateFilePath("~/code/experiments/category-theory:main", 30)).toBe("…/experi…/category-theory:main")
|
||||
})
|
||||
|
||||
test("uses remaining width for part of a long parent segment", () => {
|
||||
const path = "/private/var/folders/run-17f048ec-dbb2-4b36-860c-98637bb51a8d/files"
|
||||
expect(truncateFilePath(path, 40)).toBe("/…/run-17f048ec-dbb2-4b36-860c-98…/files")
|
||||
|
||||
@@ -3964,7 +3964,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes pending steering input while queued work remains parked.",
|
||||
"description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes if durable inbox work remains after interruption.",
|
||||
"summary": "Interrupt session execution"
|
||||
}
|
||||
},
|
||||
@@ -17728,9 +17728,8 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"activation": {
|
||||
"type": "string",
|
||||
"enum": ["auto", "enabled", "disabled"]
|
||||
"disabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"package": {
|
||||
"type": "string"
|
||||
@@ -17748,7 +17747,7 @@
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": ["id", "name", "activation", "package"],
|
||||
"required": ["id", "name", "package"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ProviderNotFoundError": {
|
||||
|
||||
@@ -3964,7 +3964,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes pending steering input while queued work remains parked.",
|
||||
"description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes if durable inbox work remains after interruption.",
|
||||
"summary": "Interrupt session execution"
|
||||
}
|
||||
},
|
||||
@@ -17728,9 +17728,8 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"activation": {
|
||||
"type": "string",
|
||||
"enum": ["auto", "enabled", "disabled"]
|
||||
"disabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"package": {
|
||||
"type": "string"
|
||||
@@ -17748,7 +17747,7 @@
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": ["id", "name", "activation", "package"],
|
||||
"required": ["id", "name", "package"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ProviderNotFoundError": {
|
||||
|
||||
Reference in New Issue
Block a user