Compare commits

..

1 Commits

Author SHA1 Message Date
Aiden Cline f81d771e47 fix(core): treat project paths as internal 2026-08-15 12:00:30 +00:00
84 changed files with 3638 additions and 14803 deletions
@@ -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)
+22 -18
View File
@@ -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) {
-14
View File
@@ -383,15 +383,6 @@ export type Endpoint5_31Output =
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly title: string }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.viewed"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
@@ -924,10 +915,6 @@ export type Endpoint5_34Input = { readonly sessionID: Session.ID; readonly messa
export type Endpoint5_34Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E>
export type Endpoint5_35Input = { readonly sessionID: Session.ID }
export type Endpoint5_35Output = void
export type SessionViewOperation<E = never> = (input: Endpoint5_35Input) => Effect.Effect<Endpoint5_35Output, E>
export interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
readonly create: SessionCreateOperation<E>
@@ -972,7 +959,6 @@ export interface SessionApi<E = never> {
readonly interrupt: SessionInterruptOperation<E>
readonly background: SessionBackgroundOperation<E>
readonly message: SessionMessageOperation<E>
readonly view: SessionViewOperation<E>
}
export type Endpoint6_0Input = {
@@ -86,8 +86,6 @@ import type {
Endpoint5_33Output,
Endpoint5_34Input,
Endpoint5_34Output,
Endpoint5_35Input,
Endpoint5_35Output,
Endpoint6_0Input,
Endpoint6_0Output,
Endpoint7_0Input,
@@ -612,11 +610,6 @@ const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34I
),
)
const Endpoint5_35 = (raw: RawClient["server.session"]) => (input: Endpoint5_35Input) =>
preserveEffect<Endpoint5_35Output>()(
raw["session.view"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup5 = (raw: RawClient["server.session"]) => ({
list: Endpoint5_0(raw),
create: Endpoint5_1(raw),
@@ -646,7 +639,6 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
interrupt: Endpoint5_32(raw),
background: Endpoint5_33(raw),
message: Endpoint5_34(raw),
view: Endpoint5_35(raw),
})
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
@@ -80,8 +80,6 @@ import type {
SessionBackgroundOutput,
SessionMessageInput,
SessionMessageOutput,
SessionViewInput,
SessionViewOutput,
MessageListInput,
MessageListOutput,
ModelListInput,
@@ -898,17 +896,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
view: (input: SessionViewInput, requestOptions?: RequestOptions) =>
request<SessionViewOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/view`,
successStatus: 204,
declaredStatuses: [404, 401, 400],
empty: true,
},
requestOptions,
),
},
message: {
list: (input: MessageListInput, requestOptions?: RequestOptions) =>
+5 -39
View File
@@ -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 }
@@ -479,16 +479,6 @@ export type SessionRenamed = {
data: { sessionID: string; title: string }
}
export type SessionViewed = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.viewed"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string }
}
export type SessionDeleted = {
id: string
created: number
@@ -1520,7 +1510,7 @@ export type SessionInfo = {
model?: ModelRef
cost: MoneyUSD
tokens: TokenUsageInfo
time: { created: number; updated: number; idle?: number; viewed?: number; archived?: number }
time: { created: number; updated: number; archived?: number }
title?: string
location: LocationRef
subpath?: string
@@ -1933,7 +1923,6 @@ export type SessionEventDurable =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionViewed
| SessionDeleted
| SessionForked
| SessionInboxDelivered
@@ -2024,7 +2013,6 @@ export type V2Event =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionViewed
| SessionUsageUpdated
| SessionDeleted
| SessionForked
@@ -2488,13 +2476,7 @@ export type SessionImportInput = {
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly time: {
readonly created: number
readonly updated: number
readonly idle?: number
readonly viewed?: number
readonly archived?: number
}
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number }
readonly title?: string
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
@@ -2761,13 +2743,7 @@ export type SessionImportInput = {
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly time: {
readonly created: number
readonly updated: number
readonly idle?: number
readonly viewed?: number
readonly archived?: number
}
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number }
readonly title?: string
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
@@ -3034,13 +3010,7 @@ export type SessionImportInput = {
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly time: {
readonly created: number
readonly updated: number
readonly idle?: number
readonly viewed?: number
readonly archived?: number
}
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number }
readonly title?: string
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
@@ -3966,10 +3936,6 @@ export type SessionMessageInput = {
export type SessionMessageOutput = { data: SessionMessageInfo }["data"]
export type SessionViewInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionViewOutput = void
export type MessageListInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly limit?: {
+1 -11
View File
@@ -136,10 +136,8 @@ test("event.subscribe terminates on Effect protocol decode failures", async () =
test("session methods retain decoded Effect inputs and outputs", async () => {
const logQueries: Array<Record<string, string>> = []
const requests: Array<{ method: string; url: string }> = []
const httpClient = HttpClient.make((request) => {
const url = request.url
requests.push({ method: request.method, url })
if (url.includes("/log")) {
logQueries.push(Object.fromEntries(request.urlParams.params))
return Effect.succeed(
@@ -185,7 +183,6 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
const created = yield* client.session.create({
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
})
yield* client.session.view({ sessionID: Session.ID.make("ses_test") })
yield* client.session.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") })
yield* client.session.switchModel({
sessionID: Session.ID.make("ses_test"),
@@ -210,11 +207,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
return { page, active, created, admitted, context, log, message }
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
const listed = result.page.data[0]
if (!listed?.time.idle || !listed.time.viewed) throw new Error("Expected attention times")
expect(DateTime.toEpochMillis(listed.time.created)).toBe(1_717_171_717_000)
expect(DateTime.toEpochMillis(listed.time.idle)).toBe(1_717_171_717_002)
expect(DateTime.toEpochMillis(listed.time.viewed)).toBe(1_717_171_717_001)
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
expect(result.active).toEqual({ ses_test: { type: "running" } })
expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype)
expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
@@ -224,7 +217,6 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
expect(result.context).toEqual([])
expect(logQueries[0]).toEqual({ after: "0" })
expect(requests).toContainEqual({ method: "POST", url: "http://localhost:3000/api/session/ses_test/view" })
const logged = Array.from(result.log)
expect(logged.map((item) => item.type)).toEqual(["session.model.selected", "log.synced"])
expect(logged[0]?.type === "session.model.selected" && DateTime.toEpochMillis(logged[0].created)).toBe(
@@ -268,8 +260,6 @@ const session = {
time: {
created: 1_717_171_717_000,
updated: 1_717_171_717_000,
idle: 1_717_171_717_002,
viewed: 1_717_171_717_001,
},
title: "Test",
location: { directory: "/tmp/project" },
-5
View File
@@ -539,7 +539,6 @@ test("session methods use the public HTTP contract", async () => {
const page = await client.session.list({ limit: 10, order: "desc", parentID: null })
const active = await client.session.active()
const created = await client.session.create({ location: { directory: "/tmp/project" } })
await client.session.view({ sessionID: "ses_test" })
await client.session.switchAgent({ sessionID: "ses_test", agent: "build" })
await client.session.switchModel({
sessionID: "ses_test",
@@ -566,7 +565,6 @@ test("session methods use the public HTTP contract", async () => {
const message = await client.session.message({ sessionID: "ses_test", messageID: "msg_model" })
expect(page.cursor.next).toBe("next")
expect(page.data[0].time).toMatchObject({ idle: 1_717_171_717_002, viewed: 1_717_171_717_001 })
expect(active).toEqual({ ses_test: { type: "running" } })
expect(created.id).toBe("ses_test")
expect(admitted.id).toBe("msg_test")
@@ -579,7 +577,6 @@ test("session methods use the public HTTP contract", async () => {
["GET", "http://localhost:3000/api/session?limit=10&order=desc&parentID=null"],
["GET", "http://localhost:3000/api/session/active"],
["POST", "http://localhost:3000/api/session"],
["POST", "http://localhost:3000/api/session/ses_test/view"],
["POST", "http://localhost:3000/api/session/ses_test/agent"],
["POST", "http://localhost:3000/api/session/ses_test/model"],
["POST", "http://localhost:3000/api/session/ses_test/prompt"],
@@ -654,8 +651,6 @@ const session = {
time: {
created: 1_717_171_717_000,
updated: 1_717_171_717_000,
idle: 1_717_171_717_002,
viewed: 1_717_171_717_001,
},
title: "Test",
location: { directory: "/tmp/project" },
+44 -152
View File
@@ -1,10 +1,8 @@
{
"version": "7",
"dialect": "sqlite",
"id": "94b6c496-ad84-426f-9d5d-3e1ac3ebfb56",
"prevIds": [
"dcde8e6b-4bf4-4f6b-b2be-4030c2c3e936"
],
"id": "dcde8e6b-4bf4-4f6b-b2be-4030c2c3e936",
"prevIds": ["5c1aa56b-c3ee-4283-9a84-c0bf626dc604"],
"ddl": [
{
"name": "account_state",
@@ -1352,26 +1350,6 @@
"entityType": "columns",
"table": "session_v2"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_idle",
"entityType": "columns",
"table": "session_v2"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_viewed",
"entityType": "columns",
"table": "session_v2"
},
{
"type": "integer",
"notNull": false,
@@ -1503,13 +1481,9 @@
"table": "worktree"
},
{
"columns": [
"active_account_id"
],
"columns": ["active_account_id"],
"tableTo": "account",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "SET NULL",
"nameExplicit": false,
@@ -1518,13 +1492,9 @@
"table": "account_state"
},
{
"columns": [
"aggregate_id"
],
"columns": ["aggregate_id"],
"tableTo": "event_sequence",
"columnsTo": [
"aggregate_id"
],
"columnsTo": ["aggregate_id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1533,13 +1503,9 @@
"table": "event"
},
{
"columns": [
"project_id"
],
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1548,13 +1514,9 @@
"table": "permission"
},
{
"columns": [
"project_id"
],
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1563,13 +1525,9 @@
"table": "project_directory"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session_v2",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1578,13 +1536,9 @@
"table": "instruction_entry"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session_v2",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1593,13 +1547,9 @@
"table": "instruction_state"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session_v2",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1608,13 +1558,9 @@
"table": "session_inbox"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session_v2",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1623,13 +1569,9 @@
"table": "session_message"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session_v2",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1638,13 +1580,9 @@
"table": "session_pending"
},
{
"columns": [
"project_id"
],
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1653,13 +1591,9 @@
"table": "session_v2"
},
{
"columns": [
"project_id"
],
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1668,175 +1602,133 @@
"table": "worktree"
},
{
"columns": [
"email",
"url"
],
"columns": ["email", "url"],
"nameExplicit": false,
"name": "control_account_pk",
"entityType": "pks",
"table": "control_account"
},
{
"columns": [
"project_id",
"directory"
],
"columns": ["project_id", "directory"],
"nameExplicit": false,
"name": "project_directory_pk",
"entityType": "pks",
"table": "project_directory"
},
{
"columns": [
"session_id",
"key"
],
"columns": ["session_id", "key"],
"nameExplicit": false,
"name": "instruction_entry_pk",
"entityType": "pks",
"table": "instruction_entry"
},
{
"columns": [
"project_id",
"directory"
],
"columns": ["project_id", "directory"],
"nameExplicit": false,
"name": "worktree_pk",
"entityType": "pks",
"table": "worktree"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "account_state_pk",
"table": "account_state",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "account_pk",
"table": "account",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "credential_pk",
"table": "credential",
"entityType": "pks"
},
{
"columns": [
"aggregate_id"
],
"columns": ["aggregate_id"],
"nameExplicit": false,
"name": "event_sequence_pk",
"table": "event_sequence",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "event_pk",
"table": "event",
"entityType": "pks"
},
{
"columns": [
"key"
],
"columns": ["key"],
"nameExplicit": false,
"name": "kv_pk",
"table": "kv",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "permission_pk",
"table": "permission",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "project_pk",
"table": "project",
"entityType": "pks"
},
{
"columns": [
"hash"
],
"columns": ["hash"],
"nameExplicit": false,
"name": "instruction_blob_pk",
"table": "instruction_blob",
"entityType": "pks"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"nameExplicit": false,
"name": "instruction_state_pk",
"table": "instruction_state",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "session_inbox_pk",
"table": "session_inbox",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "session_message_pk",
"table": "session_message",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "session_pending_pk",
"table": "session_pending",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "session_v2_pk",
"table": "session_v2",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "workspace_pk",
"table": "workspace",
@@ -2132,4 +2024,4 @@
}
],
"renames": []
}
}
+2 -2
View File
@@ -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
}
+2 -2
View File
@@ -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)
-2
View File
@@ -43,7 +43,6 @@ import m40 from "./migration/20260808023530_workspace_domain.js"
import m41 from "./migration/20260811161259_execution_claim_attempts.js"
import m42 from "./migration/20260812181746_session_inbox.js"
import m43 from "./migration/20260812213948_worktree.js"
import m44 from "./migration/20260815182818_session_viewed_state.js"
export const migrations = [
m00,
@@ -90,5 +89,4 @@ export const migrations = [
m41,
m42,
m43,
m44,
] satisfies DatabaseMigration.Migration[]
@@ -1,14 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260815182818_session_viewed_state",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session_v2\` ADD \`time_idle\` integer;`)
yield* tx.run(`ALTER TABLE \`session_v2\` ADD \`time_viewed\` integer;`)
})
},
}
export default migration
-2
View File
@@ -209,8 +209,6 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
\`model\` text,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
\`time_idle\` integer,
\`time_viewed\` integer,
\`time_compacting\` integer,
\`time_archived\` integer,
\`time_suspended\` integer,
@@ -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) || "."),
+2 -2
View File
@@ -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,
+10 -43
View File
@@ -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,
-1
View File
@@ -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
+4 -4
View File
@@ -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()) {
+1 -1
View File
@@ -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
+7 -22
View File
@@ -165,7 +165,6 @@ export interface Interface {
input: ForkInput,
) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError | ForkEmptyError>
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError>
readonly view: (input: { sessionID: SessionSchema.ID }) => Effect.Effect<void, NotFoundError>
readonly remove: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly messages: (input: {
sessionID: SessionSchema.ID
@@ -308,7 +307,6 @@ const layer = Layer.effect(
const scope = yield* Scope.Scope
const activeShells = new Set<SessionSchema.ID>()
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
const viewLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
const closeTransport = Effect.fn("Session.closeTransport")(function* (session: SessionSchema.Info) {
const location = Location.Ref.make({
directory: session.location.directory,
@@ -449,21 +447,6 @@ const layer = Layer.effect(
if (!session) return yield* new NotFoundError({ sessionID })
return session
}),
view: Effect.fn("Session.view")((input) =>
viewLocks.withLock(input.sessionID)(
Effect.gen(function* () {
const row = yield* db
.select({ idle: SessionTable.time_idle, viewed: SessionTable.time_viewed })
.from(SessionTable)
.where(eq(SessionTable.id, input.sessionID))
.get()
.pipe(Effect.orDie)
if (!row) return yield* new NotFoundError({ sessionID: input.sessionID })
if (row.idle === null || row.viewed === row.idle) return
yield* bus.publish(SessionEvent.Viewed, { sessionID: input.sessionID })
}),
),
),
remove: Effect.fn("Session.remove")(function* (sessionID) {
const session = yield* result.get(sessionID)
yield* execution.interrupt(sessionID)
@@ -806,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) {
@@ -893,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) {
+11 -22
View File
@@ -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) =>
coordinator.interrupt(
sessionID,
"user",
options?.continue
? { continue: { request: "steer", when: SessionInbox.has(db, sessionID, "steer") } }
: undefined,
),
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,
}),
-8
View File
@@ -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.
-2
View File
@@ -53,8 +53,6 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
time: {
created: DateTime.makeUnsafe(row.time_created),
updated: DateTime.makeUnsafe(row.time_updated),
idle: row.time_idle === null ? undefined : DateTime.makeUnsafe(row.time_idle),
viewed: row.time_viewed === null ? undefined : DateTime.makeUnsafe(row.time_viewed),
archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined,
},
})
@@ -59,7 +59,6 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
Match.type<SessionEvent.DurableEvent>(),
Match.discriminatorsExhaustive("type")({
"session.created": () => Effect.void,
"session.viewed": () => Effect.void,
"session.usage.recorded": () => Effect.void,
"session.agent.selected": (event) => {
return Effect.gen(function* () {
+3 -38
View File
@@ -391,30 +391,6 @@ function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, me
.pipe(Effect.orDie)
}
function projectIdle(
db: DatabaseService,
event:
| typeof SessionEvent.Execution.Succeeded.Type
| typeof SessionEvent.Execution.Failed.Type
| typeof SessionEvent.Execution.Interrupted.Type,
) {
return Effect.gen(function* () {
yield* run(db, event)
if (event.type === SessionEvent.Execution.Interrupted.type && event.data.reason === "shutdown") return
const time = DateTime.toEpochMillis(event.created)
yield* db
.update(SessionTable)
.set({
// Unread uses a strict timestamp comparison, so every terminal must advance even within one millisecond.
time_idle: sql`max(${time}, coalesce(${SessionTable.time_idle} + 1, ${time}))`,
time_updated: sql`${SessionTable.time_updated}`,
})
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
})
}
const layer = Layer.effectDiscard(
Effect.gen(function* () {
const bus = yield* Bus.Service
@@ -536,17 +512,6 @@ const layer = Layer.effectDiscard(
.run()
.pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.Viewed, (event) =>
db
.update(SessionTable)
.set({
time_viewed: sql`${SessionTable.time_idle}`,
time_updated: sql`${SessionTable.time_updated}`,
})
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.UsageRecorded, (event) => applyUsage(db, event.data.sessionID, event.data))
yield* bus.project(SessionEvent.Forked, (event) => projectFork(db, event))
yield* bus.project(SessionEvent.InboxDelivered, (event) =>
@@ -615,9 +580,9 @@ const layer = Layer.effectDiscard(
delivery: event.data.delivery,
}),
)
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => projectIdle(db, event))
yield* bus.project(SessionEvent.Execution.Failed, (event) => projectIdle(db, event))
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => projectIdle(db, event))
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
yield* bus.project(SessionEvent.Execution.Failed, (event) => run(db, event))
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
yield* bus.project(SessionEvent.InstructionsUpdated, (event) =>
Effect.gen(function* () {
yield* run(db, event)
+23 -82
View File
@@ -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,41 +9,26 @@ 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, request?: Request) => Effect.Effect<void>
/** Rings the current execution's doorbell with its existing request. 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,
options?: { readonly continue?: { readonly request: Request; readonly when: Effect.Effect<boolean> } },
) => Effect.Effect<void>
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void>
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
readonly awaitIdle: (key: Key) => Effect.Effect<void>
}
export type Request = Promotable
/**
* 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 its eligibility request, 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>
request: Request
pendingWake?: Request
pendingWake: boolean
stopping: boolean
interruptionReason?: Reason
continuation?: {
readonly request: Request
readonly when: Effect.Effect<boolean>
signaled: boolean
}
}
/**
@@ -59,7 +43,7 @@ type Execution<E, Reason> = {
* ```
*/
export const make = <Key, E, Reason = never>(options: {
readonly drain: (key: Key, force: boolean, request: Request) => 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>
/**
@@ -73,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.request)).pipe(
Effect.suspend(() => options.drain(key, force)).pipe(
Effect.flatMap(() =>
Effect.suspend(() => {
if (execution.stopping || execution.pendingWake === undefined) return Effect.void
execution.request = 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, request: Request) => {
const start = (key: Key, force: boolean) => {
const execution: Execution<E, Reason> = {
done: Deferred.makeUnsafe<void, E>(),
request,
pendingWake: false,
stopping: false,
}
executions.set(key, execution)
@@ -104,7 +87,7 @@ export const make = <Key, E, Reason = never>(options: {
execution.owner = undefined
}).pipe(Effect.andThen(options.settled?.(key, exit, execution.interruptionReason) ?? Effect.void)),
),
Effect.onExit((exit) => finish(key, execution, exit)),
Effect.onExit((exit) => Effect.sync(() => settle(key, execution, exit))),
Effect.exit,
Effect.asVoid,
),
@@ -114,22 +97,12 @@ 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>, resume: boolean) => {
if (resume && execution.continuation) start(key, false, execution.continuation.request)
else if (execution.pendingWake) start(key, false, execution.pendingWake)
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
if (execution.pendingWake) start(key, false)
else executions.delete(key)
Deferred.doneUnsafe(execution.done, exit)
}
const finish = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
if (!execution.continuation) return Effect.sync(() => settle(key, execution, exit, false))
return execution.continuation.when.pipe(
Effect.flatMap((ready) =>
Effect.sync(() => settle(key, execution, exit, ready || execution.continuation?.signaled === true)),
),
)
}
const run = (key: Key): Effect.Effect<void, E> =>
Effect.suspend(() => {
const execution = executions.get(key)
@@ -138,58 +111,26 @@ 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, request: Request = "input") =>
const wake = (key: Key) =>
Effect.sync(() => {
const execution = executions.get(key)
if (execution !== undefined) {
if (execution.stopping) {
if (execution.continuation) execution.continuation.signaled = true
else execution.continuation = { request, when: Effect.succeed(true), signaled: true }
return
}
// Coalesced wakes keep the widest request: "input" subsumes "steer".
execution.pendingWake = execution.pendingWake === "input" ? "input" : request
execution.pendingWake = true
return
}
start(key, false, request)
start(key, false)
})
const wakeActive = (key: Key) =>
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
Effect.suspend(() => {
const execution = executions.get(key)
return execution ? wake(key, execution.request) : Effect.void
})
const interrupt = (
key: Key,
reason?: Reason,
options?: { readonly continue?: { readonly request: Request; readonly when: Effect.Effect<boolean> } },
): Effect.Effect<void> =>
Effect.suspend(() => {
const execution = executions.get(key)
if (execution === undefined) return Effect.void
if (execution.stopping) {
if (options?.continue)
execution.continuation = {
...options.continue,
signaled: execution.continuation?.signaled ?? false,
}
return Deferred.await(execution.done).pipe(Effect.exit, Effect.asVoid)
}
if (execution.owner === undefined) {
if (!options?.continue) return Effect.void
execution.stopping = true
execution.pendingWake = undefined
execution.continuation = { ...options.continue, signaled: false }
return Deferred.await(execution.done).pipe(Effect.exit, Effect.asVoid)
}
if (execution?.owner === undefined || execution.stopping) return Effect.void
execution.stopping = true
execution.pendingWake = undefined
execution.pendingWake = false
execution.interruptionReason = reason
if (options?.continue) execution.continuation = { ...options.continue, signaled: false }
return Fiber.interrupt(execution.owner)
})
@@ -202,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>
}
+14 -16
View File
@@ -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([
-2
View File
@@ -56,8 +56,6 @@ export const SessionTable = sqliteTable(
variant?: string
}>(),
...Timestamps,
time_idle: integer(),
time_viewed: integer(),
time_compacting: integer(),
time_archived: integer(),
/** The execution claim timestamp (historical column name; see SessionStore.claim). */
-4
View File
@@ -117,10 +117,6 @@ const layer = Layer.effect(
tokens_cache_write: input.data.info.tokens.cache.write,
time_created: DateTime.toEpochMillis(input.data.info.time.created),
time_updated: DateTime.toEpochMillis(input.data.info.time.updated),
time_idle: input.data.info.time.idle ? DateTime.toEpochMillis(input.data.info.time.idle) : null,
time_viewed: input.data.info.time.viewed
? DateTime.toEpochMillis(input.data.info.time.viewed)
: null,
time_archived: input.data.info.time.archived
? DateTime.toEpochMillis(input.data.info.time.archived)
: null,
+3 -3
View File
@@ -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,
+3 -3
View File
@@ -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,
+3 -3
View File
@@ -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,
+7 -7
View File
@@ -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,
+3 -3
View File
@@ -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,
+2 -2
View File
@@ -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
+3 -3
View File
@@ -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,
+1 -30
View File
@@ -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, () => {})
+1 -1
View File
@@ -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" })
@@ -13,7 +13,6 @@ import { tmpdir } from "./fixture/tmpdir"
import type { SqlClient } from "effect/unstable/sql/SqlClient"
import legacyCredentialsMigration from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
import worktreeMigration from "@opencode-ai/core/database/migration/20260812213948_worktree"
import sessionViewedStateMigration from "@opencode-ai/core/database/migration/20260815182818_session_viewed_state"
import { Global } from "@opencode-ai/util/global"
const run = <A, E>(
@@ -74,27 +73,6 @@ describe("DatabaseMigration", () => {
)
})
test("adds nullable attention state to existing sessions", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE session_v2 (id text PRIMARY KEY, title text)`)
yield* db.run(sql`INSERT INTO session_v2 (id, title) VALUES ('ses_existing', 'Existing')`)
yield* DatabaseMigration.applyOnly(db, [sessionViewedStateMigration])
yield* DatabaseMigration.applyOnly(db, [sessionViewedStateMigration])
expect(yield* db.get(sql`SELECT id, title, time_idle, time_viewed FROM session_v2`)).toEqual({
id: "ses_existing",
title: "Existing",
time_idle: null,
time_viewed: null,
})
expect(yield* db.get(sql`SELECT count(*) AS count FROM migration`)).toEqual({ count: 1 })
}),
)
})
test("rejects a non-empty database without a session table", async () => {
await expect(
run(
+9 -9
View File
@@ -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" })
@@ -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",
})
})
+1 -107
View File
@@ -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(
-2
View File
@@ -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)
}),
),
+3 -16
View File
@@ -840,15 +840,7 @@ describe("SessionTransfer", () => {
const imported = yield* transfer.import({
data: {
info: {
...template,
id: sessionID,
time: {
...template.time,
idle: DateTime.makeUnsafe(200),
viewed: DateTime.makeUnsafe(150),
},
},
info: { ...template, id: sessionID },
messages: [
{
id: sourceMessageID,
@@ -871,18 +863,13 @@ describe("SessionTransfer", () => {
const messages = yield* session.messages({ sessionID, order: "asc" })
expect(imported).toMatchObject({ id: sessionID, title: "Exported", location })
expect(imported.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) })
expect(messages).toMatchObject([
{ id: sourceMessageID, type: "user", text: "Imported message" },
{ id: errorMessageID, type: "compaction", error: { type: "test_error", message: "Original error" } },
])
expect(yield* Bus.latestSequence(db, sessionID)).toBe(2)
const exported = yield* transfer.export({ sessionID })
expect(exported.info.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) })
expect(exported.messages).toEqual(messages)
const sanitized = yield* transfer.export({ sessionID, sanitize: true })
expect(sanitized.info.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) })
expect(sanitized.messages).toMatchObject([
expect((yield* transfer.export({ sessionID })).messages).toEqual(messages)
expect((yield* transfer.export({ sessionID, sanitize: true })).messages).toMatchObject([
{ id: sourceMessageID, text: `[redacted:text:${sourceMessageID}]` },
{ id: errorMessageID, error: { type: "test_error", message: "Original error" } },
])
@@ -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,
+17 -7
View File
@@ -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([])
}),
)
@@ -269,35 +269,6 @@ describe("SessionRunCoordinator", () => {
),
)
it.effect("replaces a settlement-window wake with a steer continuation", () =>
Effect.scoped(
Effect.gen(function* () {
const settling = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const requests: SessionRunCoordinator.Request[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, _force, request) => Effect.sync(() => requests.push(request)),
settled: () => Deferred.succeed(settling, undefined).pipe(Effect.andThen(Deferred.await(release))),
})
yield* coordinator.wake("session", "input")
yield* Deferred.await(settling)
yield* coordinator.wake("session", "input")
const interrupted = yield* coordinator
.interrupt("session", undefined, {
continue: { request: "steer", when: Effect.succeed(true) },
})
.pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(interrupted)
yield* coordinator.awaitIdle("session")
expect(requests).toEqual(["input", "steer"])
}),
),
)
it.effect("interrupts active execution and clears its pending wake", () =>
Effect.scoped(
Effect.gen(function* () {
@@ -371,193 +342,6 @@ describe("SessionRunCoordinator", () => {
),
)
it.effect("coalesces drain requests with input taking precedence", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const requests: SessionRunCoordinator.Request[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, _force, request) =>
Effect.gen(function* () {
requests.push(request)
if (requests.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(requests).toEqual(["steer", "input"])
}),
),
)
it.effect("does not carry a completed input request into a steer drain", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const requests: SessionRunCoordinator.Request[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, _force, request) =>
Effect.gen(function* () {
requests.push(request)
if (requests.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(requests).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 requests: SessionRunCoordinator.Request[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, _force, request) =>
Effect.gen(function* () {
requests.push(request)
if (requests.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(requests).toEqual(["steer", "steer"])
}),
),
)
it.effect("coalesces overlapping interrupt continuations into one steer successor", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const requests: SessionRunCoordinator.Request[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, _force, request) =>
Effect.gen(function* () {
requests.push(request)
if (requests.length !== 1) return
yield* Deferred.succeed(firstStarted, undefined)
yield* Effect.never.pipe(
Effect.onInterrupt(() =>
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
)
}),
})
const continuation = { continue: { request: "steer" as const, when: Effect.succeed(false) } }
yield* coordinator.wake("session")
yield* Deferred.await(firstStarted)
const first = yield* coordinator.interrupt("session", undefined, continuation).pipe(Effect.forkChild)
yield* Deferred.await(cleanupStarted)
const second = yield* coordinator.interrupt("session", undefined, continuation).pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* coordinator.wake("session", "input")
yield* Deferred.succeed(cleanupGate, undefined)
yield* Effect.all([Fiber.join(first), Fiber.join(second)])
yield* coordinator.awaitIdle("session")
expect(requests).toEqual(["input", "steer"])
}),
),
)
it.effect("a continuing interrupt replaces a cleanup-era input wake", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const requests: SessionRunCoordinator.Request[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, _force, request) =>
Effect.gen(function* () {
requests.push(request)
if (requests.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 plain = yield* coordinator.interrupt("session").pipe(Effect.forkChild)
yield* Deferred.await(cleanupStarted)
yield* coordinator.wake("session", "input")
const continuing = yield* coordinator
.interrupt("session", undefined, {
continue: { request: "steer", when: Effect.succeed(false) },
})
.pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* Deferred.succeed(cleanupGate, undefined)
yield* Effect.all([Fiber.join(plain), Fiber.join(continuing)])
yield* coordinator.awaitIdle("session")
expect(requests).toEqual(["input", "steer"])
}),
),
)
it.effect("does not start a conditional continuation without eligible work", () =>
Effect.scoped(
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const requests: SessionRunCoordinator.Request[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, _force, request) =>
Effect.sync(() => requests.push(request)).pipe(
Effect.andThen(Deferred.succeed(started, undefined)),
Effect.andThen(Effect.never),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(started)
yield* coordinator.interrupt("session", undefined, {
continue: { request: "steer", when: Effect.succeed(false) },
})
yield* coordinator.awaitIdle("session")
expect(requests).toEqual(["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,
})
}),
+1 -58
View File
@@ -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
-114
View File
@@ -1,114 +0,0 @@
import { describe, expect } from "bun:test"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { DateTime, Effect } from "effect"
import { eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
import { globalProjectLayer } from "./lib/project"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[Bus.node, Bus.configured({ persist: true })],
[Project.node, globalProjectLayer],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
)
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
describe("Session.view", () => {
it.effect("copies the latest idle time without changing session recency", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const created = yield* session.create({ location })
expect(created.time.idle).toBeUndefined()
expect(created.time.viewed).toBeUndefined()
yield* Effect.all([session.view({ sessionID: created.id }), session.view({ sessionID: created.id })], {
concurrency: "unbounded",
discard: true,
})
expect((yield* session.get(created.id)).time.viewed).toBeUndefined()
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: created.id })
const idle = yield* session.get(created.id)
expect(idle.time.idle).toBeDefined()
expect(idle.time.viewed).toBeUndefined()
expect(idle.time.updated).toEqual(created.time.updated)
yield* session.view({ sessionID: created.id })
const viewed = yield* session.get(created.id)
if (!viewed.time.idle || !viewed.time.viewed) return yield* Effect.die(new Error("Expected attention times"))
expect(viewed.time.viewed).toEqual(viewed.time.idle)
expect(viewed.time.updated).toEqual(created.time.updated)
expect(
yield* db
.select({ idle: SessionTable.time_idle, viewed: SessionTable.time_viewed })
.from(SessionTable)
.where(eq(SessionTable.id, created.id))
.get(),
).toEqual({
idle: DateTime.toEpochMillis(viewed.time.idle),
viewed: DateTime.toEpochMillis(viewed.time.viewed),
})
expect((yield* session.list()).data.find((item) => item.id === created.id)?.time).toEqual(viewed.time)
yield* session.view({ sessionID: created.id })
expect((yield* session.get(created.id)).time).toEqual(viewed.time)
yield* bus.publish(SessionEvent.Execution.Failed, {
sessionID: created.id,
error: { type: "unknown", message: "failed" },
})
const unread = yield* session.get(created.id)
if (!unread.time.idle || !unread.time.viewed) return yield* Effect.die(new Error("Expected attention times"))
expect(DateTime.toEpochMillis(unread.time.idle)).toBeGreaterThan(DateTime.toEpochMillis(unread.time.viewed))
yield* session.view({ sessionID: created.id })
expect((yield* session.get(created.id)).time.viewed).toEqual(unread.time.idle)
yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID: created.id, reason: "shutdown" })
expect((yield* session.get(created.id)).time.idle).toEqual(unread.time.idle)
yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID: created.id, reason: "user" })
const interrupted = yield* session.get(created.id)
if (!interrupted.time.idle || !interrupted.time.viewed)
return yield* Effect.die(new Error("Expected attention times"))
expect(DateTime.toEpochMillis(interrupted.time.idle)).toBeGreaterThan(
DateTime.toEpochMillis(interrupted.time.viewed),
)
expect(
(yield* db
.select({ type: EventTable.type })
.from(EventTable)
.where(eq(EventTable.aggregate_id, created.id))
.all()).filter((event) => event.type === "session.viewed.1"),
).toHaveLength(2)
}),
)
it.effect("rejects an unknown session", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const sessionID = Session.ID.make("ses_missing_view")
expect(yield* Effect.flip(session.view({ sessionID }))).toEqual(new Session.NotFoundError({ sessionID }))
}),
)
})
+3 -3
View File
@@ -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,
+6 -7
View File
@@ -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,
+5 -5
View File
@@ -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 })],
+3 -3
View File
@@ -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")
+2 -3
View File
@@ -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,
-1
View File
@@ -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),
})
+3 -3
View File
@@ -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,
-2
View File
@@ -60,8 +60,6 @@ const session = (
model: null,
time_created: 1,
time_updated: 2,
time_idle: null,
time_viewed: null,
time_compacting: 3,
time_archived: null,
time_suspended: null,
File diff suppressed because it is too large Load Diff
+1 -14
View File
@@ -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.",
}),
),
)
@@ -693,19 +693,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}),
),
)
.add(
HttpApiEndpoint.post("session.view", "/api/session/:sessionID/view", {
params: { sessionID: Session.ID },
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.view",
summary: "View session",
description: "Mark the latest recorded idle transition as viewed.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "session",
+2 -5
View File
@@ -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: "" }),
})),
)
-8
View File
@@ -105,13 +105,6 @@ export const Renamed = Event.durable({
})
export type Renamed = typeof Renamed.Type
export const Viewed = Event.durable({
type: "session.viewed",
...options,
schema: Base,
})
export type Viewed = typeof Viewed.Type
export const UsageRecorded = Event.durable({
type: "session.usage.recorded",
...options,
@@ -587,7 +580,6 @@ export const Definitions = Event.inventory(
ModelSelected,
Moved,
Renamed,
Viewed,
UsageUpdated,
Deleted,
Forked,
-2
View File
@@ -40,8 +40,6 @@ export const Info = Schema.Struct({
time: Schema.Struct({
created: DateTimeUtcFromMillis,
updated: DateTimeUtcFromMillis,
idle: DateTimeUtcFromMillis.pipe(optional),
viewed: DateTimeUtcFromMillis.pipe(optional),
archived: DateTimeUtcFromMillis.pipe(optional),
}),
title: Schema.String.pipe(optional),
+9 -23
View File
@@ -54,29 +54,17 @@ describe("contract hygiene", () => {
}),
).toEqual({ text: "completed" })
const info = Session.Info.make({
id: Session.ID.make("ses_untitled"),
projectID: Project.ID.make("global"),
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: {
created: DateTime.makeUnsafe(0),
updated: DateTime.makeUnsafe(0),
idle: undefined,
viewed: undefined,
},
title: undefined,
location: { directory: AbsolutePath.make("/project") },
})
const encoded = Schema.encodeSync(Session.Info)(info)
expect(encoded).not.toHaveProperty("title")
expect(encoded.time).toEqual({ created: 0, updated: 0 })
expect(
Schema.encodeSync(Session.Info)({
...info,
time: { ...info.time, idle: DateTime.makeUnsafe(2), viewed: DateTime.makeUnsafe(1) },
}).time,
).toEqual({ created: 0, updated: 0, idle: 2, viewed: 1 })
id: Session.ID.make("ses_untitled"),
projectID: Project.ID.make("global"),
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
title: undefined,
location: { directory: AbsolutePath.make("/project") },
}),
).not.toHaveProperty("title")
})
test("session inbox items omit the internal enqueue sequence", () => {
@@ -132,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,
@@ -83,7 +83,6 @@ describe("public event manifest", () => {
"session.model.selected.1",
"session.moved.1",
"session.renamed.1",
"session.viewed.1",
"session.usage.recorded.1",
"session.forked.2",
"session.inbox.delivered.1",
-16
View File
@@ -180,22 +180,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}
}),
)
.handle(
"session.view",
Effect.fn(function* (ctx) {
yield* session.view({ sessionID: ctx.params.sessionID }).pipe(
Effect.catchTag(
"Session.NotFoundError",
(error) =>
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.remove",
Effect.fn(function* (ctx) {
-30
View File
@@ -52,36 +52,6 @@ it.live("serves unauthenticated and answers CORS preflight when no password is c
}).pipe(Effect.scoped),
)
it.live("serves the session view operation and missing-session error", () =>
Effect.gen(function* () {
const handler = yield* ServerFetch.make(options)
const created = yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/session", {
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
}),
).then((response) => response.json()),
)
if (typeof created !== "object" || created === null || !("data" in created))
return yield* Effect.die(new Error("Expected a session response"))
const data = created.data
if (typeof data !== "object" || data === null || !("id" in data) || typeof data.id !== "string")
return yield* Effect.die(new Error("Expected a session ID"))
const viewed = yield* Effect.promise(() =>
handler(new Request(`http://opencode.local/api/session/${data.id}/view`, { method: "POST" })),
)
expect(viewed.status).toBe(204)
const missing = yield* Effect.promise(() =>
handler(new Request("http://opencode.local/api/session/ses_missing_view/view", { method: "POST" })),
)
expect(missing.status).toBe(404)
}).pipe(Effect.scoped),
)
// Pins the eager-boot guarantee: the application layer is built before the handler returns, so
// an aborted first request cannot interrupt layer construction and wedge every later request
// (the Effect-TS/effect#6319 failure class that lazy first-request builds are prone to).
@@ -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",
+2 -17
View File
@@ -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: "",
}
}
-4
View File
@@ -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")
+1096 -4468
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff