Compare commits

..
54 changed files with 2671 additions and 1068 deletions
+8
View File
@@ -0,0 +1,8 @@
---
"@opencode-ai/core": minor
"@opencode-ai/schema": patch
---
Open durable sessions with in-process model, tool, instruction, and permission capabilities. Live Sources update at safe boundaries through existing instruction epochs, while capability replacement waits for the next busy period. Capability-owned sessions remain pending after restart until their host reopens and drives them.
Close an open's in-process capabilities after settlement without deleting durable history. Tool executors may yield domain errors, which normalize to tool failures while canonical permission declines retain their interruption behavior.
@@ -208,56 +208,6 @@ test("navigates from a running subagent card and hides background controls in th
await expect(page.getByText(/move running work to the background/i)).toHaveCount(0)
})
for (const name of ["shell", "subagent"] as const) {
test(`keeps the background shortcut available for a grouped running ${name}`, async ({ page }) => {
const message = assistant(false, true)
await setupTimeline(page, {
sessionMessages: [
user,
{
...message,
content: [
{
type: "tool",
id: "call_read",
name: "read",
state: {
status: "completed",
input: { path: "src/example.ts" },
content: [{ type: "text", text: "export const example = true" }],
metadata: {},
},
time: { created: 1, completed: 2 },
},
{
type: "tool",
id: "call_running",
name,
state: {
status: "running",
input:
name === "shell" ? { command: "echo checking" } : { agent: "general", description: "Inspect code" },
metadata: {},
},
time: { created: 3 },
},
],
},
],
})
const group = page.locator('[data-timeline-part-ids="call_read,call_running"]')
await expect(group).toBeVisible()
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
await expect(page.locator('[data-component="session-background-hint"]')).toBeVisible()
const request = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/background`,
)
await page.keyboard.press("Control+b")
await request
})
}
test("shows a badge for active background work", async ({ page }) => {
const childID = "ses_background_child"
await setupTimeline(page, {
@@ -44,7 +44,7 @@ test("expands a mixed collapsed tool stack without expanding its individual call
const group = page.locator(
'[data-timeline-part-ids="prt_stack_shell_1,prt_stack_explore,prt_stack_patch,prt_stack_shell_2"]',
)
const summary = group.getByRole("button", { name: "Used Shell, Agent, Patch" })
const summary = group.getByRole("button", { name: "Used Shell, Explore, Patch" })
await expect(summary).toHaveAttribute("aria-expanded", "false")
await expect(summary).toHaveCSS("height", "28px")
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
@@ -52,7 +52,7 @@ test("shows parent lineage while the child timeline loads", async ({ page }) =>
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used Agent" }).click()
await page.getByRole("button", { name: "Used Explore" }).click()
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))])
await Promise.all([
@@ -77,7 +77,7 @@ test("keeps the parent visible while the child session resolves", async ({ page
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used Agent" }).click()
await page.getByRole("button", { name: "Used Explore" }).click()
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await requested.promise
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)]).finally(
@@ -195,7 +195,7 @@ async function setup(page: Page, events?: () => OpenCodeEvent[]) {
async function openChildFromParent(page: Page) {
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used Agent" }).click()
await page.getByRole("button", { name: "Used Explore" }).click()
const card = page.locator(`a[href="${sessionHref(childID)}"]`)
await expect(card).toBeVisible()
@@ -349,7 +349,8 @@ function MessageTimelineView(
: projects.find((item) => containsDirectory(item.worktree, sessionDirectory()))
})
const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), sessionDirectory()))
const showProjectIcon = () => import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && settings.general.showProjectIcon()
const showProjectIcon = () =>
import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && settings.general.showProjectIcon()
const avatarProject = createMemo(() => {
if (!showProjectIcon()) return
const session = props.session.data.info()
@@ -468,13 +469,13 @@ function MessageTimelineView(
})
const backgroundHintPartID = createMemo(() => {
const blocking = new Set(props.background.blocking().map((task) => task.partID))
if (blocking.size === 0) return
return projection
const row = projection
.rows()
.flatMap((row) =>
row._tag === "AssistantPart" ? (row.group.type === "part" ? [row.group.ref] : row.group.refs) : [],
.findLast(
(row) => row._tag === "AssistantPart" && row.group.type === "part" && blocking.has(row.group.ref.partID),
)
.findLast((ref) => blocking.has(ref.partID))?.partID
if (row?._tag !== "AssistantPart" || row.group.type !== "part") return
return row.group.ref.partID
})
const [backgroundHintRef, setBackgroundHintRef] = createSignal<HTMLDivElement>()
const backgroundHintPresence = createAnimatedPresence(backgroundHintPartID, () => backgroundHintRef() ?? null)
+1 -1
View File
@@ -55,7 +55,7 @@ export interface Interface extends State.Transformable<Draft> {
export class Service extends Context.Service<Service, Interface>()("@opencode/Image") {}
const layer = Layer.effect(
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = State.create<Limits, Draft>({
+1 -8
View File
@@ -402,14 +402,7 @@ export const make = Effect.gen(function* () {
})
const pendingBackground: Interface["pendingBackground"] = Effect.gen(function* () {
const recovered: Background[] = []
let after: string | undefined
do {
const page = yield* kv.scan({ prefix: backgroundPrefix, after })
recovered.push(...Array.filterMap(page.entries, (entry) => decodeBackground(entry.value)))
after = page.next
} while (after)
return recovered
return Array.filterMap(yield* kv.scanAll(backgroundPrefix), (entry) => decodeBackground(entry.value))
}).pipe(Effect.withSpan("Job.pendingBackground"))
const completeBackground: Interface["completeBackground"] = Effect.fn("Job.completeBackground")((notificationID) =>
+33 -20
View File
@@ -29,6 +29,7 @@ export interface Interface {
readonly set: (key: string, value: Value) => Effect.Effect<void>
readonly remove: (key: string) => Effect.Effect<void>
readonly scan: (options: ScanOptions) => Effect.Effect<ScanResult>
readonly scanAll: (prefix: string) => Effect.Effect<readonly Entry[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/KV") {}
@@ -37,6 +38,28 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const db = (yield* Database.Service).db
const scan: Interface["scan"] = Effect.fn("KV.scan")(function* (options) {
const limit = Number.isNaN(options.limit) ? 100 : Math.min(Math.max(Math.floor(options.limit ?? 100), 1), 1000)
const end = prefixEnd(options.prefix)
const rows = yield* db
.select({ key: KVTable.key, value: KVTable.value })
.from(KVTable)
.where(
and(
options.prefix === "" ? undefined : gte(KVTable.key, options.prefix),
end === undefined ? undefined : lt(KVTable.key, end),
options.after === undefined ? undefined : gt(KVTable.key, options.after),
),
)
.orderBy(asc(KVTable.key))
.limit(limit + 1)
.all()
.pipe(Effect.orDie)
const entries = rows.slice(0, limit)
if (rows.length <= limit) return { entries }
return { entries, next: entries[entries.length - 1].key }
})
return Service.of({
get: Effect.fn("KV.get")(function* (key) {
return (yield* db
@@ -57,26 +80,16 @@ const layer = Layer.effect(
remove: Effect.fn("KV.remove")(function* (key) {
yield* db.delete(KVTable).where(eq(KVTable.key, key)).run().pipe(Effect.orDie)
}),
scan: Effect.fn("KV.scan")(function* (options) {
const limit = Number.isNaN(options.limit) ? 100 : Math.min(Math.max(Math.floor(options.limit ?? 100), 1), 1000)
const end = prefixEnd(options.prefix)
const rows = yield* db
.select({ key: KVTable.key, value: KVTable.value })
.from(KVTable)
.where(
and(
options.prefix === "" ? undefined : gte(KVTable.key, options.prefix),
end === undefined ? undefined : lt(KVTable.key, end),
options.after === undefined ? undefined : gt(KVTable.key, options.after),
),
)
.orderBy(asc(KVTable.key))
.limit(limit + 1)
.all()
.pipe(Effect.orDie)
const entries = rows.slice(0, limit)
if (rows.length <= limit) return { entries }
return { entries, next: entries[entries.length - 1].key }
scan,
scanAll: Effect.fn("KV.scanAll")(function* (prefix) {
const entries: Entry[] = []
let after: string | undefined
do {
const page = yield* scan({ prefix, after, limit: 1000 })
entries.push(...page.entries)
after = page.next
} while (after !== undefined)
return entries
}),
})
}),
+4 -4
View File
@@ -100,6 +100,10 @@ export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
return rulesets.flat()
}
export function relevant(input: Pick<Request, "action">, rules: Permission.Ruleset) {
return rules.filter((rule) => Wildcard.match(input.action, rule.action))
}
export interface Interface {
readonly ask: (input: AssertInput) => Effect.Effect<AskResult, SessionErrors.NotFoundError>
readonly assert: (input: AssertInput) => Effect.Effect<void, Error | SessionErrors.NotFoundError>
@@ -161,10 +165,6 @@ const layer = Layer.effect(
return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny")
}
function relevant(input: AssertInput, rules: Permission.Ruleset) {
return rules.filter((rule) => Wildcard.match(input.action, rule.action))
}
const evaluateInput = Effect.fnUntraced(function* (input: AssertInput) {
const rules = yield* configured(input.sessionID, input.agent)
if (denied(input, rules)) return { effect: "deny" as const, rules }
+38
View File
@@ -0,0 +1,38 @@
export * as Permissions from "./permissions.js"
import { Effect } from "effect"
import { Permission } from "./permission.js"
import type { SessionSchema } from "./session/schema.js"
import { Source } from "./source.js"
export interface Interface {
readonly visibility: Source.Interface<Permission.Ruleset>
readonly ask: (
session: SessionSchema.Info,
request: Omit<Permission.AssertInput, "sessionID" | "agent">,
) => Effect.Effect<void, Permission.Error | Permission.DeclinedError>
}
export const allowAll: Interface = {
visibility: Source.constant([{ action: "*", resource: "*", effect: "allow" }]),
ask: () => Effect.void,
}
export function rules(source: Source.Value<Permission.Ruleset>): Interface {
const visibility = Source.from(source)
return {
visibility,
ask: Effect.fn("Permissions.ask")(function* (session, request) {
const rules = yield* visibility.get(session)
if (
request.resources.every((resource) => Permission.evaluate(request.action, resource, rules).effect === "allow")
)
return
return yield* new Permission.BlockedError({
rules: Permission.relevant(request, rules),
permission: request.action,
resources: request.resources,
})
}),
}
}
+1 -1
View File
@@ -60,7 +60,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginHooks") {}
const layer = Layer.effect(
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const callbacks = new Map<string, Entry[]>()
+10 -6
View File
@@ -46,6 +46,15 @@ export interface Resolved {
readonly vcsBackend?: string
}
export function markerless(directory: AbsolutePath): Resolved {
return {
id: ID.make(Hash.fast(`directory:${directory}`)),
directory,
canonical: directory,
vcs: undefined,
}
}
// Keep this filesystem-only; permission checks use it and should not execute VCS commands.
export const root = Effect.fn("Project.root")(function* (
fs: FSUtil.Interface,
@@ -361,12 +370,7 @@ const layer = Layer.effect(
})
}
return yield* persist({
id: ID.make(Hash.fast(`directory:${directory}`)),
directory,
canonical: directory,
vcs: undefined,
})
return yield* persist(markerless(directory))
})
return Service.of({ list, update, resolve })
+116 -67
View File
@@ -1,5 +1,6 @@
export * as Session from "./session.js"
export * from "./session/schema.js"
export type { OpenInput, Handle } from "./session/capabilities.js"
import { Cause, Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session"
@@ -55,6 +56,8 @@ import { fileURLToPath } from "url"
import { SessionEnvironment } from "./session/environment.js"
import { SessionHistory } from "./session/history.js"
import { InstructionEntry } from "./session/instruction-entry.js"
import { SessionResolve } from "./session/resolve.js"
import type { SessionCapabilities } from "./session/capabilities.js"
// get project -> project.locations
//
@@ -187,6 +190,7 @@ export const MessageNotFoundError = SessionRevert.MessageNotFoundError
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
export interface Interface {
readonly open: (input: SessionCapabilities.OpenInput) => Effect.Effect<SessionCapabilities.Handle>
readonly list: (input?: ListInput) => Effect.Effect<{
readonly data: SessionSchema.Info[]
}>
@@ -341,6 +345,7 @@ const layer = Layer.effect(
const execution = yield* SessionExecution.Service
const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap.Service
const resolve = yield* SessionResolve.Service
const fs = yield* FSUtil.Service
const jobs = yield* Job.Service
const environments = yield* SessionEnvironment.Service
@@ -348,6 +353,9 @@ const layer = Layer.effect(
const activeShells = new Set<SessionSchema.ID>()
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
const closeTransport = Effect.fn("Session.closeTransport")(function* (session: SessionSchema.Info) {
const resolved = yield* resolve.resolve(session)
if (resolved.status === "attached") return yield* resolved.capabilities.transport.close(session.id)
if (resolved.status === "owned-detached") return
const location = Location.Ref.make({
directory: session.location.directory,
workspaceID: session.location.workspaceID,
@@ -356,7 +364,7 @@ const layer = Layer.effect(
yield* SessionModelTransport.Service.use((transport) => transport.close(session.id)).pipe(
Effect.provide(locations.get(location)),
)
})
}, Effect.scoped)
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
const persistProject = (project: Project.Resolved) => upsertProject(db, project).pipe(Effect.orDie)
@@ -383,61 +391,90 @@ const layer = Layer.effect(
}),
)
const create = Effect.fn("Session.create")(function* (input: CreateInput, resolved?: Project.Resolved) {
const sessionID = input.id ?? SessionSchema.ID.create()
const recorded = yield* store.get(sessionID)
if (recorded) return recorded
const parent = input.parentID ? yield* store.get(input.parentID) : undefined
if (input.parentID && parent === undefined) return yield* new NotFoundError({ sessionID: input.parentID })
const location = parent?.location ?? input.location
if (location === undefined)
return yield* Effect.die(new Error("Session.create requires either location or an existing parentID"))
const project = resolved ?? (yield* projects.resolve(location.directory))
yield* persistProject(project)
const projected = yield* bus
.publish(
SessionEvent.Created,
{
sessionID,
slug: Slug.create(),
version: app.version,
projectID: project.id,
parentID: input.parentID,
location,
subpath: RelativePath.make(path.relative(project.directory, location.directory).replaceAll("\\", "/")),
title: input.title,
agent: input.agent,
model: input.model
? {
id: Model.ID.make(input.model.id),
providerID: input.model.providerID,
variant: input.model.variant,
}
: undefined,
},
{ location },
)
.pipe(
Effect.as({ type: "created" } as const),
Effect.catchDefect((defect) => {
if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
return Effect.die(defect)
}
// Concurrent creation lost the projection race. The existing Session identity wins.
return store
.get(sessionID)
.pipe(
Effect.flatMap((session) =>
session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
),
)
}),
)
if (projected.type === "existing") return projected.session
// TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice.
return yield* result.get(sessionID).pipe(Effect.orDie)
})
const result = Service.of({
create: Effect.fn("Session.create")(function* (input) {
const sessionID = input.id ?? SessionSchema.ID.create()
const recorded = yield* store.get(sessionID)
if (recorded) return recorded
const parent = input.parentID ? yield* store.get(input.parentID) : undefined
if (input.parentID && parent === undefined) return yield* new NotFoundError({ sessionID: input.parentID })
const location = parent?.location ?? input.location
if (location === undefined)
return yield* Effect.die(new Error("Session.create requires either location or an existing parentID"))
const project = yield* projects.resolve(location.directory)
yield* persistProject(project)
const projected = yield* bus
.publish(
SessionEvent.Created,
{
sessionID,
slug: Slug.create(),
version: app.version,
projectID: project.id,
parentID: input.parentID,
location,
subpath: RelativePath.make(path.relative(project.directory, location.directory).replaceAll("\\", "/")),
title: input.title,
agent: input.agent,
model: input.model
? {
id: Model.ID.make(input.model.id),
providerID: input.model.providerID,
variant: input.model.variant,
}
: undefined,
},
{ location },
)
.pipe(
Effect.as({ type: "created" } as const),
Effect.catchDefect((defect) => {
if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
return Effect.die(defect)
}
// Concurrent creation lost the projection race. The existing Session identity wins.
return store
.get(sessionID)
.pipe(
Effect.flatMap((session) =>
session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
),
)
}),
)
if (projected.type === "existing") return projected.session
// TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice.
return yield* result.get(sessionID).pipe(Effect.orDie)
}),
create,
open: Effect.fn("Session.open")((input) =>
Effect.uninterruptible(
Effect.gen(function* () {
const directory = AbsolutePath.make(process.cwd())
// Transitional placement is unused by supplied capabilities, but listings group
// these Sessions under the deterministic cwd-derived project. Adoption never moves it.
const session = yield* resolve.own(
create(
{ id: input.id, title: input.title, location: Location.Ref.make({ directory }) },
Project.markerless(directory),
).pipe(Effect.orDie),
)
const close = yield* resolve.attach(session.id, input)
return {
id: session.id,
prompt: (prompt) =>
Effect.gen(function* () {
const admitted = yield* result.prompt({ ...prompt, sessionID: session.id, resume: false })
if (prompt.resume !== false) yield* result.resume(session.id)
return admitted
}),
resume: () => result.resume(session.id),
interrupt: (options) => result.interrupt(session.id, options),
close,
} satisfies SessionCapabilities.Handle
}),
),
),
fork: Effect.fn("Session.fork")(function* (input) {
const parent = yield* result.get(input.sessionID)
const boundary = yield* db
@@ -510,6 +547,7 @@ const layer = Layer.effect(
yield* Effect.forEach(children.data, (child) => result.remove(child.id), { concurrency: 1, discard: true })
yield* environments.clear(sessionID)
yield* bus.publish(SessionEvent.Deleted, { sessionID })
yield* resolve.remove(sessionID)
yield* bus.remove(sessionID)
}),
list: Effect.fn("Session.list")(function* (input = {}) {
@@ -648,16 +686,24 @@ const layer = Layer.effect(
if (session.revert) yield* SessionRevert.commit(session).pipe(Effect.provideService(Bus.Service, bus))
// Resolved lazily so prompt admission only boots location services when an
// image attachment actually needs the resizer.
const image = Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Image.Service
}).pipe(Effect.provide(locations.get(session.location)))
const skills = Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Skill.Service
}).pipe(Effect.provide(locations.get(session.location)))
const resolved = input.files?.length || input.skills?.length ? yield* resolve.resolve(session) : undefined
// TODO: typed unavailable-operation errors belong to the capability-gated operations phase.
if (resolved?.status === "owned-detached") return yield* SessionResolve.unavailable(session.id)
const capabilities = resolved?.status === "attached" ? resolved.capabilities : undefined
const image = capabilities
? Effect.succeed(capabilities.image)
: Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Image.Service
}).pipe(Effect.provide(locations.get(session.location)))
const skills = capabilities
? Effect.undefined
: Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Skill.Service
}).pipe(Effect.provide(locations.get(session.location)))
const prompt = yield* resolvePrompt(
{ text: input.text, files: input.files, agents: input.agents, skills: input.skills },
image,
@@ -690,7 +736,7 @@ const layer = Layer.effect(
}
return admitted
}),
),
).pipe(Effect.scoped),
),
generate: Effect.fn("Session.generate")(function* (input) {
const session = yield* result.get(input.sessionID)
@@ -778,6 +824,7 @@ const layer = Layer.effect(
}),
skill: Effect.fn("Session.skill")(function* (input) {
const session = yield* result.get(input.sessionID)
if (resolve.status(session.id) !== "unowned") return yield* new SkillNotFoundError({ skill: input.skill })
const skills = yield* Skill.Service.pipe(Effect.provide(locations.get(session.location)))
const skill = yield* skills.get(input.skill)
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
@@ -1021,7 +1068,7 @@ function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Inf
const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (
input: PromptInput.Prompt,
image: Effect.Effect<Image.Interface>,
skills: Effect.Effect<Skill.Interface>,
skills: Effect.Effect<Skill.Interface | undefined>,
) {
const fs = yield* FSUtil.Service
const files = input.files
@@ -1031,6 +1078,7 @@ const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (
const selected = yield* Effect.gen(function* () {
if (!requested?.length) return undefined
const skillService = yield* skills
if (!skillService) return yield* new SkillNotFoundError({ skill: requested[0].id })
const prepared = new Map<Skill.ID, Skill.Name>()
return yield* Effect.forEach(requested, (attachment) =>
Effect.gen(function* () {
@@ -1201,6 +1249,7 @@ export const node = makeGlobalNode({
Project.node,
SessionExecution.node,
SessionStore.node,
SessionResolve.node,
LocationServiceMap.node,
SessionProjector.node,
FSUtil.node,
+38
View File
@@ -0,0 +1,38 @@
export * as SessionCapabilities from "./capabilities.js"
import type { Effect } from "effect"
import type { Instructions } from "../instructions/index.js"
import type { Permissions } from "../permissions.js"
import type { Source } from "../source.js"
import type { Tool } from "../tool.js"
import type { Session } from "../session.js"
import type { SessionRunner } from "./runner/index.js"
import type { SessionRunnerModel } from "./runner/model.js"
import type { SessionSchema } from "./schema.js"
export interface OpenInput {
readonly id?: SessionSchema.ID
readonly title?: string
readonly model: Source.Value<SessionRunnerModel.Resolved, SessionRunnerModel.Error>
readonly tools?: Source.Value<ReadonlyArray<Tool.Info>>
readonly instructions?: Source.Value<ReadonlyArray<string> | Instructions.Unavailable>
readonly permissions?: Permissions.Interface
readonly system?: Source.Value<string | Instructions.Unavailable>
readonly limits?: Source.Value<{ readonly steps?: number }>
/** Called after replacement or host teardown, once all in-flight work has settled. */
readonly retire?: () => Effect.Effect<void>
}
export interface Handle {
readonly id: SessionSchema.ID
readonly prompt: (
input: Omit<Parameters<Session.Interface["prompt"]>[0], "sessionID">,
) => Effect.Effect<
Effect.Success<ReturnType<Session.Interface["prompt"]>>,
Effect.Error<ReturnType<Session.Interface["prompt"]>> | SessionRunner.RunError
>
readonly resume: () => ReturnType<Session.Interface["resume"]>
readonly interrupt: (options?: { readonly continue?: boolean }) => Effect.Effect<boolean>
/** Releases this open's capabilities after settlement without interrupting or deleting the Session. */
readonly close: () => Effect.Effect<void>
}
+89 -87
View File
@@ -1,6 +1,6 @@
export * as SessionCompaction from "./compaction.js"
import { LLMClient, AIError, LLMEvent, LLMRequest, Message } from "@opencode-ai/ai"
import { LLMClient, AIError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Context, Effect, Layer, Stream } from "effect"
@@ -9,15 +9,14 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { llmClient } from "../effect/app-node-platform.js"
import { SessionEvent } from "./event.js"
import type { SessionContext } from "./context.js"
import type { Instructions } from "../instructions/index.js"
import type { AgentNotFoundError } from "./error.js"
import type { SessionMessage } from "./message.js"
import { SessionModelRequest } from "./model-request.js"
import type { SessionModelRequest } from "./model-request.js"
import type { SessionRunnerModel } from "./runner/model.js"
import { SessionSchema } from "./schema.js"
import { toSessionError } from "./to-session-error.js"
import { Token } from "../util/token.js"
import { SessionUsage } from "./usage.js"
import { Agent } from "../agent.js"
import { State } from "../state.js"
const DEFAULT_BUFFER = 20_000
@@ -78,25 +77,26 @@ export type AutoInput = {
readonly messages: readonly SessionMessage.Info[]
readonly resolved: SessionRunnerModel.Resolved
readonly prepare: SessionModelRequest.Interface["prepare"]
/** The runner resolves the conversation agent only when there is history to compact. */
readonly context: Effect.Effect<
SessionContext.Loaded,
AgentNotFoundError | SessionRunnerModel.Error | Instructions.InitializationBlocked
>
}
type RequiredInput = Pick<AutoInput, "messages" | "resolved">
export type ManualInput = Pick<AutoInput, "session" | "messages" | "context" | "prepare"> & {
export type ManualInput = {
readonly session: SessionSchema.Info
readonly messages: readonly SessionMessage.Info[]
readonly inputID: SessionMessage.ID
readonly started?: boolean
/** Invoked after content planning, not when the caller captures the operation. */
readonly resolveModel: SessionContext.Interface["resolveModel"]
readonly prepare: SessionModelRequest.Interface["prepare"]
}
type Plan = {
readonly session: SessionSchema.Info
readonly context: AutoInput["context"]
readonly resolved: SessionRunnerModel.Resolved
readonly reason: SessionMessage.Compaction["reason"]
readonly messages: readonly SessionMessage.Info[]
readonly prompt: string
readonly recent: string
readonly inputID?: SessionMessage.ID
readonly started?: boolean
readonly prepare: SessionModelRequest.Interface["prepare"]
@@ -176,7 +176,10 @@ const serialize = (message: SessionMessage.Info) => {
return ""
}
const select = (messages: readonly SessionMessage.Info[], tokens: number) => {
const select = (
messages: readonly SessionMessage.Info[],
tokens: number,
): { readonly head: string; readonly recent: string } | undefined => {
const conversation = messages
.filter((message) => message.type !== "compaction" && message.type !== "system")
.flatMap((message) => {
@@ -198,8 +201,10 @@ const select = (messages: readonly SessionMessage.Info[], tokens: number) => {
if (latestUser > 0) split = latestUser
}
return {
split: messages.indexOf(conversation[split].message),
hasHead: split > 0,
head: conversation
.slice(0, split)
.map((item) => item.text)
.join("\n\n"),
recent: conversation
.slice(split)
.map((item) => item.text)
@@ -207,12 +212,14 @@ const select = (messages: readonly SessionMessage.Info[], tokens: number) => {
}
}
export const buildPrompt = () =>
export const buildPrompt = (input: { readonly previousSummary?: string; readonly context: readonly string[] }) =>
[
"Summarize the conversation above so work can continue without the earlier messages.",
input.previousSummary
? `Update the anchored summary below using the conversation history above.\nPreserve still-true details, remove stale details, and merge in the new facts.\n<previous-summary>\n${input.previousSummary}\n</previous-summary>`
: "Create a new anchored summary from the conversation history.",
SUMMARY_TEMPLATE,
"If the history contains a conversation checkpoint, incorporate its summary and recent context. Preserve still-true details, remove stale details, and merge in the new facts.",
"Do not continue the task or call tools. Output only the summary.",
"The following is the conversation history:",
...input.context,
].join("\n\n")
const planContent = (messages: readonly SessionMessage.Info[], tokens: number) => {
@@ -222,10 +229,13 @@ const planContent = (messages: readonly SessionMessage.Info[], tokens: number) =
(message): message is SessionMessage.CompactionCompleted =>
message.type === "compaction" && message.status === "completed",
)
const summarizeRecent = !previousSummary?.recent && !selected.hasHead
const previousRecent = previousSummary?.recent ?? ""
const summarizeRecent = !previousRecent && !selected.head
return {
// Keep the existing checkpoint and chronological updates in their original positions.
messages: summarizeRecent ? messages : messages.slice(0, selected.split),
prompt: buildPrompt({
previousSummary: previousSummary?.summary,
context: summarizeRecent ? [selected.recent] : [previousRecent, selected.head].filter(Boolean),
}),
recent: summarizeRecent ? "" : selected.recent,
}
}
@@ -252,39 +262,11 @@ const make = (dependencies: Dependencies) => {
return { status: "failed" as const, error: input.error }
})
const execute = Effect.fn("SessionCompaction.execute")(function* (plan: Plan) {
if (
!plan.messages.some((message) => message.type !== "compaction" && message.type !== "system" && serialize(message))
)
return yield* failed({
sessionID: plan.session.id,
reason: plan.reason,
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
inputID: plan.inputID,
})
const loaded = yield* plan.context.pipe(
Effect.catch((cause) =>
failed({
sessionID: plan.session.id,
reason: plan.reason,
error: toSessionError(cause),
inputID: plan.inputID,
}),
),
)
if ("status" in loaded) return loaded
const content = planContent(loaded.messages, state.get().tokens)
if (!content)
return yield* failed({
sessionID: plan.session.id,
reason: plan.reason,
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
inputID: plan.inputID,
})
if (!plan.started)
yield* dependencies.bus.publish(SessionEvent.Compaction.Started, {
sessionID: loaded.session.id,
sessionID: plan.session.id,
reason: plan.reason,
recent: content.recent,
recent: plan.recent,
inputID: plan.inputID,
})
@@ -294,44 +276,33 @@ const make = (dependencies: Dependencies) => {
const recordUsage = Effect.suspend(() =>
usage
? dependencies.bus.publish(SessionEvent.UsageRecorded, {
sessionID: loaded.session.id,
sessionID: plan.session.id,
source: "compaction",
...usage,
})
: Effect.void,
)
const transcript = SessionModelRequest.baseTranscript({
agent: loaded.agent.info,
model: loaded.model,
tools: loaded.tools,
initial: loaded.initial,
messages: content.messages,
})
const prepared = yield* plan.prepare({
scope: { session: loaded.session, agentID: loaded.agent.id, model: loaded.model, tools: loaded.tools },
transcript,
scope: { session: plan.session, agentID: Agent.ID.make("compaction"), model: plan.resolved },
transcript: { system: [], messages: [Message.user(plan.prompt)] },
contextHooks: false,
})
const request = LLMRequest.update(prepared.request, {
messages: [...prepared.request.messages, Message.user(buildPrompt())],
})
yield* dependencies.llm.stream(request, prepared.options).pipe(
yield* dependencies.llm.stream(prepared.request, prepared.options).pipe(
Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event))
failure = {
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
message: event.message,
}
if (LLMEvent.is.toolCall(event))
failure = { type: "compaction.failed", message: "Compaction attempted to call a tool" }
if (LLMEvent.is.textDelta(event)) {
chunks.push(event.text)
return dependencies.bus.publish(SessionEvent.Compaction.Delta, {
sessionID: loaded.session.id,
sessionID: plan.session.id,
text: event.text,
})
}
if (LLMEvent.is.stepFinish(event)) {
const step = SessionUsage.record(event.usage, loaded.model.cost)
const step = SessionUsage.record(event.usage, plan.resolved.cost)
usage = usage ? SessionUsage.add(usage, step) : step
}
return Effect.void
@@ -346,7 +317,7 @@ const make = (dependencies: Dependencies) => {
Effect.andThen(
plan.reason === "auto"
? failed({
sessionID: loaded.session.id,
sessionID: plan.session.id,
reason: plan.reason,
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
inputID: plan.inputID,
@@ -361,29 +332,36 @@ const make = (dependencies: Dependencies) => {
if (failure || !summary.trim()) {
const error = failure ?? { type: "compaction.failed" as const, message: "Compaction produced no summary" }
return yield* failed({
sessionID: loaded.session.id,
sessionID: plan.session.id,
reason: plan.reason,
error,
inputID: plan.inputID,
})
}
yield* dependencies.bus.publish(SessionEvent.Compaction.Ended, {
sessionID: loaded.session.id,
sessionID: plan.session.id,
reason: plan.reason,
text: summary,
recent: content.recent,
recent: plan.recent,
})
return { status: "completed" as const }
})
const compact = Effect.fn("SessionCompaction.compact")((input: AutoInput) =>
execute({
session: input.session,
messages: input.messages,
context: input.context,
prepare: input.prepare,
const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput) {
const content = planContent(input.messages, state.get().tokens)
if (content)
return yield* execute({
session: input.session,
resolved: input.resolved,
prepare: input.prepare,
reason: "auto",
...content,
})
return yield* failed({
sessionID: input.session.id,
reason: "auto",
}),
)
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
})
})
const required = (input: RequiredInput) => {
const config = state.get()
if (!config.auto) return false
@@ -405,12 +383,36 @@ const make = (dependencies: Dependencies) => {
if (used <= 0) return false
return used >= promptCeiling
}
const compactManual = Effect.fn("SessionCompaction.compactManual")((input: ManualInput) =>
execute({
...input,
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
const content = planContent(input.messages, state.get().tokens)
if (!content)
return yield* failed({
sessionID: input.session.id,
reason: "manual",
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
inputID: input.inputID,
})
const resolved = yield* input.resolveModel(input.session).pipe(
Effect.catch((cause) =>
failed({
sessionID: input.session.id,
reason: "manual",
error: toSessionError(cause),
inputID: input.inputID,
}),
),
)
if ("status" in resolved) return resolved
return yield* execute({
session: input.session,
resolved,
prepare: input.prepare,
reason: "manual",
}),
)
inputID: input.inputID,
started: input.started,
...content,
})
})
return Service.of({
transform: state.transform,
reload: state.reload,
+149 -18
View File
@@ -1,6 +1,6 @@
export * as SessionContext from "./context.js"
import { Context, Effect, Layer } from "effect"
import { Context, Effect, Layer, Schema } from "effect"
import { Agent } from "../agent.js"
import { Catalog } from "../catalog.js"
import { CodeModeInstructions } from "../codemode/instructions.js"
@@ -17,6 +17,13 @@ import { PluginSupervisor } from "../plugin/supervisor.js"
import { ReferenceInstructions } from "../reference/instructions.js"
import { SkillInstructions } from "../skill/instructions.js"
import { Tool } from "../tool.js"
import { Permission } from "../permission.js"
import { Permissions } from "../permissions.js"
import { Image } from "../image.js"
import { PluginHooks } from "../plugin/hooks.js"
import { Source } from "../source.js"
import type { SessionCapabilities } from "./capabilities.js"
import { SessionSystemPrompt } from "./system-prompt.js"
import { AgentNotFoundError } from "./error.js"
import { SessionHistory } from "./history.js"
import { InstructionEntry } from "./instruction-entry.js"
@@ -31,6 +38,8 @@ export interface Selection {
readonly agent: Agent.Selection & { readonly info: Agent.Info }
readonly instructions: Instructions.List
readonly tools: Tool.Snapshot
/** `baseTranscript` uses its default prefix for undefined; "" omits it when system text lives in the instruction epoch. */
readonly system?: string
}
export interface Loaded {
@@ -40,6 +49,7 @@ export interface Loaded {
readonly initial: string
readonly messages: ReadonlyArray<SessionMessage.Info>
readonly tools: Tool.Snapshot
readonly system?: Selection["system"]
}
/**
@@ -50,7 +60,7 @@ export interface Loaded {
*/
export interface Interface {
/** Selects the Session, agent, instructions, and tools used by subsequent work. */
readonly select: (sessionID: SessionSchema.ID, agentID?: Agent.ID) => Effect.Effect<Selection, AgentNotFoundError>
readonly select: (sessionID: SessionSchema.ID) => Effect.Effect<Selection, AgentNotFoundError>
/** Resolves the model and active history for that selection. */
readonly load: (selection: Selection) => Effect.Effect<Loaded, SessionRunnerModel.Error>
readonly resolveModel: (
@@ -119,7 +129,7 @@ const layer = Layer.effect(
return { agent, primary, selected }
})
const select = Effect.fn("SessionContext.select")(function* (sessionID: SessionSchema.ID, agentID?: Agent.ID) {
const select = Effect.fn("SessionContext.select")(function* (sessionID: SessionSchema.ID) {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
@@ -127,8 +137,8 @@ const layer = Layer.effect(
yield* plugins.flush
yield* mcpTools.flush
const agent = yield* agents.select(agentID ?? session.agent)
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: agent.id })
const agent = yield* agents.select(session.agent)
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
const loaded = yield* Effect.all(
{
tools: registry.snapshot(agent.info.permissions),
@@ -157,23 +167,144 @@ const layer = Layer.effect(
}
})
const load = Effect.fn("SessionContext.load")(function* (selection: Selection) {
const model = yield* resolveModel(selection.session)
const history = yield* SessionHistory.entriesForRunner(db, selection.session.id, selection.instructions)
return {
session: selection.session,
agent: selection.agent,
model,
initial: history.initial,
messages: history.entries.map((entry) => entry.message),
tools: selection.tools,
}
return Service.of({
select,
load: load(db, resolveModel),
resolveModel,
selectTitle,
prepare: modelRequests.prepare,
})
return Service.of({ select, load, resolveModel, selectTitle, prepare: modelRequests.prepare })
}),
)
/** The values path shares instruction persistence and history assembly with discovery. */
export const values = (input: SessionCapabilities.OpenInput) =>
Layer.effect(
Service,
Effect.gen(function* () {
const db = (yield* Database.Service).db
const store = yield* SessionStore.Service
const entries = yield* InstructionEntry.Service
const requests = yield* SessionModelRequest.Service
const hooks = yield* PluginHooks.Service
const image = yield* Image.Service
const tools = Source.from(input.tools ?? [])
const instructions = Source.from(input.instructions ?? [])
const limits = Source.from(input.limits ?? {})
const permissions = input.permissions ?? Permissions.allowAll
const model = Source.from(input.model)
const resolveModel: Interface["resolveModel"] = (session) => model.get(session)
let cached:
| {
readonly tools: ReadonlyArray<Tool.Info>
readonly rules: Permission.Ruleset
readonly snapshot: Tool.Snapshot
}
| undefined
const select: Interface["select"] = Effect.fn("SessionContext.selectValues")(function* (sessionID) {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
const selected = yield* Effect.all(
{
tools: tools.get(session),
rules: permissions.visibility.get(session),
limits: limits.get(session),
entries: entries.load(sessionID),
},
{ concurrency: "unbounded" },
)
if (cached?.tools !== selected.tools || cached.rules !== selected.rules)
cached = {
tools: selected.tools,
rules: selected.rules,
snapshot: yield* Tool.snapshot(selected.tools, selected.rules).pipe(
Effect.provideService(PluginHooks.Service, hooks),
Effect.provideService(Image.Service, image),
),
}
const snapshot = cached.snapshot
const id = session.agent ?? Agent.defaultID
return {
session,
agent: { id, info: { ...Agent.Info.default(id), permissions: selected.rules, steps: selected.limits.steps } },
// System text participates in the epoch instead of changing the privileged prefix.
system: "",
tools: snapshot,
instructions: Instructions.combine([
Instructions.make({
key: Instructions.Key.make("session/system"),
codec: Schema.String,
read:
input.system === undefined
? Effect.succeed(SessionSystemPrompt.make(snapshot.definitions.map((tool) => tool.name)))
: Source.from(input.system)
.get(session)
.pipe(Effect.map((value) => (value === "" ? Instructions.removed : value))),
render: {
initial: (value) => value,
changed: (_previous, value) =>
`The system instructions changed and supersede the previous value:\n${value}`,
removed: () => "The previous system instructions no longer apply.",
},
}),
CodeModeInstructions.make(snapshot.codeModeCatalog),
Instructions.make({
key: Instructions.Key.make("session/instructions"),
codec: Schema.Array(Schema.String),
read: instructions
.get(session)
.pipe(
Effect.map((value) =>
Array.isArray(value) && !value.some((part) => part.length > 0) ? Instructions.removed : value,
),
),
render: {
initial: (value) => value.join("\n\n"),
changed: (_previous, value) =>
`The session instructions changed and supersede the previous value:\n${value.join("\n\n")}`,
removed: () => "The previous session instructions no longer apply.",
},
}),
Instructions.make({
key: Instructions.Key.make("session/permissions"),
codec: Schema.toCodecJson(Permission.Ruleset),
read: Effect.succeed(selected.rules.length > 0 ? selected.rules : Instructions.removed),
render: {
initial: (value) => `Permission rules:\n${JSON.stringify(value)}`,
changed: (_previous, value) => `Permission rules changed:\n${JSON.stringify(value)}`,
removed: () => "The previous permission rules no longer apply.",
},
}),
selected.entries,
]),
}
})
return Service.of({
select,
load: load(db, resolveModel),
resolveModel,
selectTitle: () => Effect.undefined,
prepare: requests.prepare,
})
}),
)
function load(db: Database.Interface["db"], resolveModel: Interface["resolveModel"]): Interface["load"] {
return Effect.fn("SessionContext.load")(function* (selection: Selection) {
const model = yield* resolveModel(selection.session)
const history = yield* SessionHistory.entriesForRunner(db, selection.session.id, selection.instructions)
return {
session: selection.session,
agent: selection.agent,
model,
initial: history.initial,
messages: history.entries.map((entry) => entry.message),
tools: selection.tools,
...(selection.system === undefined ? {} : { system: selection.system }),
}
})
}
/** Variant IDs that minimize reasoning output, in preference order. */
const MINIMAL_REASONING_VARIANTS = ["none", "minimal", "low"].map((id) => Model.VariantID.make(id))
+23 -9
View File
@@ -14,6 +14,7 @@ import { SessionStore } from "./store.js"
import { toSessionError } from "./to-session-error.js"
import { UserInterruptedError } from "./error.js"
import { SessionInbox } from "./inbox.js"
import { SessionResolve } from "./resolve.js"
export interface Interface {
/** Snapshots active execution owned by this process. */
@@ -52,6 +53,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap.Service
const resolve = yield* SessionResolve.Service
const bus = yield* Bus.Service
const jobs = yield* Job.Service
const db = (yield* Database.Service).db
@@ -86,10 +88,14 @@ export const layer = Layer.effect(
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 }),
const pinned = resolve.pinned(sessionID)
const result = yield* (
pinned
? pinned.drain({ sessionID, force, continuation, promotable })
: SessionRunner.Service.use((runner) => runner.drain({ sessionID, force, continuation, promotable })).pipe(
Effect.provide(locations.get(session.location)),
)
).pipe(
Effect.provide(locations.get(session.location)),
Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.void
@@ -101,11 +107,14 @@ export const layer = Layer.effect(
})
}
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
started: (sessionID) =>
reportLifecycle(
eligible: (sessionID) => resolve.status(sessionID) !== "owned-detached",
started: (sessionID) => {
resolve.pin(sessionID)
return reportLifecycle(
sessionID,
bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(sessionID)),
),
)
},
drain: (sessionID, force, promotable) => drain(sessionID, force, undefined, promotable),
// One terminal observation per busy period, covering every coalesced drain.
settled: (sessionID, exit, reason) =>
@@ -137,7 +146,7 @@ export const layer = Layer.effect(
releaseOnCommit(sessionID),
)
}),
),
).pipe(Effect.ensuring(resolve.settle(sessionID))),
})
return Service.of({
@@ -160,7 +169,12 @@ export const layer = Layer.effect(
yield* coordinator.wake(sessionID, "steer")
return interrupted
}),
resume: coordinator.run,
resume: (sessionID) =>
Effect.gen(function* () {
// TODO: typed unavailable-operation errors belong to the capability-gated operations phase.
if (resolve.status(sessionID) === "owned-detached") return yield* SessionResolve.unavailable(sessionID)
yield* coordinator.run(sessionID)
}),
wake: coordinator.wake,
awaitIdle: coordinator.awaitIdle,
})
@@ -170,7 +184,7 @@ export const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer,
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node, Job.node],
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node, Job.node, SessionResolve.node],
})
/** Low-level compatibility layer for callers that only need durable Session recording. */
+28 -9
View File
@@ -7,6 +7,7 @@ import { Job } from "../../job.js"
import { Session } from "../../session.js"
import { SessionEvent } from "../event.js"
import { SessionExecution } from "../execution.js"
import { SessionResolve } from "../resolve.js"
import { SessionSchema } from "../schema.js"
import { SessionStore } from "../store.js"
@@ -37,6 +38,8 @@ export interface Interface {
* shutdown (which preserves the claim on purpose). The claim is never
* cleared here: only a terminal event releases it, so a death anywhere in
* the resume path leaves the same orphaned claim for the next boot.
* Capability-owned Sessions stay pending even when reopened; they require
* an explicit prompt or resume instead of automatic recovery.
*/
readonly resumeSuspendedSessions: Effect.Effect<void>
}
@@ -66,6 +69,7 @@ export const layer = (options?: Options) =>
Effect.gen(function* () {
const store = yield* SessionStore.Service
const execution = yield* SessionExecution.Service
const resolve = yield* SessionResolve.Service
const bus = yield* Bus.Service
const jobs = yield* Job.Service
const sessions = yield* Session.Service
@@ -73,6 +77,8 @@ export const layer = (options?: Options) =>
const maxAttempts = options?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS
const prepareResume = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
// Reopening capabilities does not opt a Session into automatic recovery.
if (resolve.status(sessionID) !== "unowned") return undefined
// Durable before the resume runs, so a crash inside the resumed turn is
// counted by the next sweep and the budget cannot be dodged.
const attempts = yield* store.countResume(sessionID)
@@ -95,6 +101,14 @@ export const layer = (options?: Options) =>
return true
})
const eligibleJob = (recovery: Job.Recovery) => {
if (recovery.kind === "shell") return resolve.status(recovery.sessionID) === "unowned"
return (
resolve.status(recovery.parentSessionID) === "unowned" &&
resolve.status(recovery.childSessionID) === "unowned"
)
}
const recoverShell = Effect.fnUntraced(function* (
background: Job.Background,
recovery: Extract<Job.Recovery, { kind: "shell" }>,
@@ -143,6 +157,7 @@ export const layer = (options?: Options) =>
const notify = Effect.fnUntraced(function* (result: Pick<Job.Background, "status" | "output" | "error">) {
if (result.status === "running") return
if (!eligibleJob(recovery)) return
const text =
result.status === "completed"
? (result.output ?? "Subagent completed without a text response.")
@@ -172,7 +187,9 @@ export const layer = (options?: Options) =>
return
}
if ((yield* execution.active).has(recovery.childSessionID)) return
if (!(yield* prepareResume(recovery.childSessionID))) {
const prepared = yield* prepareResume(recovery.childSessionID)
if (prepared === undefined) return
if (!prepared) {
yield* notify({ status: "error", error: RESUME_EXHAUSTED.message })
return
}
@@ -214,18 +231,20 @@ export const layer = (options?: Options) =>
// Early notices wait for root recovery's accounting, including roots that exhaust their budget.
const suspended = new Set((yield* store.listSuspended()).filter((sessionID) => !active.has(sessionID)))
const pending = yield* jobs.pendingBackground
yield* store.releaseChildClaims(
pending.flatMap((background) =>
yield* store.releaseChildClaims([
...(yield* resolve.ownedIDs),
...pending.flatMap((background) =>
background.status === "running" && background.recovery.kind === "subagent"
? [background.recovery.childSessionID]
: [],
),
)
])
yield* Effect.forEach(
pending,
Effect.fnUntraced(function* (background) {
if ((yield* jobs.get(background.id))?.status === "running") return
const recovery = background.recovery
if (!eligibleJob(recovery)) return
yield* recovery.kind === "shell"
? recoverShell(background, recovery)
: recoverSubagent(background, recovery, suspended)
@@ -237,10 +256,10 @@ export const layer = (options?: Options) =>
const resumed = yield* execution.active
yield* Effect.forEach(
(yield* store.listSuspended()).filter((sessionID) => !resumed.has(sessionID)),
(sessionID) =>
execution
.resume(sessionID)
.pipe(Effect.ignore, Effect.forkIn(scope), Effect.when(prepareResume(sessionID))),
Effect.fnUntraced(function* (sessionID) {
if (!(yield* prepareResume(sessionID))) return
yield* execution.resume(sessionID).pipe(Effect.ignore, Effect.forkIn(scope))
}),
{ concurrency: "unbounded", discard: true },
)
// Async observers consult this set at delivery; later completions wake parents normally.
@@ -253,5 +272,5 @@ export const layer = (options?: Options) =>
export const node = makeGlobalNode({
service: Service,
layer: layer(),
deps: [SessionStore.node, SessionExecution.node, Bus.node, Job.node, Session.node],
deps: [SessionStore.node, SessionExecution.node, SessionResolve.node, Bus.node, Job.node, Session.node],
})
-18
View File
@@ -63,24 +63,6 @@ export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseServ
return (yield* messageEntries(db, sessionID)).map((entry) => entry.message)
})
/** Finds the last assistant even when a checkpoint has replaced it in model-visible history. */
export const latestAssistant = Effect.fn("SessionHistory.latestAssistant")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
) {
const row = yield* db
.select()
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "assistant")))
.orderBy(desc(SessionMessageTable.seq))
.limit(1)
.get()
.pipe(Effect.orDie)
if (!row) return
const message = yield* decodeMessageRow(row).pipe(Effect.orDie)
return message.type === "assistant" ? message : undefined
})
export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
@@ -103,7 +103,7 @@ const source = (entry: Info & { readonly removed: boolean }) =>
},
})
const layer = Layer.effect(
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { db } = yield* Database.Service
+7 -5
View File
@@ -60,7 +60,7 @@ interface PrepareInput {
readonly session: SessionSchema.Info
readonly agentID: Agent.ID
readonly model: SessionRunnerModel.Resolved
/** Omitted for requests that carry no tools, such as titles. */
/** Omitted for requests that carry no tools (title, compaction). */
readonly tools?: Tool.Snapshot
}
readonly transcript: {
@@ -70,7 +70,7 @@ interface PrepareInput {
readonly toolChoice?: LLM.RequestInput["toolChoice"]
/**
* Session context hooks shape the agent conversation. Requests that are not
* part of the conversation (such as titles) opt out: their transcripts
* part of the conversation (title, compaction) opt out: their transcripts
* pass through unchanged.
*/
readonly contextHooks?: false
@@ -84,14 +84,16 @@ export const baseTranscript = (input: {
readonly tools: Tool.Snapshot
readonly initial: string
readonly messages: ReadonlyArray<SessionMessage.Info>
readonly system?: string
}) => {
const providerMetadataKey = input.model.model.route.providerMetadataKey ?? input.model.model.provider
return {
providerMetadataKey,
system: [
input.agent.system
? input.agent.system
: SessionSystemPrompt.make(input.tools.definitions.map((tool) => tool.name)),
input.system ??
(input.agent.system
? input.agent.system
: SessionSystemPrompt.make(input.tools.definitions.map((tool) => tool.name))),
input.initial,
]
.filter((part) => part.length > 0)
+291
View File
@@ -0,0 +1,291 @@
export * as SessionResolve from "./resolve.js"
import type { LLMClientService } from "@opencode-ai/ai"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect"
import { Bus } from "../bus.js"
import { App } from "../app.js"
import { Database } from "../database/database.js"
import { llmClient, webSocketConstructor } from "../effect/app-node-platform.js"
import { KV } from "../kv.js"
import type { SessionCapabilities } from "./capabilities.js"
import type { SessionRunner } from "./runner/index.js"
import { SessionSchema } from "./schema.js"
import { SessionStore } from "./store.js"
import { Socket } from "effect/unstable/socket"
import type { Image } from "../image.js"
import type { SessionContext } from "./context.js"
import type { SessionModelTransport } from "./model-transport.js"
const prefix = "session.capabilities/"
/**
* Live operations, never an attempt snapshot. Title, request hooks, compaction,
* media/skills, snapshots, output, and transport customization are deferred;
* open supplies their internal defaults without directory discovery.
*/
interface Capabilities extends SessionContext.Interface {
readonly image: Image.Interface
readonly transport: SessionModelTransport.Interface
}
type Status = "attached" | "owned-detached" | "unowned"
type Resolved =
| { readonly status: "attached"; readonly capabilities: Capabilities }
| { readonly status: "owned-detached" | "unowned" }
type Opened = {
readonly capabilities: Capabilities
readonly runner: SessionRunner.Interface
readonly scope: Scope.Closeable
readonly onRetire: () => Effect.Effect<void>
readonly done: Deferred.Deferred<void>
current: boolean
users: number
}
export interface Interface {
readonly own: <A extends { readonly id: SessionSchema.ID }, R>(
record: Effect.Effect<A, never, R>,
) => Effect.Effect<A, never, R>
readonly status: (id: SessionSchema.ID) => Status
readonly ownedIDs: Effect.Effect<ReadonlyArray<SessionSchema.ID>>
readonly attach: (
id: SessionSchema.ID,
input: SessionCapabilities.OpenInput,
) => Effect.Effect<() => Effect.Effect<void>>
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Resolved, never, Scope.Scope>
/** Called synchronously when the coordinator installs a busy period, before its first fiber yield. */
readonly pin: (id: SessionSchema.ID) => void
readonly pinned: (id: SessionSchema.ID) => SessionRunner.Interface | undefined
readonly settle: (id: SessionSchema.ID) => Effect.Effect<void>
readonly remove: (id: SessionSchema.ID) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionResolve") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const kv = yield* KV.Service
const db = (yield* Database.Service).db
const owned = new Set(
(yield* kv.scanAll(prefix))
.filter((entry) => entry.value === true)
.map((entry) => SessionSchema.ID.make(entry.key.slice(prefix.length))),
)
const globals = yield* Effect.context<
| Database.Service
| Bus.Service
| SessionStore.Service
| LLMClientService
| FSUtil.Service
| Global.Service
| Socket.WebSocketConstructor
>()
const current = new Map<SessionSchema.ID, Opened>()
const pinned = new Map<SessionSchema.ID, Opened>()
const opened = new Map<Deferred.Deferred<void>, Opened>()
// LayerMap invalidation cannot choose synchronously at coordinator start or
// run a host hook after all generation-specific users settle. Keep explicit leases.
const retire = (value: Opened) =>
Effect.suspend(() => {
if (value.current || value.users > 0 || !opened.delete(value.done)) return Effect.void
return Scope.close(value.scope, Exit.void).pipe(
Effect.andThen(value.onRetire()),
Effect.onExit((exit) => Deferred.done(value.done, exit)),
)
})
const release = (value: Opened) =>
Effect.sync(() => {
value.users--
}).pipe(Effect.andThen(retire(value)))
yield* Effect.addFinalizer(() =>
Effect.sync(() => current.clear()).pipe(
Effect.andThen(
Effect.forEach(
opened.values(),
(value) => {
value.current = false
return retire(value)
},
{ discard: true },
),
),
),
)
return Service.of({
// Ownership is transitionally one-way: retirement never hands a Session back to discovery.
own: (record) =>
Effect.uninterruptible(
Effect.gen(function* () {
const session = yield* db
.transaction(() =>
Effect.gen(function* () {
const session = yield* record
if (!owned.has(session.id)) yield* kv.set(prefix + session.id, true)
return session
}),
)
.pipe(Effect.orDie)
// Publish the memory index only after the durable transaction commits.
owned.add(session.id)
return session
}),
),
status: (id) => (current.has(id) ? "attached" : owned.has(id) ? "owned-detached" : "unowned"),
ownedIDs: Effect.sync(() => Array.from(owned)),
attach: Effect.fn("SessionResolve.attach")(function* (id, input) {
const [
{ Image },
{ PluginHooks },
{ PluginSupervisor },
{ Snapshot },
{ ToolOutput },
{ SessionCompaction },
{ SessionContext },
{ InstructionEntry },
{ SessionModelRequest },
{ SessionModelTransport },
{ SessionRunner },
{ SessionRunnerLLM },
{ SessionTitle },
] = yield* Effect.promise(() =>
Promise.all([
import("../image.js"),
import("../plugin/hooks.js"),
import("../plugin/supervisor-service.js"),
import("../snapshot.js"),
import("../tool-output.js"),
import("./compaction.js"),
import("./context.js"),
import("./instruction-entry.js"),
import("./model-request.js"),
import("./model-transport.js"),
import("./runner/index.js"),
import("./runner/llm.js"),
import("./title.js"),
]),
)
const scope = yield* Scope.make()
const base = Layer.mergeAll(
PluginHooks.layer,
Image.layer,
InstructionEntry.layer,
SessionModelTransport.layer,
ToolOutput.layer,
Snapshot.noopLayer,
SessionCompaction.layer,
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
).pipe(Layer.provide(Layer.succeedContext(globals)))
const requests = SessionModelRequest.layer.pipe(Layer.provideMerge(base))
const capabilities = SessionContext.values(input).pipe(Layer.provideMerge(requests))
const runner = SessionRunnerLLM.layer.pipe(
Layer.provideMerge(SessionTitle.layer.pipe(Layer.provideMerge(capabilities))),
Layer.provide(Layer.succeedContext(globals)),
)
// Each open builds fresh local state over the SAME captured durable/global services.
const services = yield* Layer.buildWithScope(Layer.fresh(runner), scope).pipe(
Effect.onError(() => Scope.close(scope, Exit.void)),
)
const value: Opened = {
capabilities: {
...Context.get(services, SessionContext.Service),
image: Context.get(services, Image.Service),
transport: Context.get(services, SessionModelTransport.Service),
},
runner: Context.get(services, SessionRunner.Service),
scope,
onRetire: input.retire ?? (() => Effect.void),
done: Deferred.makeUnsafe<void>(),
current: true,
users: 0,
}
const previous = current.get(id)
current.set(id, value)
opened.set(value.done, value)
if (previous) {
previous.current = false
yield* retire(previous)
}
// Closed handles retain only completion, not retired capability functions or layers.
const done = value.done
return () =>
Effect.gen(function* () {
yield* Effect.uninterruptible(
Effect.gen(function* () {
const value = opened.get(done)
if (!value) return
if (current.get(id) === value) current.delete(id)
value.current = false
yield* retire(value)
}),
)
yield* Deferred.await(done)
})
}),
resolve: (session) =>
Effect.gen(function* () {
const value = current.get(session.id)
if (!value) return { status: owned.has(session.id) ? ("owned-detached" as const) : ("unowned" as const) }
yield* Effect.acquireRelease(
Effect.sync(() => {
value.users++
}),
() => release(value),
)
return { status: "attached" as const, capabilities: value.capabilities }
}),
pin: (id) => {
const value = current.get(id)
if (!value) return
value.users++
pinned.set(id, value)
},
pinned: (id) => pinned.get(id)?.runner,
settle: (id) =>
Effect.suspend(() => {
const value = pinned.get(id)
if (!value) return Effect.void
pinned.delete(id)
return release(value)
}),
remove: (id) =>
Effect.gen(function* () {
const value = current.get(id)
current.delete(id)
if (value) {
value.current = false
yield* retire(value)
}
if (!owned.has(id)) return
yield* kv.remove(prefix + id)
owned.delete(id)
}),
})
}),
)
export const node = makeGlobalNode({
service: Service,
layer,
deps: [
KV.node,
Database.node,
Bus.node,
SessionStore.node,
llmClient,
FSUtil.node,
Global.node,
webSocketConstructor,
// Request preparation reads this Reference from the captured globals, not its fallback metadata.
App.node,
],
})
/** TODO: replace this defect with the typed error in the capability-gated operations phase. */
export const unavailable = (sessionID: SessionSchema.ID) =>
Effect.die(new Error(`Session must be reopened with capabilities: ${sessionID}`))
+5 -1
View File
@@ -52,6 +52,8 @@ type Execution<E, Reason> = {
*/
export const make = <Key, E, Reason = never>(options: {
readonly drain: (key: Key, force: boolean, scope: Promotable) => Effect.Effect<void, E>
/** Controls new busy periods, including late successors; existing execution may finish. */
readonly eligible?: (key: Key) => boolean
/** Runs once when a process-local busy period begins, before its first drain. */
readonly started?: (key: Key) => Effect.Effect<void>
/**
@@ -107,7 +109,7 @@ export const make = <Key, E, Reason = never>(options: {
// A doorbell that survives the execution loop (rung after the loop decided to end, or
// during failure or interruption cleanup) starts a fresh execution for the remaining work.
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
if (execution.pendingWake) start(key, false, execution.pendingWake)
if (execution.pendingWake && (options.eligible?.(key) ?? true)) start(key, false, execution.pendingWake)
else executions.delete(key)
Deferred.doneUnsafe(execution.done, exit)
}
@@ -121,11 +123,13 @@ export const make = <Key, E, Reason = never>(options: {
return Deferred.await(execution.done).pipe(Effect.ignoreCause, Effect.andThen(run(key)))
return Deferred.await(execution.done)
}
if (options.eligible?.(key) === false) return Effect.interrupt
return Deferred.await(start(key, true, "input").done)
})
const wake = (key: Key, scope: Promotable = "input") =>
Effect.sync(() => {
if (options.eligible?.(key) === false) return
const execution = executions.get(key)
if (execution !== undefined) {
// Coalesced wakes keep the widest scope: "input" subsumes "steer".
+4 -20
View File
@@ -8,7 +8,6 @@ import { InstructionState } from "../instruction-state.js"
import { SessionCompaction } from "../compaction.js"
import { SessionContext } from "../context.js"
import { SessionEvent } from "../event.js"
import { SessionHistory } from "../history.js"
import { SessionInbox } from "../inbox.js"
import { SessionModelRequest } from "../model-request.js"
import { SessionModelTransport } from "../model-transport.js"
@@ -31,7 +30,7 @@ import { MAX_STEPS_PROMPT } from "./max-steps.js"
const CONTINUE_AFTER_INCOMPLETE_STREAM =
"The previous response was interrupted. Continue from where you left off without repeating completed content."
const layer = Layer.effect(
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
@@ -140,12 +139,11 @@ const layer = Layer.effect(
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
const compacted = yield* restore(
Effect.gen(function* () {
const messages = yield* store.context(sessionID)
return yield* compaction.compactManual({
session,
resolveModel: context.resolveModel,
prepare: context.prepare,
messages,
context: loadCompactionContext(sessionID, messages),
messages: yield* store.context(sessionID),
inputID: pending.id,
started: true,
})
@@ -206,20 +204,6 @@ const layer = Layer.effect(
return selected
})
const loadCompactionContext = Effect.fn("SessionRunner.loadCompactionContext")(function* (
sessionID: SessionSchema.ID,
messages: readonly SessionMessage.Info[],
loaded?: SessionContext.Loaded,
) {
const last =
messages.findLast((message) => message.type === "assistant") ??
(yield* SessionHistory.latestAssistant(db, sessionID))
if (loaded && (!last || last.agent === loaded.agent.id)) return loaded
const selected = yield* context.select(sessionID, last?.agent)
yield* InstructionState.prepare(db, bus, selected.instructions, sessionID)
return yield* context.load(selected)
})
/** Owns logical Step policy; each attempt owns its streaming, tools, and durable settlement. */
const runStep = Effect.fn("SessionRunner.runStep")(function* (first: SessionContext.Loaded, step: number) {
const sessionID = first.session.id
@@ -237,7 +221,6 @@ const layer = Layer.effect(
messages: loaded.messages,
resolved: loaded.model,
prepare: context.prepare,
context: loadCompactionContext(sessionID, loaded.messages, loaded),
}
if (compaction.required(compactionInput)) {
const compacted = yield* compaction.compact(compactionInput)
@@ -252,6 +235,7 @@ const layer = Layer.effect(
tools: loaded.tools,
initial: loaded.initial,
messages: loaded.messages,
system: loaded.system,
})
const prepared = yield* context.prepare({
scope: { session: loaded.session, agentID: loaded.agent.id, model: loaded.model, tools: loaded.tools },
+1 -1
View File
@@ -33,7 +33,7 @@ export interface Interface {
readonly release: (sessionID: Session.ID) => Effect.Effect<void>
/**
* Clears orphaned child claims except children owned by recoverable
* background subagent jobs.
* background subagent jobs or capability-owned Sessions.
*/
readonly releaseChildClaims: (recoverable: ReadonlyArray<Session.ID>) => Effect.Effect<void>
/**
+37
View File
@@ -0,0 +1,37 @@
export * as Source from "./source.js"
import { Effect, Ref } from "effect"
import type { SessionSchema } from "./session/schema.js"
export interface Interface<T, E = never> {
/** Return replacement values when changing state; consumers may reuse derived results by reference identity. */
readonly get: (session: SessionSchema.Info) => Effect.Effect<T, E>
}
export type Value<T, E = never> = T | Interface<T, E>
export interface Mutable<T> extends Interface<T> {
readonly set: (value: T) => Effect.Effect<void>
readonly update: (update: (value: T) => T) => Effect.Effect<void>
}
export function mutable<T>(initial: T): Mutable<T> {
const ref = Ref.makeUnsafe(initial)
return {
get: () => Ref.get(ref),
set: (value) => Ref.set(ref, value),
update: (update) => Ref.update(ref, update),
}
}
export function constant<T>(value: T): Interface<T> {
return { get: () => Effect.succeed(value) }
}
export function from<T, E = never>(value: Value<T, E>): Interface<T, E> {
return isSource(value) ? value : constant(value)
}
function isSource<T, E>(value: Value<T, E>): value is Interface<T, E> {
return typeof value === "object" && value !== null && "get" in value && typeof value.get === "function"
}
+1 -1
View File
@@ -45,7 +45,7 @@ const cleanup = Effect.fn("ToolOutput.cleanup")(function* (fs: FSUtil.Interface,
)
})
const layer = Layer.effect(
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
+196 -188
View File
@@ -1,6 +1,6 @@
export * as Tool from "./tool.js"
export { CallID, Content, Error, FileContent, TextContent } from "@opencode-ai/schema/tool"
export type { Context, Metadata, Options, Result } from "@opencode-ai/schema/tool"
export type { Context, Info, Metadata, Options, Result } from "@opencode-ai/schema/tool"
import { ToolDefinition, type ToolCall } from "@opencode-ai/ai"
import { Tool } from "@opencode-ai/schema/tool"
@@ -54,201 +54,209 @@ export interface Snapshot {
export class Service extends Context.Service<Service, Interface>()("@opencode/Tool") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const image = yield* Image.Service
const make = Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const image = yield* Image.Service
type NormalizedItem = Tool.Content | "decode" | "size"
const normalizeImages = Effect.fnUntraced(function* (content: ReadonlyArray<Tool.Content>) {
const normalized = yield* Effect.forEach(content, (item): Effect.Effect<NormalizedItem> => {
if (item.type !== "file" || !item.mime.startsWith("image/")) return Effect.succeed(item)
const base64 = /^data:[^,]*;base64,(.*)$/s.exec(item.uri)?.[1]
if (base64 === undefined) return Effect.succeed(item)
const resource = item.name ?? `${item.mime} tool output`
return image.normalize(resource, { uri: resource, content: base64, encoding: "base64", mime: item.mime }).pipe(
Effect.map((result) => ({
...item,
uri: `data:${result.mime};base64,${result.content}`,
mime: result.mime,
})),
Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(item)),
Effect.catchTag("Image.DecodeError", () => Effect.succeed("decode" as const)),
Effect.catchTag("Image.SizeError", () => Effect.succeed("size" as const)),
)
})
const note = (reason: "decode" | "size", text: string) => {
const count = normalized.filter((item) => item === reason).length
if (count === 0) return []
return [{ type: "text" as const, text: `[${count} image${count === 1 ? "" : "s"} omitted: ${text}]` }]
}
return [
...normalized.filter((item) => typeof item !== "string"),
...note("decode", "could not be decoded."),
...note("size", "could not be resized below the image size limit."),
]
})
const executeTool = Effect.fn("Tool.execute")(function* (
tool: Tool.Info,
name: string,
input: unknown,
context: Tool.Context,
) {
const beforeEvent: PluginHooks.Domains["tool"]["execute.before"] = {
tool: name,
inputSchema: definition(tool).inputSchema,
sessionID: context.sessionID,
agent: context.agent,
messageID: context.messageID,
id: context.id,
input,
}
yield* hooks.trigger("tool", "execute.before", beforeEvent)
const execution = yield* execute(tool, beforeEvent.input, context).pipe(
Effect.map((value) => ({ value })),
Effect.catchTag("Tool.Error", (failure) => Effect.succeed({ failure })),
type NormalizedItem = Tool.Content | "decode" | "size"
const normalizeImages = Effect.fnUntraced(function* (content: ReadonlyArray<Tool.Content>) {
const normalized = yield* Effect.forEach(content, (item): Effect.Effect<NormalizedItem> => {
if (item.type !== "file" || !item.mime.startsWith("image/")) return Effect.succeed(item)
const base64 = /^data:[^,]*;base64,(.*)$/s.exec(item.uri)?.[1]
if (base64 === undefined) return Effect.succeed(item)
const resource = item.name ?? `${item.mime} tool output`
return image.normalize(resource, { uri: resource, content: base64, encoding: "base64", mime: item.mime }).pipe(
Effect.map((result) => ({
...item,
uri: `data:${result.mime};base64,${result.content}`,
mime: result.mime,
})),
Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(item)),
Effect.catchTag("Image.DecodeError", () => Effect.succeed("decode" as const)),
Effect.catchTag("Image.SizeError", () => Effect.succeed("size" as const)),
)
const base = {
tool: name,
sessionID: context.sessionID,
agent: context.agent,
messageID: context.messageID,
id: context.id,
input: beforeEvent.input,
}
if ("failure" in execution) {
const afterEvent: PluginHooks.Domains["tool"]["execute.after"] = {
...base,
status: "error",
error: execution.failure,
}
yield* hooks.trigger("tool", "execute.after", afterEvent)
return yield* afterEvent.error
}
})
const note = (reason: "decode" | "size", text: string) => {
const count = normalized.filter((item) => item === reason).length
if (count === 0) return []
return [{ type: "text" as const, text: `[${count} image${count === 1 ? "" : "s"} omitted: ${text}]` }]
}
return [
...normalized.filter((item) => typeof item !== "string"),
...note("decode", "could not be decoded."),
...note("size", "could not be resized below the image size limit."),
]
})
const executeTool = Effect.fn("Tool.execute")(function* (
tool: Tool.Info,
name: string,
input: unknown,
context: Tool.Context,
) {
const beforeEvent: PluginHooks.Domains["tool"]["execute.before"] = {
tool: name,
inputSchema: definition(tool).inputSchema,
sessionID: context.sessionID,
agent: context.agent,
messageID: context.messageID,
id: context.id,
input,
}
yield* hooks.trigger("tool", "execute.before", beforeEvent)
const execution = yield* execute(tool, beforeEvent.input, context).pipe(
Effect.map((value) => ({ value })),
Effect.catchTag("Tool.Error", (failure) => Effect.succeed({ failure })),
)
const base = {
tool: name,
sessionID: context.sessionID,
agent: context.agent,
messageID: context.messageID,
id: context.id,
input: beforeEvent.input,
}
if ("failure" in execution) {
const afterEvent: PluginHooks.Domains["tool"]["execute.after"] = {
...base,
status: "completed",
result: {
...(execution.value.output === undefined ? {} : { output: execution.value.output }),
content: execution.value.content,
...(execution.value.metadata === undefined ? {} : { metadata: execution.value.metadata }),
},
status: "error",
error: execution.failure,
}
yield* hooks.trigger("tool", "execute.after", afterEvent)
const afterContent = yield* normalizeImages(normalizeContent(afterEvent.result.content, afterEvent.result.output))
return {
...(afterEvent.result.output === undefined ? {} : { output: afterEvent.result.output }),
content: afterContent,
...(afterEvent.result.metadata === undefined ? {} : { metadata: afterEvent.result.metadata }),
}
})
return yield* afterEvent.error
}
const afterEvent: PluginHooks.Domains["tool"]["execute.after"] = {
...base,
status: "completed",
result: {
...(execution.value.output === undefined ? {} : { output: execution.value.output }),
content: execution.value.content,
...(execution.value.metadata === undefined ? {} : { metadata: execution.value.metadata }),
},
}
yield* hooks.trigger("tool", "execute.after", afterEvent)
const afterContent = yield* normalizeImages(normalizeContent(afterEvent.result.content, afterEvent.result.output))
return {
...(afterEvent.result.output === undefined ? {} : { output: afterEvent.result.output }),
content: afterContent,
...(afterEvent.result.metadata === undefined ? {} : { metadata: afterEvent.result.metadata }),
}
})
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
name: "tool",
initial: () => ({
tools: new Map(),
errors: [],
}),
draft: (draft) => ({
list: () => Array.from(draft.tools.values()),
get: (id) => draft.tools.get(id),
add: (tool) => {
const error = registrationError(tool)
if (error) {
draft.errors.push({ tool, error })
return
}
const id = effectiveName(tool)
draft.tools.set(id, { ...tool, id, options: tool.options && { ...tool.options } })
},
update: (id, update) => {
const current = draft.tools.get(id)
if (!current) return
const tool = { ...current, options: current.options && { ...current.options } }
update(tool)
tool.name = current.name
tool.id = id
if (tool.options?.namespace !== current.options?.namespace)
tool.options = { ...tool.options, namespace: current.options?.namespace }
const error = registrationError(tool)
if (error) {
draft.errors.push({ tool, error })
return
}
draft.tools.set(id, tool)
},
remove: (id) => {
draft.tools.delete(id)
},
}),
finalize: () =>
Effect.forEach(
state.get().errors,
({ tool, error }) =>
Effect.logError("Skipping invalid tool registration", {
name: tool.name,
namespace: tool.options?.namespace,
error: error.message,
}),
{ discard: true },
),
})
return Service.of({
transform: state.transform,
reload: state.reload,
snapshot: Effect.fn("Tool.snapshot")((permissions) =>
Effect.sync(() => {
const active = new Map<string, Tool.Info>()
const rules = permissions ?? []
for (const [name, tool] of state.get().tools) {
if (whollyDisabled(tool.options?.permission ?? name, rules)) continue
active.set(name, tool)
}
const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false))
const codemode = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
const executeRule = rules.findLast((rule) => Wildcard.match("execute", rule.action))
const codemodeEnabled = executeRule?.resource !== "*" || executeRule.effect !== "deny"
const codemodeTool = codemodeEnabled
? CodeModeTool.create(codemode, (name, tool, input, context) => executeTool(tool, name, input, context))
: undefined
const codeModeCatalog = codemodeEnabled ? CodeModeTool.catalog(codemode) : undefined
return {
...(codeModeCatalog === undefined ? {} : { codeModeCatalog }),
definitions: [
...Array.from(direct)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([, tool]) => definition(tool)),
...(codemodeTool ? [definition(codemodeTool)] : []),
],
execute: (input: {
readonly sessionID: SessionSchema.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly call: ToolCall
readonly progress?: (update: Tool.Metadata) => Effect.Effect<void>
}) => {
const context: Tool.Context = {
sessionID: input.sessionID,
agent: input.agent,
messageID: input.messageID,
id: Tool.CallID.make(input.call.id),
progress: input.progress ?? (() => Effect.void),
}
if (input.call.name === "execute" && codemodeTool)
return executeTool(codemodeTool, input.call.name, input.call.input, context)
const tool = direct.get(input.call.name)
if (tool) return executeTool(tool, input.call.name, input.call.input, context)
return new Tool.Error({ message: `Unknown tool: ${input.call.name}` })
},
}
}),
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
name: "tool",
initial: () => ({
tools: new Map(),
errors: [],
}),
draft: (draft) => ({
list: () => Array.from(draft.tools.values()),
get: (id) => draft.tools.get(id),
add: (tool) => {
const error = registrationError(tool)
if (error) {
draft.errors.push({ tool, error })
return
}
const id = effectiveName(tool)
draft.tools.set(id, { ...tool, id, options: tool.options && { ...tool.options } })
},
update: (id, update) => {
const current = draft.tools.get(id)
if (!current) return
const tool = { ...current, options: current.options && { ...current.options } }
update(tool)
tool.name = current.name
tool.id = id
if (tool.options?.namespace !== current.options?.namespace)
tool.options = { ...tool.options, namespace: current.options?.namespace }
const error = registrationError(tool)
if (error) {
draft.errors.push({ tool, error })
return
}
draft.tools.set(id, tool)
},
remove: (id) => {
draft.tools.delete(id)
},
}),
finalize: () =>
Effect.forEach(
state.get().errors,
({ tool, error }) =>
Effect.logError("Skipping invalid tool registration", {
name: tool.name,
namespace: tool.options?.namespace,
error: error.message,
}),
{ discard: true },
),
})
}),
)
})
return Service.of({
transform: state.transform,
reload: state.reload,
snapshot: Effect.fn("Tool.snapshot")((permissions) =>
Effect.sync(() => {
const active = new Map<string, Tool.Info>()
const rules = permissions ?? []
for (const [name, tool] of state.get().tools) {
if (whollyDisabled(tool.options?.permission ?? name, rules)) continue
active.set(name, tool)
}
const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false))
const codemode = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
const executeRule = rules.findLast((rule) => Wildcard.match("execute", rule.action))
const codemodeEnabled = executeRule?.resource !== "*" || executeRule.effect !== "deny"
const codemodeTool = codemodeEnabled
? CodeModeTool.create(codemode, (name, tool, input, context) => executeTool(tool, name, input, context))
: undefined
const codeModeCatalog = codemodeEnabled ? CodeModeTool.catalog(codemode) : undefined
return {
...(codeModeCatalog === undefined ? {} : { codeModeCatalog }),
definitions: [
...Array.from(direct)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([, tool]) => definition(tool)),
...(codemodeTool ? [definition(codemodeTool)] : []),
],
execute: (input: {
readonly sessionID: SessionSchema.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly call: ToolCall
readonly progress?: (update: Tool.Metadata) => Effect.Effect<void>
}) => {
const context: Tool.Context = {
sessionID: input.sessionID,
agent: input.agent,
messageID: input.messageID,
id: Tool.CallID.make(input.call.id),
progress: input.progress ?? (() => Effect.void),
}
if (input.call.name === "execute" && codemodeTool)
return executeTool(codemodeTool, input.call.name, input.call.input, context)
const tool = direct.get(input.call.name)
if (tool) return executeTool(tool, input.call.name, input.call.input, context)
return new Tool.Error({ message: `Unknown tool: ${input.call.name}` })
},
}
}),
),
})
})
const layer = Layer.effect(Service, make)
export const snapshot = Effect.fn("Tool.snapshot")(function* (
values: readonly Tool.Info[],
permissions?: Permission.Ruleset,
) {
const tools = yield* make
yield* tools.transform((draft) => values.forEach((tool) => draft.add(tool)))
return yield* tools.snapshot(permissions)
}, Effect.scoped)
const whollyDisabled = (action: string, rules: Permission.Ruleset) => {
const rule = rules.findLast((rule) => Wildcard.match(action, rule.action))
+6 -5
View File
@@ -19,8 +19,9 @@ import { ToolOutput } from "../../tool-output.js"
export const name = "shell"
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
const BACKGROUND_STARTED = "The command was moved to the background."
const BACKGROUND_INSTRUCTION =
"You will be notified automatically when the command finishes. Avoid sleep commands or polling for completion; if you need the output before then, read the file directly."
"You will be notified automatically when the command finishes. DO NOT sleep, poll, or proactively check on its progress."
const OS =
process.platform === "darwin"
? "macOS"
@@ -94,8 +95,8 @@ const toolResult = (output: Output) => {
}
}
const backgroundResult = (shellID: string, file: string) => ({
output: `Command moved to the background (shell ID: ${shellID}).\nOutput is streaming to: ${file}`,
const backgroundResult = (shellID: string) => ({
output: BACKGROUND_STARTED,
shellID,
truncated: false,
status: "running" as const,
@@ -294,7 +295,7 @@ export const Plugin = {
if (input.background === true) {
yield* runtime.job.background(job.id)
yield* notifyWhenDone(context.sessionID, context.id, info.id, info.command, settled)
return backgroundResult(info.id, info.file)
return backgroundResult(info.id)
}
const result = yield* runtime.job
@@ -303,7 +304,7 @@ export const Plugin = {
if (result?.type === "backgrounded") {
yield* shell.timeout(info.id, 0)
yield* notifyWhenDone(context.sessionID, context.id, info.id, info.command, settled)
return backgroundResult(info.id, info.file)
return backgroundResult(info.id)
}
if (result?.info.status === "error")
return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
+8 -12
View File
@@ -3,6 +3,7 @@ import { Tool } from "@opencode-ai/schema/tool"
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
import { Cache, Effect, JsonSchema, Schema, SchemaIssue, SchemaRepresentation } from "effect"
import { $ZodType, toJSONSchema } from "zod/v4/core"
import { Permission } from "../permission.js"
const formatEffectIssues = SchemaIssue.makeFormatterStandardSchemaV1()
@@ -27,19 +28,14 @@ export const definition = (tool: Tool.Info<any, any>): ToolDefinition => ({
export const execute = (tool: Tool.Info<any, any>, input: unknown, context: Tool.Context) =>
Effect.gen(function* () {
const decoded = yield* decodeInput(tool, input)
// Tool implementations declare `Tool.Error` but plugins can fail with anything at
// runtime. A foreign typed failure would slip past every `catchTag("Tool.Error")`
// downstream and leave its call permanently unsettled, so the declared contract is
// enforced here at the untrusted boundary. Declines tunnel through as defects and
// interrupts are not errors; neither is touched.
// Normalize foreign typed failures so downstream Tool.Error handlers settle the call.
// Host declines enter Permission.assert's defect tunnel; existing defects and interrupts pass through.
const result = yield* tool.execute(decoded, context).pipe(
Effect.mapError((error: unknown) =>
error instanceof Tool.Error
? error
: new Tool.Error({
message: error instanceof globalThis.Error ? error.message : String(error),
}),
),
Effect.catch((error) => {
if (error instanceof Permission.DeclinedError) return Effect.die(error)
if (error instanceof Tool.Error) return Effect.fail(error)
return Effect.fail(new Tool.Error({ message: error instanceof Error ? error.message : String(error) }))
}),
)
if (tool.output === undefined) {
if ("output" in result) return yield* Effect.die("Tool result declared output without an output schema")
+15 -23
View File
@@ -78,33 +78,25 @@ describe("ConfigCompactionPlugin.Plugin", () => {
const started = yield* bus
.subscribe(SessionEvent.Compaction.Started)
.pipe(Stream.runHead, Effect.forkScoped({ startImmediately: true }))
const messages: SessionMessage.Info[] = [
{
id: SessionMessage.ID.create(),
type: "user",
text: "Older context",
time: { created: DateTime.makeUnsafe(0) },
},
{
id: SessionMessage.ID.create(),
type: "user",
text: "Recent context",
time: { created: DateTime.makeUnsafe(1) },
},
]
expect(
yield* compaction.compactManual({
session,
resolveModel: () => Effect.succeed(resolved),
prepare: modelRequests.prepare,
context: Effect.succeed({
session,
agent: { id: Agent.defaultID, info: Agent.Info.default(Agent.defaultID) },
model: resolved,
initial: "",
messages,
tools: { definitions: [], execute: () => Effect.die("Compaction must not execute tools") },
}),
messages,
messages: [
{
id: SessionMessage.ID.create(),
type: "user",
text: "Older context",
time: { created: DateTime.makeUnsafe(0) },
},
{
id: SessionMessage.ID.create(),
type: "user",
text: "Recent context",
time: { created: DateTime.makeUnsafe(1) },
},
],
inputID: SessionMessage.ID.make("msg_compaction_manual"),
}),
).toEqual({ status: "completed" })
@@ -0,0 +1,20 @@
import { Effect, Schema } from "effect"
import { SessionSchema } from "../../src/session/schema"
import { Tool } from "../../src/tool"
export const session = Schema.decodeUnknownSync(SessionSchema.Info)({
id: "ses_capabilities",
projectID: "global",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
location: { directory: "/project" },
})
export const echo = (execute: (text: string) => Effect.Effect<string>, name = "echo"): Tool.Info => ({
name,
description: `Echo text with ${name}`,
input: Schema.Struct({ text: Schema.String }),
output: Schema.String,
execute: (input) => execute(input.text).pipe(Effect.map((output) => ({ output }))),
})
+11
View File
@@ -51,6 +51,8 @@ describe("KV", () => {
entries: [{ key: `${prefix}éclair`, value: { order: 3 } }],
})
expect(yield* kv.scan({ prefix: `${prefix}%_` })).toEqual({ entries: [] })
expect(yield* kv.scanAll(`${prefix}%_`)).toEqual([])
expect(yield* kv.scanAll(prefix)).toEqual([...first.entries, { key: `${prefix}éclair`, value: { order: 3 } }])
}),
)
@@ -76,6 +78,15 @@ describe("KV", () => {
expect((yield* kv.scan({ prefix, limit: 0 })).entries).toHaveLength(1)
expect((yield* kv.scan({ prefix, limit: -10 })).entries).toHaveLength(1)
expect((yield* kv.scan({ prefix, limit: Number.NaN })).entries).toHaveLength(100)
const all = kv.scanAll(prefix)
const entries = Array.from({ length: 1001 }, (_, index) => {
const key = `${prefix}${index.toString().padStart(4, "0")}`
return { key, value: key }
})
expect(yield* all).toEqual(entries)
yield* kv.remove(`${prefix}0000`)
expect(yield* all).toEqual(entries.slice(1))
}),
)
})
@@ -0,0 +1,102 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { Permission } from "../src/permission"
import { Permissions } from "../src/permissions"
import { SessionSchema } from "../src/session/schema"
import { Source } from "../src/source"
import { session } from "./fixture/capabilities"
import { it } from "./lib/effect"
it.effect("allowAll permits requests and exposes an allow-all visibility rule", () =>
Effect.gen(function* () {
expect(yield* Permissions.allowAll.visibility.get(session)).toEqual([
{ action: "*", resource: "*", effect: "allow" },
])
yield* Permissions.allowAll.ask(session, { action: "write", resources: ["src/file.ts", "other/file.ts"] })
}),
)
it.effect("rules fail immediately for unmatched, ask, and denied resources", () =>
Effect.gen(function* () {
const request = { action: "read", resources: ["src/file.ts"] }
yield* Effect.forEach(
[
[],
[{ action: "read", resource: "*", effect: "ask" }],
[{ action: "read", resource: "*", effect: "deny" }],
] satisfies Permission.Ruleset[],
(rules) =>
Effect.gen(function* () {
const permissions = Permissions.rules(rules)
expect(yield* permissions.visibility.get(session)).toBe(rules)
expect(yield* permissions.ask(session, request).pipe(Effect.flip)).toEqual(
new Permission.BlockedError({ rules, permission: request.action, resources: request.resources }),
)
}),
)
}),
)
it.effect("rules use wildcard precedence and require every resource to be allowed", () =>
Effect.gen(function* () {
const permissions = Permissions.rules([
{ action: "*", resource: "*", effect: "deny" },
{ action: "re*", resource: "src/*", effect: "allow" },
{ action: "read", resource: "src/private/*", effect: "ask" },
{ action: "read", resource: "src/private/public.ts", effect: "allow" },
])
yield* permissions.ask(session, { action: "read", resources: ["src/file.ts", "src/private/public.ts"] })
expect(
yield* permissions
.ask(session, { action: "read", resources: ["src/file.ts", "src/private/file.ts"] })
.pipe(Effect.flip),
).toBeInstanceOf(Permission.BlockedError)
expect(
yield* permissions.ask(session, { action: "write", resources: ["src/file.ts"] }).pipe(Effect.flip),
).toBeInstanceOf(Permission.BlockedError)
}),
)
it.effect("blocked requests report action-relevant rules without inventing a reason", () =>
Effect.gen(function* () {
const rules: Permission.Ruleset = [
{ action: "*", resource: "*", effect: "deny" },
{ action: "write", resource: "*", effect: "allow" },
{ action: "re*", resource: "other/*", effect: "allow" },
{ action: "read", resource: "src/*", effect: "deny" },
]
const request = { action: "read", resources: ["src/file.ts"] }
const relevant = [rules[0], rules[2], rules[3]]
expect(Permission.relevant(request, rules)).toEqual(relevant)
const error = yield* Permissions.rules(rules).ask(session, request).pipe(Effect.flip)
expect(error).toEqual(
new Permission.BlockedError({ rules: relevant, permission: request.action, resources: request.resources }),
)
if (error._tag !== "Permission.BlockedError") return yield* Effect.die("Expected blocked permission")
expect(error.reason).toBeUndefined()
expect(error.message).toBe("Permission denied: read")
}),
)
it.effect("rules sample mutable and session-dependent sources for visibility and each request", () =>
Effect.gen(function* () {
const source = Source.mutable<Permission.Ruleset>([])
const permissions = Permissions.rules(source)
const request = { action: "read", resources: ["src/file.ts"] }
expect(permissions.visibility).toBe(source)
expect(yield* permissions.ask(session, request).pipe(Effect.flip)).toBeInstanceOf(Permission.BlockedError)
yield* source.set([{ action: "read", resource: "*", effect: "allow" }])
yield* permissions.ask(session, request)
yield* source.update((rules) => [...rules, { action: "read", resource: "src/*", effect: "deny" }])
expect(yield* permissions.ask(session, request).pipe(Effect.flip)).toBeInstanceOf(Permission.BlockedError)
const scoped = Permissions.rules({
get: (current) =>
Effect.succeed([{ action: "read", resource: "*", effect: current.id === session.id ? "allow" : "deny" }]),
})
yield* scoped.ask(session, request)
const other = { ...session, id: SessionSchema.ID.make("ses_other") }
expect(yield* scoped.visibility.get(other)).toEqual([{ action: "read", resource: "*", effect: "deny" }])
expect(yield* scoped.ask(other, request).pipe(Effect.flip)).toBeInstanceOf(Permission.BlockedError)
}),
)
@@ -0,0 +1,223 @@
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 { Job } from "@opencode-ai/core/job"
import { KV } from "@opencode-ai/core/kv"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { SessionResolve } from "@opencode-ai/core/session/resolve"
import { SessionInboxTable, SessionMessageTable, 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 { Effect, RcMap } from "effect"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
Bus.node,
Job.node,
KV.node,
Session.node,
SessionStore.node,
SessionExecution.node,
SessionRestart.node,
SessionResolve.node,
LocationServiceMap.node,
]),
),
)
describe("capability-owned Session recovery", () => {
for (const claimed of [false, true]) {
it.effect(`leaves an owned Session ${claimed ? "with an exhausted claim" : "without a claim"} inert`, () =>
Effect.gen(function* () {
const database = yield* Database.Service
const resolve = yield* SessionResolve.Service
const restart = yield* SessionRestart.Service
const sessionID = Session.ID.make("ses_capability_recovery")
yield* seedSession(sessionID, claimed ? { time_suspended: 123, resume_attempts: 10 } : {})
yield* resolve.own(Effect.succeed({ id: sessionID }))
const before = yield* accounting(database)
expect(resolve.status(sessionID)).toBe("owned-detached")
yield* restart.resumeSuspendedSessions
yield* restart.resumeSuspendedSessions
expect(yield* accounting(database)).toEqual(before)
expect(resolve.status(sessionID)).toBe("owned-detached")
yield* assertInert()
}),
)
}
it.effect("keeps a completed Job pending for an unclaimed owned parent without waking it", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const resolve = yield* SessionResolve.Service
const jobs = yield* Job.Service
const restart = yield* SessionRestart.Service
const parent = Session.ID.make("ses_capability_completed_parent")
const child = Session.ID.make("ses_capability_completed_child")
yield* seedSession(parent)
yield* seedSession(child, { parent_id: parent })
yield* resolve.own(Effect.succeed({ id: parent }))
yield* seedJob(
{ kind: "subagent", parentSessionID: parent, childSessionID: child, agent: "explore", description: "Inspect" },
"completed",
)
const pending = yield* jobs.pendingBackground
const before = yield* accounting(database)
expect(pending).toMatchObject([{ status: "completed", output: "Recovered result" }])
yield* restart.resumeSuspendedSessions
yield* restart.resumeSuspendedSessions
expect(yield* jobs.pendingBackground).toEqual(pending)
expect(yield* jobs.get("recovery-job")).toBeUndefined()
expect(yield* accounting(database)).toEqual(before)
yield* assertInert()
}),
)
for (const owner of ["parent", "child"] as const) {
it.effect(`preserves child claims with a capability-owned ${owner} and pending running Job`, () =>
Effect.gen(function* () {
const database = yield* Database.Service
const resolve = yield* SessionResolve.Service
const jobs = yield* Job.Service
const restart = yield* SessionRestart.Service
const parent = Session.ID.make("ses_capability_running_parent")
const child = Session.ID.make("ses_capability_running_child")
const orphan = Session.ID.make("ses_capability_orphan_child")
yield* seedSession(parent, owner === "parent" ? { time_suspended: 789, resume_attempts: 10 } : {})
yield* seedSession(child, { parent_id: parent, time_suspended: 123, resume_attempts: 10 })
yield* seedSession(orphan, { parent_id: parent, time_suspended: 456, resume_attempts: 1 })
yield* resolve.own(Effect.succeed({ id: owner === "parent" ? parent : child }))
yield* resolve.own(Effect.succeed({ id: orphan }))
yield* seedJob(
{
kind: "subagent",
parentSessionID: parent,
childSessionID: child,
agent: "explore",
description: "Inspect",
},
"running",
)
const pending = yield* jobs.pendingBackground
const before = yield* accounting(database)
expect(pending).toMatchObject([{ status: "running" }])
yield* restart.resumeSuspendedSessions
yield* restart.resumeSuspendedSessions
expect(yield* jobs.pendingBackground).toEqual(pending)
expect(yield* jobs.get("recovery-job")).toBeUndefined()
expect(yield* accounting(database)).toEqual(before)
yield* assertInert()
}),
)
}
for (const status of ["running", "completed"] as const) {
it.effect(`keeps a ${status} shell Job pending for an owned Session`, () =>
Effect.gen(function* () {
const database = yield* Database.Service
const resolve = yield* SessionResolve.Service
const jobs = yield* Job.Service
const restart = yield* SessionRestart.Service
const sessionID = Session.ID.make("ses_capability_shell")
yield* seedSession(sessionID)
yield* resolve.own(Effect.succeed({ id: sessionID }))
yield* seedJob({ kind: "shell", sessionID, shellID: "sh_recovery", command: "echo result" }, status)
const pending = yield* jobs.pendingBackground
const before = yield* accounting(database)
yield* restart.resumeSuspendedSessions
expect(yield* jobs.pendingBackground).toEqual(pending)
expect(yield* accounting(database)).toEqual(before)
yield* assertInert()
}),
)
}
})
function seedSession(
sessionID: Session.ID,
values: Partial<Pick<typeof SessionTable.$inferInsert, "time_suspended" | "resume_attempts" | "parent_id">> = {},
) {
return Effect.gen(function* () {
const database = yield* Database.Service
yield* database.db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
yield* database.db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: sessionID,
directory: "/project",
title: sessionID,
version: "test",
...values,
})
.run()
.pipe(Effect.orDie)
})
}
function seedJob(recovery: Job.Recovery, status: "running" | "completed") {
// A previous process-local Job registry leaves only its durable record behind.
return Effect.gen(function* () {
const jobs = yield* Job.make
yield* jobs.start({
id: "recovery-job",
type: recovery.kind,
recovery,
run: status === "running" ? Effect.never : Effect.succeed("Recovered result"),
})
yield* jobs.background("recovery-job")
if (status === "completed") yield* jobs.wait({ id: "recovery-job" })
}).pipe(Effect.scoped)
}
function accounting(database: Database.Service["Service"]) {
return database.db
.select({
id: SessionTable.id,
claimed: SessionTable.time_suspended,
attempts: SessionTable.resume_attempts,
updated: SessionTable.time_updated,
})
.from(SessionTable)
.orderBy(SessionTable.id)
.all()
.pipe(Effect.orDie)
}
function assertInert() {
return Effect.gen(function* () {
const database = yield* Database.Service
const execution = yield* SessionExecution.Service
const locations = yield* LocationServiceMap.Service
expect(yield* execution.active).toEqual(new Set())
expect(yield* database.db.select().from(EventTable).all().pipe(Effect.orDie)).toEqual([])
expect(yield* database.db.select().from(SessionMessageTable).all().pipe(Effect.orDie)).toEqual([])
expect(yield* database.db.select().from(SessionInboxTable).all().pipe(Effect.orDie)).toEqual([])
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([])
})
}
@@ -0,0 +1,865 @@
import { describe, expect } from "bun:test"
import { LanguageModel } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols/openai-chat"
import { TestLLM } from "@opencode-ai/ai/testing"
import { Context, Deferred, Effect, Exit, Fiber, Layer, RcMap, Scope } from "effect"
import { eq } from "drizzle-orm"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { AppNodeBuilder } from "../src/effect/app-node-builder"
import { LayerNodePlatform } from "../src/effect/app-node-platform"
import { Bus } from "../src/bus"
import { Database } from "../src/database/database"
import { Instructions } from "../src/instructions/index"
import { KV } from "../src/kv"
import { Session } from "../src/session"
import { InstructionState } from "../src/session/instruction-state"
import { SessionResolve } from "../src/session/resolve"
import { SessionRunnerModel } from "../src/session/runner/model"
import { InstructionBlobTable, InstructionStateTable, SessionTable } from "../src/session/sql"
import { SessionStore } from "../src/session/store"
import { Source } from "../src/source"
import { SessionRestart } from "../src/session/execution/restart"
import { Location } from "../src/location"
import { AbsolutePath } from "../src/schema"
import { InstructionEntry } from "../src/session/instruction-entry"
import { Tool } from "../src/tool"
import { testEffect } from "./lib/effect"
import { tmpdir } from "./fixture/tmpdir"
import path from "path"
import { LocationServiceMap } from "../src/location-service-map"
import { Skill } from "../src/skill"
import { Permissions } from "../src/permissions"
import { Permission } from "../src/permission"
import { SessionMessage } from "../src/session/message"
import { PluginHooks } from "../src/plugin/hooks"
import { echo } from "./fixture/capabilities"
import { App } from "../src/app"
import { SessionExecution } from "../src/session/execution"
const scripted = TestLLM.layer()
const application = AppNodeBuilder.build(
LayerNode.group([
Session.node,
SessionExecution.node,
LocationServiceMap.node,
SessionResolve.node,
SessionStore.node,
SessionRestart.node,
Database.node,
Bus.node,
KV.node,
InstructionEntry.node,
PluginHooks.node,
]),
[
[App.node, App.configured({ name: "fixture-host" })],
[Bus.node, Bus.configured({ persist: true })],
[LayerNodePlatform.llmClient, TestLLM.clientLayer.pipe(Layer.provide(scripted))],
],
).pipe(Layer.provideMerge(scripted))
const it = testEffect(application)
const isolated = testEffect(scripted)
const model = SessionRunnerModel.resolved(
LanguageModel.make({ id: "fixture-model", provider: "fixture", route: OpenAIChat.route }),
{
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
limit: { context: 200_000, output: 32_000 },
},
)
describe("Session capabilities", () => {
it.live("closing a replacement during retirement leaves late admitted work parked", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const llm = yield* TestLLM.Service
const locations = yield* LocationServiceMap.Service
const retiring = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
yield* Effect.addFinalizer(() => Deferred.succeed(release, undefined))
const gate = yield* llm.gate
const session = yield* sessions.open({
model,
retire: () => Deferred.succeed(retiring, undefined).pipe(Effect.andThen(Deferred.await(release))),
})
yield* llm.push(TestLLM.text("first done", "late_close_reply"))
const running = yield* session.prompt({ text: "First busy period." }).pipe(Effect.forkScoped)
yield* llm.wait(1)
const replacement = yield* sessions.open({ id: session.id, model })
yield* gate.release
yield* Deferred.await(retiring)
yield* sessions.prompt({ sessionID: session.id, text: "Late work." })
yield* replacement.close()
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(running)
yield* sessions.wait(session.id)
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([])
expect(yield* sessions.inbox(session.id)).toHaveLength(1)
expect(llm.requests).toHaveLength(1)
const reopened = yield* sessions.open({ id: session.id, model })
yield* llm.push(TestLLM.text("late work drained", "late_reopened_reply"))
// An advisory wake after reopen delivers the already-admitted item, without a new prompt.
const execution = yield* SessionExecution.Service
yield* execution.wake(reopened.id)
yield* sessions.wait(reopened.id)
expect(yield* sessions.inbox(reopened.id)).toHaveLength(0)
expect(llm.requests).toHaveLength(2)
expect(JSON.stringify(llm.requests[1].messages)).toContain("Late work.")
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([])
}),
)
it.live("failed durable ownership writes do not poison the memory index", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const resolve = yield* SessionResolve.Service
const store = yield* SessionStore.Service
const db = (yield* Database.Service).db
const id = Session.ID.make("ses_rejected_marker")
yield* db
.run("CREATE TRIGGER reject_marker BEFORE INSERT ON kv BEGIN SELECT RAISE(ABORT, 'marker rejected'); END")
.pipe(Effect.orDie)
const exit = yield* sessions
.open({ id, model })
.pipe(Effect.exit, Effect.ensuring(db.run("DROP TRIGGER reject_marker").pipe(Effect.orDie)))
expect(exit._tag).toBe("Failure")
expect(yield* store.get(id)).toBeUndefined()
expect(resolve.status(id)).toBe("unowned")
const session = yield* sessions.open({ id, model })
expect(resolve.status(id)).toBe("attached")
yield* session.close()
}),
)
it.live("pins at coordinator start before a close can retire the new busy period", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const execution = yield* SessionExecution.Service
const resolve = yield* SessionResolve.Service
const llm = yield* TestLLM.Service
const session = yield* sessions.open({ model })
yield* session.prompt({ text: "Begin before close.", resume: false })
yield* llm.push(TestLLM.text("settled", "start_close_reply"))
const running = yield* execution.resume(session.id).pipe(Effect.forkScoped({ startImmediately: true }))
expect((yield* execution.active).has(session.id)).toBe(true)
const closing = yield* session.close().pipe(Effect.forkScoped({ startImmediately: true }))
expect(resolve.status(session.id)).toBe("owned-detached")
yield* Fiber.join(running)
yield* Fiber.join(closing)
expect(llm.requests).toHaveLength(1)
}),
)
it.live("close waits for busy-period settlement, preserves history, and cannot close a replacement", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const resolve = yield* SessionResolve.Service
const llm = yield* TestLLM.Service
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const retired: string[] = []
const session = yield* sessions.open({
model,
tools: [
echo((text) =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.as(text),
Effect.ensuring(
Effect.sync(() => {
retired.push("tool")
}),
),
),
),
],
retire: () =>
Effect.sync(() => {
retired.push("old")
}),
})
yield* llm.push(
TestLLM.tool("close_tool", "execute", { code: 'return await tools.echo({ text: "settled" })' }),
TestLLM.text("settled", "close_reply"),
)
const prompting = yield* session.prompt({ text: "Work before close." }).pipe(Effect.forkScoped)
yield* Deferred.await(started)
const closing = yield* session.close().pipe(Effect.forkScoped({ startImmediately: true }))
expect(resolve.status(session.id)).toBe("owned-detached")
expect(retired).toEqual([])
expect(llm.requests).toHaveLength(1)
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(prompting)
yield* Fiber.join(closing)
expect(retired).toEqual(["tool", "old"])
expect(llm.requests).toHaveLength(2)
expect(yield* sessions.messages({ sessionID: session.id })).toHaveLength(3)
expect((yield* session.resume().pipe(Effect.exit)).toString()).toContain("must be reopened with capabilities")
expect(llm.requests).toHaveLength(2)
const reopened = yield* sessions.open({
id: session.id,
model,
retire: () =>
Effect.sync(() => {
retired.push("new")
}),
})
yield* session.close()
expect(resolve.status(session.id)).toBe("attached")
yield* reopened.close()
yield* reopened.close()
expect(retired).toEqual(["tool", "old", "new"])
expect(resolve.status(session.id)).toBe("owned-detached")
}),
)
it.live("cached snapshots skip rebuilding but reread all selection capabilities", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const resolve = yield* SessionResolve.Service
const entries = yield* InstructionEntry.Service
const tools = Source.mutable([echo(Effect.succeed)])
const rules = Source.mutable<Permission.Ruleset>([{ action: "*", resource: "*", effect: "allow" }])
const counts = { tools: 0, rules: 0, limits: 0 }
const session = yield* sessions.open({
model,
tools: {
get: (session) =>
Effect.sync(() => {
counts.tools++
}).pipe(Effect.andThen(tools.get(session))),
},
permissions: {
ask: Permissions.allowAll.ask,
visibility: {
get: (session) =>
Effect.sync(() => {
counts.rules++
}).pipe(Effect.andThen(rules.get(session))),
},
},
limits: { get: () => Effect.sync(() => ({ steps: ++counts.limits })) },
})
const resolved = yield* resolve.resolve(yield* sessions.get(session.id))
if (resolved.status !== "attached") return yield* Effect.die("Expected open capabilities")
const first = yield* resolved.capabilities.select(session.id)
yield* entries.put({ sessionID: session.id, key: InstructionEntry.Key.make("cache-proof"), value: "Fresh entry" })
const second = yield* resolved.capabilities.select(session.id)
expect(second.tools).toBe(first.tools)
expect(second.agent.info.steps).toBe(2)
expect(second.instructions.map((source) => source.key)).toContain(Instructions.Key.make("api/cache-proof"))
expect(counts).toEqual({ tools: 2, rules: 2, limits: 2 })
const original = yield* tools.get(first.session)
yield* tools.set([...original])
const replaced = yield* resolved.capabilities.select(session.id)
expect(replaced.tools).not.toBe(first.tools)
yield* tools.set(original)
const restored = yield* resolved.capabilities.select(session.id)
expect(restored.tools).not.toBe(first.tools)
expect(restored.tools).not.toBe(replaced.tools)
yield* rules.update((rules) => [...rules])
const repolicy = yield* resolved.capabilities.select(session.id)
expect(repolicy.tools).not.toBe(restored.tools)
expect(counts).toEqual({ tools: 5, rules: 5, limits: 5 })
}),
)
it.live("reopens already-owned Sessions without rewriting KV and skips unowned removal", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const resolve = yield* SessionResolve.Service
const db = (yield* Database.Service).db
const session = yield* sessions.open({ model })
yield* db.run("PRAGMA query_only = ON").pipe(Effect.orDie)
yield* Effect.gen(function* () {
yield* sessions.open({ id: session.id, model })
yield* resolve.remove(Session.ID.make("ses_unowned_removal"))
expect(resolve.status(session.id)).toBe("attached")
}).pipe(Effect.ensuring(db.run("PRAGMA query_only = OFF").pipe(Effect.orDie)))
}),
)
it.live("fresh local defaults do not inherit plugin state from the host's root composition", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const hooks = yield* PluginHooks.Service
const llm = yield* TestLLM.Service
const executed: string[] = []
yield* hooks.register(
"tool",
"execute.before",
() => new Tool.Error({ message: "Root plugin rejected execution" }),
)
const session = yield* sessions.open({
model,
tools: [
echo((text) =>
Effect.sync(() => {
executed.push(text)
return text
}),
),
],
})
yield* llm.push(
TestLLM.tool("root_isolation", "execute", { code: 'return await tools.echo({ text: "local" })' }),
TestLLM.text("finished", "root_isolation_reply"),
)
yield* session.prompt({ text: "Execute with local defaults." })
expect(executed).toEqual(["local"])
expect(JSON.stringify(llm.requests)).not.toContain("Root plugin rejected execution")
}),
)
isolated.live("reconstructs over durable storage with zero model calls until reopen and explicit drive", () =>
Effect.gen(function* () {
const directory = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir("session-capabilities-")),
(directory) => Effect.promise(() => directory[Symbol.asyncDispose]()),
)
const llm = yield* TestLLM.Service
const root = AppNodeBuilder.build(
LayerNode.group([
Session.node,
SessionResolve.node,
SessionStore.node,
SessionRestart.node,
LocationServiceMap.node,
Database.node,
]),
[
[Database.node, Database.configured({ path: path.join(directory.path, "sessions.db") })],
[Bus.node, Bus.configured({ persist: true })],
[LayerNodePlatform.llmClient, TestLLM.clientLayer.pipe(Layer.provide(Layer.succeed(TestLLM.Service, llm)))],
],
)
const firstScope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(firstScope, Exit.void))
const first = yield* Layer.buildWithScope(Layer.fresh(root), firstScope)
const sessions = Context.get(first, Session.Service)
const input = { model, instructions: ["Persist across restarts."], tools: [echo(Effect.succeed)] }
const session = yield* sessions.open(input)
const initial = yield* sessions.get(session.id)
// A process may die after open's durable writes, before admission or a claim.
yield* Context.get(first, SessionRestart.Service).resumeSuspendedSessions
expect(llm.requests).toHaveLength(0)
expect(yield* Context.get(first, SessionStore.Service).listSuspended()).toEqual([])
yield* session.prompt({ text: "Pending work.", resume: false })
yield* Context.get(first, SessionStore.Service).claim(session.id)
yield* Scope.close(firstScope, Exit.void)
// Status and unowned cleanup must remain pure memory operations, even with storage closed.
expect(Context.get(first, SessionResolve.Service).status(session.id)).toBe("owned-detached")
expect(Context.get(first, SessionResolve.Service).status(Session.ID.make("ses_unknown"))).toBe("unowned")
yield* Context.get(first, SessionResolve.Service).remove(Session.ID.make("ses_unknown"))
const secondScope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(secondScope, Exit.void))
const second = yield* Layer.buildWithScope(Layer.fresh(root), secondScope)
const restarted = Context.get(second, Session.Service)
const recovery = Context.get(second, SessionRestart.Service)
const db = Context.get(second, Database.Service).db
yield* recovery.resumeSuspendedSessions
expect(llm.requests).toHaveLength(0)
expect(yield* Context.get(second, SessionStore.Service).listSuspended()).toEqual([session.id])
const waiting = yield* db
.select()
.from(SessionTable)
.where(eq(SessionTable.id, session.id))
.get()
.pipe(Effect.orDie)
expect(waiting?.time_suspended).not.toBeNull()
expect(waiting?.resume_attempts).toBe(0)
expect(Array.from(yield* RcMap.keys(Context.get(second, LocationServiceMap.Service).rcMap))).toEqual([])
const reopened = yield* restarted.open({ ...input, id: session.id, title: "ignored" })
expect((yield* restarted.get(session.id)).location).toEqual(initial.location)
expect((yield* restarted.get(session.id)).projectID).toBe(initial.projectID)
yield* recovery.resumeSuspendedSessions
expect(llm.requests).toHaveLength(0)
yield* llm.push(TestLLM.text("resumed", "recovery_reply"))
yield* reopened.resume()
expect(llm.requests).toHaveLength(1)
expect(llm.requests[0].system.map((part) => part.text).join("\n")).toContain("Persist across restarts.")
expect(yield* Context.get(second, SessionStore.Service).listSuspended()).toEqual([])
expect(
(yield* restarted.messages({ sessionID: session.id })).filter((message) => message.type === "assistant"),
).toHaveLength(1)
expect(Array.from(yield* RcMap.keys(Context.get(second, LocationServiceMap.Service).rcMap))).toEqual([])
}),
)
it.live("opens with values and drains through tools, durable history, and the instruction epoch", () =>
Effect.gen(function* () {
const llm = yield* TestLLM.Service
const sessions = yield* Session.Service
const db = (yield* Database.Service).db
const kv = yield* KV.Service
const executed: string[] = []
const tools = Source.mutable([
echo((text) =>
Effect.sync(() => {
executed.push(text)
return text
}),
),
])
const session = yield* sessions.open({ model, tools, instructions: ["Keep replies brief."] })
expect(llm.requests).toHaveLength(0)
expect(yield* kv.get(`session.capabilities/${session.id}`)).toBe(true)
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, session.id)).get().pipe(Effect.orDie)
expect(row?.time_suspended).toBeNull()
yield* llm.push(
TestLLM.tool("echo_call", "execute", { code: 'return await tools.echo({ text: "hello" })' }),
TestLLM.text("done", "reply"),
)
yield* session.prompt({ text: "Use echo." })
expect(executed).toEqual(["hello"])
expect(llm.requests).toHaveLength(2)
expect(llm.requests[0]?.tools.map((tool) => tool.name)).toEqual(["execute"])
expect(llm.requests[0]?.http?.headers?.["x-opencode-client"]).toBe("fixture-host")
expect(llm.requests[0]?.system.map((part) => part.text).join("\n")).toContain("Keep replies brief.")
const history = yield* sessions.messages({ sessionID: session.id })
expect(history.filter((message) => message.type === "user")).toHaveLength(1)
expect(history.filter((message) => message.type === "assistant")).toHaveLength(2)
expect(
history.some(
(message) =>
message.type === "assistant" &&
message.content.some((part) => part.type === "tool" && part.state.status === "completed"),
),
).toBe(true)
const state = yield* db
.select()
.from(InstructionStateTable)
.where(eq(InstructionStateTable.session_id, session.id))
.get()
.pipe(Effect.orDie)
expect(state?.initial_values["session/instructions"]).toBe(Instructions.hash(["Keep replies brief."]))
expect(state?.current_values).toEqual(state?.initial_values)
const blob = yield* db
.select()
.from(InstructionBlobTable)
.where(eq(InstructionBlobTable.hash, Instructions.hash(["Keep replies brief."])))
.get()
.pipe(Effect.orDie)
expect(blob?.value).toEqual(["Keep replies brief."])
}),
)
it.live("admits images without discovery and rejects undiscovered skill mentions as missing", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const llm = yield* TestLLM.Service
const session = yield* sessions.open({ model })
const admitted = yield* session.prompt({
text: "Inspect this image.",
files: [
{
uri: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
},
],
resume: false,
})
expect(admitted.payload.files?.[0]?.mime).toBe("image/png")
expect(llm.requests).toHaveLength(0)
expect(
yield* session
.prompt({ text: "Use a missing skill.", skills: [{ id: Skill.ID.make("missing") }], resume: false })
.pipe(Effect.flip),
).toBeInstanceOf(Session.SkillNotFoundError)
yield* llm.push(TestLLM.text("image received", "image_reply"))
yield* session.resume()
expect(llm.requests).toHaveLength(1)
}),
)
it.live("an unavailable initial Source leaves admitted input pending without a model call", () =>
Effect.gen(function* () {
const llm = yield* TestLLM.Service
const sessions = yield* Session.Service
const instructions = Source.mutable<ReadonlyArray<string> | Instructions.Unavailable>(Instructions.unavailable)
const session = yield* sessions.open({ model, instructions })
expect(yield* session.prompt({ text: "Wait for policy." }).pipe(Effect.flip)).toBeInstanceOf(
Instructions.InitializationBlocked,
)
expect(llm.requests).toHaveLength(0)
expect(yield* sessions.inbox(session.id)).toHaveLength(1)
yield* instructions.set(["Policy is ready."])
yield* llm.push(TestLLM.text("ready", "ready_reply"))
yield* session.resume()
expect(llm.requests[0].system.map((part) => part.text).join("\n")).toContain("Policy is ready.")
}),
)
it.live("host permission declines interrupt while corrections remain model-facing", () =>
Effect.gen(function* () {
const llm = yield* TestLLM.Service
const sessions = yield* Session.Service
for (const outcome of ["decline", "correction", "foreign"]) {
const correction = outcome === "correction"
const declined = outcome === "decline"
const permissions: Permissions.Interface = {
visibility: Permissions.allowAll.visibility,
ask: () =>
correction
? new Permission.CorrectedError({ feedback: "Use a safer value." })
: new Permission.DeclinedError(),
}
const tool: Tool.Info = {
...echo(Effect.succeed),
options: { codemode: false },
execute: (input, invocation) =>
Effect.gen(function* () {
if (outcome === "foreign") return yield* Effect.fail(new Error("ordinary failure"))
const session = yield* sessions.get(Session.ID.make(invocation.sessionID))
yield* permissions
.ask(session, { action: "echo", resources: [input.text] })
.pipe(
Effect.catchTag("Permission.CorrectedError", (error) => new Tool.Error({ message: error.feedback })),
)
return { output: input.text }
}),
}
const session = yield* sessions.open({ model, tools: [tool], permissions })
const before = llm.requests.length
yield* llm.push(TestLLM.tool(`permission_${outcome}`, "echo", { text: "requested" }))
if (!declined) yield* llm.push(TestLLM.text("continued", `continued_${outcome}`))
const exit = yield* session.prompt({ text: "Request echo." }).pipe(Effect.exit)
expect(exit._tag).toBe(declined ? "Failure" : "Success")
expect(llm.requests.length - before).toBe(declined ? 1 : 2)
const calls = (yield* sessions.messages({ sessionID: session.id })).flatMap((message) =>
message.type === "assistant" ? message.content.filter((part) => part.type === "tool") : [],
)
expect(calls[0].state.status).toBe("error")
if (calls[0].state.status !== "error") return yield* Effect.die("Expected durable tool failure")
expect(calls[0].state.error.message).toBe(
declined ? "The user declined this tool call" : correction ? "Use a safer value." : "ordinary failure",
)
if (!declined)
expect(JSON.stringify(llm.requests[before + 1].messages)).toContain(
correction ? "Use a safer value." : "ordinary failure",
)
if (!declined) {
const db = (yield* Database.Service).db
expect(
(yield* db.select().from(SessionTable).where(eq(SessionTable.id, session.id)).get().pipe(Effect.orDie))
?.time_suspended,
).toBeNull()
}
}
}),
)
it.live("fresh open and reopen share effective capabilities and identical request assembly", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const resolve = yield* SessionResolve.Service
const db = (yield* Database.Service).db
const bus = yield* Bus.Service
const llm = yield* TestLLM.Service
const input = { model, tools: [echo(Effect.succeed)], instructions: ["Stable policy."], system: "Stable system." }
const session = yield* sessions.open(input)
yield* llm.push(TestLLM.text("done", "parity_reply"))
yield* session.prompt({ text: "hello" })
const before = yield* sessions.get(session.id)
const first = yield* resolve.resolve(before)
if (first.status !== "attached") return yield* Effect.die("Expected supplied capabilities")
const selected = yield* first.capabilities.select(session.id)
yield* InstructionState.prepare(db, bus, selected.instructions, session.id)
const loaded = yield* first.capabilities.load(selected)
const prepare = (capabilities: typeof first.capabilities, value: typeof loaded) =>
capabilities.prepare({
scope: { session: value.session, agentID: value.agent.id, model: value.model, tools: value.tools },
transcript: { system: [...llm.requests[0].system], messages: [...llm.requests[0].messages] },
})
const initial = yield* prepare(first.capabilities, loaded)
const sequence = yield* Bus.latestSequence(db, session.id)
const reopened = yield* sessions.open({ ...input, id: session.id, title: "ignored on adopt" })
expect(yield* sessions.get(reopened.id)).toEqual(before)
const second = yield* resolve.resolve(before)
if (second.status !== "attached") return yield* Effect.die("Expected reopened capabilities")
const reselected = yield* second.capabilities.select(reopened.id)
yield* InstructionState.prepare(db, bus, reselected.instructions, reopened.id)
const reloaded = yield* second.capabilities.load(reselected)
expect(reloaded.initial).toBe(loaded.initial)
expect(reloaded.agent).toEqual(loaded.agent)
expect(reloaded.model).toEqual(loaded.model)
expect(reloaded.tools.definitions).toEqual(loaded.tools.definitions)
expect((yield* prepare(second.capabilities, reloaded)).request).toEqual(initial.request)
const call = {
sessionID: session.id,
agent: loaded.agent.id,
messageID: SessionMessage.ID.create(),
call: {
type: "tool-call" as const,
id: "parity_echo",
name: "execute",
input: { code: 'return await tools.echo({ text: "same" })' },
},
}
expect(yield* reloaded.tools.execute(call)).toEqual(yield* loaded.tools.execute(call))
expect(yield* Bus.latestSequence(db, session.id)).toBe(sequence)
const prior = yield* sessions.create({
location: Location.Ref.make({ directory: AbsolutePath.make("/prior-host") }),
})
const adopted = yield* sessions.open({ ...input, id: prior.id })
expect(yield* sessions.get(adopted.id)).toEqual(prior)
yield* llm.push(TestLLM.text("reconnected", "adopt_reply"))
yield* adopted.prompt({ text: "Reconnect from a different cwd." })
expect((yield* sessions.get(prior.id)).location).toEqual(prior.location)
}),
)
it.live("hot Sources produce chronological diffs alongside durable entries between busy periods", () =>
Effect.gen(function* () {
const llm = yield* TestLLM.Service
const sessions = yield* Session.Service
const entries = yield* InstructionEntry.Service
const db = (yield* Database.Service).db
const instructions = Source.mutable<ReadonlyArray<string> | Instructions.Unavailable>(["First policy."])
const tools = Source.mutable([echo(Effect.succeed)])
const session = yield* sessions.open({ model, tools, instructions })
yield* entries.put({
sessionID: session.id,
key: InstructionEntry.Key.make("thread-policy"),
value: "Durable policy.",
})
yield* llm.push(TestLLM.text("first", "first_reply"))
yield* session.prompt({ text: "first" })
const initial = llm.requests[0].system
yield* instructions.update(() => ["Second policy."])
yield* tools.update(() => [echo(Effect.succeed, "second_echo")])
yield* llm.push(TestLLM.text("second", "second_reply"))
yield* session.prompt({ text: "second" })
expect(llm.requests[1].system).toEqual(initial)
const updates = (yield* sessions.messages({ sessionID: session.id })).filter(
(message) => message.type === "system",
)
expect(updates).toHaveLength(1)
expect(updates[0].text).toContain("Second policy.")
expect(updates[0].text).toContain("second_echo")
expect(
llm.requests[1].messages.some(
(message) =>
message.role === "system" &&
message.content.some((part) => part.type === "text" && part.text.includes("Second policy.")),
),
).toBe(true)
const state = yield* db
.select()
.from(InstructionStateTable)
.where(eq(InstructionStateTable.session_id, session.id))
.get()
.pipe(Effect.orDie)
expect(state?.initial_values["session/instructions"]).toBe(Instructions.hash(["First policy."]))
expect(state?.current_values["session/instructions"]).toBe(Instructions.hash(["Second policy."]))
expect(state?.current_values["api/thread-policy"]).toBe(Instructions.hash("Durable policy."))
yield* instructions.set(Instructions.unavailable)
yield* llm.push(TestLLM.text("retained", "retained_reply"))
yield* session.prompt({ text: "temporarily unavailable" })
expect(
(yield* sessions.messages({ sessionID: session.id })).filter((message) => message.type === "system"),
).toHaveLength(1)
yield* instructions.set([])
yield* llm.push(TestLLM.text("removed", "removed_reply"))
yield* session.prompt({ text: "removed" })
expect(
(yield* sessions.messages({ sessionID: session.id, order: "asc" }))
.filter((message) => message.type === "system")
.at(-1)?.text,
).toContain("no longer apply")
}),
)
it.live("pins capabilities across coalesced drains but rereads their Sources at safe boundaries", () =>
Effect.gen(function* () {
const llm = yield* TestLLM.Service
const sessions = yield* Session.Service
const began = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const settled: string[] = []
const executed: string[] = []
const tools = Source.mutable([
echo((text) =>
Deferred.succeed(began, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.andThen(
Effect.sync(() => {
executed.push("old")
return text
}),
),
Effect.ensuring(
Effect.sync(() => {
settled.push("tool")
}),
),
),
),
])
const instructions = Source.mutable(["Old policy."])
const session = yield* sessions.open({
model,
tools,
instructions,
retire: () =>
Effect.sync(() => {
settled.push("retired")
}),
})
yield* llm.push(
TestLLM.tool("blocked_echo", "execute", { code: 'return await tools.echo({ text: "blocked" })' }),
TestLLM.tool("updated_echo", "execute", { code: 'return await tools.updated_echo({ text: "updated" })' }),
TestLLM.text("finished", "busy_reply"),
)
const prompting = yield* session.prompt({ text: "Start work." }).pipe(Effect.forkScoped)
yield* Deferred.await(began)
yield* sessions.open({
model,
id: session.id,
tools: [
echo(
(text) =>
Effect.sync(() => {
executed.push("replacement")
return text
}),
"replacement_echo",
),
],
instructions: ["Replacement policy."],
})
expect(settled).toEqual([])
yield* tools.set([
echo(
(text) =>
Effect.sync(() => {
executed.push("updated source")
return text
}),
"updated_echo",
),
])
yield* instructions.set(["Updated old policy."])
yield* sessions.prompt({ sessionID: session.id, text: "Steer during work." })
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(prompting)
yield* sessions.wait(session.id)
expect(executed).toEqual(["old", "updated source"])
expect(settled).toEqual(["tool", "retired"])
expect(
llm.requests[1].messages.some(
(message) =>
message.role === "system" &&
message.content.some((part) => part.type === "text" && part.text.includes("Updated old policy.")),
),
).toBe(true)
expect(JSON.stringify(llm.requests.slice(0, 3))).not.toContain("Replacement policy.")
yield* llm.push(
TestLLM.tool("replacement_call", "execute", {
code: 'return await tools.replacement_echo({ text: "replacement" })',
}),
TestLLM.text("replacement finished", "replacement_reply"),
)
yield* session.prompt({ text: "Next busy period." })
expect(executed).toEqual(["old", "updated source", "replacement"])
expect(
llm.requests[3].messages.some(
(message) =>
message.role === "system" &&
message.content.some((part) => part.type === "text" && part.text.includes("Replacement policy.")),
),
).toBe(true)
}),
)
it.live("concurrent opens isolate their tools, instructions, and executable snapshots", () =>
Effect.gen(function* () {
const llm = yield* TestLLM.Service
const resolve = yield* SessionResolve.Service
const sessions = yield* Session.Service
const gate = yield* llm.gate
const executed: string[] = []
const first = yield* sessions.open({
model,
tools: [
echo(
(text) =>
Effect.sync(() => {
executed.push("first")
return text
}),
"first_echo",
),
],
instructions: ["First private policy."],
})
const second = yield* sessions.open({
model,
tools: [
echo(
(text) =>
Effect.sync(() => {
executed.push("second")
return text
}),
"second_echo",
),
],
instructions: ["Second private policy."],
})
yield* llm.push(
TestLLM.tool("first_call", "execute", { code: 'return await tools.first_echo({ text: "first" })' }),
TestLLM.tool("second_call", "execute", { code: 'return await tools.second_echo({ text: "second" })' }),
TestLLM.text("first done", "first_isolation_reply"),
TestLLM.text("second done", "second_isolation_reply"),
)
const firstPrompt = yield* first.prompt({ text: "first" }).pipe(Effect.forkScoped)
yield* llm.wait(1)
const secondPrompt = yield* second.prompt({ text: "second" }).pipe(Effect.forkScoped)
yield* llm.wait(2)
expect(llm.requests).toHaveLength(2)
const firstRequest = llm.requests.find((request) => request.http?.headers?.["X-Session-Id"] === first.id)
const secondRequest = llm.requests.find((request) => request.http?.headers?.["X-Session-Id"] === second.id)
expect(JSON.stringify(firstRequest)).toContain("First private policy.")
expect(JSON.stringify(firstRequest)).toContain("first_echo")
expect(JSON.stringify(firstRequest)).not.toContain("Second private policy.")
expect(JSON.stringify(firstRequest)).not.toContain("second_echo")
expect(JSON.stringify(secondRequest)).toContain("Second private policy.")
expect(JSON.stringify(secondRequest)).toContain("second_echo")
expect(JSON.stringify(secondRequest)).not.toContain("First private policy.")
yield* gate.release
yield* Fiber.join(firstPrompt)
yield* Fiber.join(secondPrompt)
expect(executed.toSorted()).toEqual(["first", "second"])
for (const session of [first, second]) {
const capabilities = yield* resolve.resolve(yield* sessions.get(session.id))
if (capabilities.status !== "attached") return yield* Effect.die("Expected isolated capabilities")
const selected = yield* capabilities.capabilities.select(session.id)
expect(selected.tools.codeModeCatalog?.map((tool) => tool.path)).toEqual([
session === first ? "first_echo" : "second_echo",
])
}
}),
)
it.live("retires deleted capabilities and removes their durable ownership marker", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const resolve = yield* SessionResolve.Service
const retired: string[] = []
const session = yield* sessions.open({
model,
retire: () =>
Effect.sync(() => {
retired.push("retired")
}),
})
yield* sessions.remove(session.id)
expect(retired).toEqual(["retired"])
expect(resolve.status(session.id)).toBe("unowned")
expect(yield* sessions.get(session.id).pipe(Effect.flip)).toBeInstanceOf(Session.NotFoundError)
}),
)
})
+26 -50
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { LLMClient, LLMEvent, LanguageModel, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import { LLMClient, LLMEvent, LanguageModel, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -8,7 +8,6 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { EventTable } from "@opencode-ai/core/event/sql"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import type { SessionContext } from "@opencode-ai/core/session/context"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionModelRequest } from "@opencode-ai/core/session/model-request"
@@ -75,18 +74,6 @@ const resolved = SessionRunnerModel.resolved(model, {
cost,
limit: { context: 200_000, output: 32_000 },
})
const context = (
session: Session.Info,
messages: readonly SessionMessage.Info[],
): Effect.Effect<SessionContext.Loaded> =>
Effect.succeed({
session,
agent: { id: Agent.defaultID, info: { ...Agent.Info.default(Agent.defaultID), system: "Working agent system" } },
model: resolved,
initial: "Session instructions",
messages,
tools: { definitions: [], execute: () => Effect.die("Compaction must not execute tools") },
})
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
@@ -106,7 +93,7 @@ const it = testEffect(
)
test("compaction prompt preserves detailed work state and relevant files", () => {
const prompt = SessionCompaction.buildPrompt()
const prompt = SessionCompaction.buildPrompt({ context: ["conversation history"] })
expect(prompt).toContain("## Work State\n### Completed")
expect(prompt).toContain("### Active")
@@ -138,7 +125,7 @@ test("compaction truncation does not split surrogate pairs", () => {
})
test("compaction prompt requires the checkpoint headings in order", () => {
const prompt = SessionCompaction.buildPrompt()
const prompt = SessionCompaction.buildPrompt({ context: ["Conversation history"] })
expect(prompt.match(/^#{2,3} .+$/gm)).toEqual([
"## Objective",
"## Important Details",
@@ -262,8 +249,8 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
expect(
yield* compaction.compactManual({
session,
resolveModel: () => Effect.succeed(resolved),
prepare: modelRequests.prepare,
context: context(session, [userMessage]),
messages: [userMessage],
inputID: SessionMessage.ID.make("msg_manual_compaction"),
}),
@@ -282,9 +269,6 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
"x-opencode-client": "opencode",
})
expect(requests[0]?.generation).toBeUndefined()
expect(requests[0]?.system.map((part) => part.text)).toEqual(["Working agent system", "Session instructions"])
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "user"])
expect(requests[0]?.messages.at(-1)?.content).toEqual([Message.text(SessionCompaction.buildPrompt())])
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
expect(JSON.stringify(requests[0]?.messages)).toContain("Use Effect services and generators.")
expect(yield* store.context(sessionID)).toMatchObject([
@@ -321,20 +305,19 @@ it.effect("forked session compaction reuses the fork root prompt cache key", ()
fork_boundary: { type: "before", messageID: SessionMessage.ID.create() },
})
const modelRequests = yield* SessionModelRequest.Service
const messages: SessionMessage.Info[] = [
{
id: SessionMessage.ID.create(),
type: "user",
text: "Summarize the forked conversation.",
time: { created: DateTime.makeUnsafe(0) },
},
]
expect(
yield* compaction.compactManual({
session,
context: context(session, messages),
messages,
resolveModel: () => Effect.succeed(resolved),
prepare: modelRequests.prepare,
messages: [
{
id: SessionMessage.ID.create(),
type: "user",
text: "Summarize the forked conversation.",
time: { created: DateTime.makeUnsafe(0) },
},
],
inputID: SessionMessage.ID.make("msg_fork_compaction"),
}),
).toEqual({ status: "completed" })
@@ -344,45 +327,38 @@ it.effect("forked session compaction reuses the fork root prompt cache key", ()
}),
)
it.effect("applies the working agent's context hooks to compaction requests", () =>
it.effect("keeps session context hooks away from compaction requests", () =>
Effect.gen(function* () {
requests = []
const compaction = yield* SessionCompaction.Service
// Context hooks shape the agent conversation; compaction is not part of it,
// so it opts out and the transcript passes through unchanged.
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
expect(event.agent).toBe(Agent.defaultID)
event.system.push(SystemPart.make("Injected conversation context"))
event.messages.push(Message.user("Additional conversation context"))
}),
)
const session = yield* insertSession(Session.ID.make("ses_hook_compaction"))
const modelRequests = yield* SessionModelRequest.Service
const messages: SessionMessage.Info[] = [
{
id: SessionMessage.ID.create(),
type: "user",
text: "Summarize this conversation.",
time: { created: DateTime.makeUnsafe(0) },
},
]
expect(
yield* compaction.compactManual({
session,
context: context(session, messages),
messages,
resolveModel: () => Effect.succeed(resolved),
prepare: modelRequests.prepare,
messages: [
{
id: SessionMessage.ID.create(),
type: "user",
text: "Summarize this conversation.",
time: { created: DateTime.makeUnsafe(0) },
},
],
inputID: SessionMessage.ID.make("msg_hook_compaction"),
}),
).toEqual({ status: "completed" })
expect(requests).toHaveLength(1)
expect(requests[0]?.system.map((part) => part.text)).toEqual([
"Working agent system",
"Session instructions",
"Injected conversation context",
])
expect(requests[0]?.messages.at(-2)?.content).toEqual([Message.text("Additional conversation context")])
expect(requests[0]?.messages.at(-1)?.content).toEqual([Message.text(SessionCompaction.buildPrompt())])
expect(requests[0]?.system).toEqual([])
}),
)
+6 -1
View File
@@ -18,6 +18,7 @@ import { UserInterruptedError } from "@opencode-ai/core/session/error"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionResolve } from "@opencode-ai/core/session/resolve"
import { SessionRunner } from "@opencode-ai/core/session/runner/index"
import { SessionInboxTable, SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
@@ -26,7 +27,9 @@ import { eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionStore.node, Job.node, KV.node, Session.node])),
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionStore.node, Job.node, KV.node, Session.node, SessionResolve.node]),
),
)
describe("SessionExecution lifecycle", () => {
@@ -1135,6 +1138,7 @@ function buildExecution(
const store = yield* SessionStore.Service
const jobs = overrideJobs ?? (yield* Job.Service)
const sessions = yield* Session.Service
const resolve = yield* SessionResolve.Service
const sessionLayer = Layer.effect(
Session.Service,
Effect.gen(function* () {
@@ -1170,6 +1174,7 @@ function buildExecution(
Layer.provide(Layer.succeed(Database.Service, database)),
Layer.provide(Layer.succeed(Bus.Service, bus)),
Layer.provide(Layer.succeed(SessionStore.Service, store)),
Layer.provide(Layer.succeed(SessionResolve.Service, resolve)),
Layer.provide(Layer.succeed(Job.Service, jobs)),
Layer.provide(locations),
),
+10 -164
View File
@@ -1641,7 +1641,7 @@ describe("SessionRunnerLLM", () => {
yield* runner.drain({ sessionID, force: false, continuation: moved.continuation })
expect(requests).toHaveLength(3)
expect(requests[1]?.messages.at(-1)).toEqual(Message.user(SessionCompaction.buildPrompt()))
expect(userTexts(requests[1])[0]).toContain("Create a new anchored summary")
expect(userTexts(requests[2])[0]).toContain("<summary>\nEntry summary\n</summary>")
expect(yield* session.inbox(sessionID)).toEqual([])
}),
@@ -2287,7 +2287,7 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(4)
expect(userTexts(requests[1])).toContain("Steer after compaction")
expect(userTexts(requests[1])).toContain("Completion after compaction")
expect(requests[2]?.messages.at(-1)).toEqual(Message.user(SessionCompaction.buildPrompt()))
expect(userTexts(requests[2])[0]).toContain("Create a new anchored summary")
expect(userTexts(requests[3])).toContain("Queue after compaction")
expect(yield* SessionInbox.find((yield* Database.Service).db, first.id)).toBeUndefined()
expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toMatchObject({
@@ -2368,17 +2368,12 @@ describe("SessionRunnerLLM", () => {
yield* runPrompt(session, "Earlier question")
requests.length = 0
systemBaseline = "Changed before manual compaction"
yield* TestLLM.push(TestLLM.text("Manual summary", "text-manual-unknown-summary"))
const compaction = yield* session.compact({ sessionID, delivery: "steer" })
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
expect(userTexts(requests[0])[0]).toContain("Earlier question")
expect(requests[0]?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"])
expect(messageRoles(requests[0])).toEqual(["user", "assistant", "system", "user"])
expect(systemTexts(requests[0])).toEqual(["Changed before manual compaction"])
expect(requests[0]?.messages.at(-1)).toEqual(Message.user(SessionCompaction.buildPrompt()))
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "completed",
@@ -2409,7 +2404,7 @@ describe("SessionRunnerLLM", () => {
// Steer-delivered compaction runs at the boundary after the active step, ahead of
// the queued prompt, and consuming it does not trigger an input-free model call.
expect(requests).toHaveLength(3)
expect(requests[1]?.messages.at(-1)).toEqual(Message.user(SessionCompaction.buildPrompt()))
expect(userTexts(requests[1])[0]).toContain("Create a new anchored summary")
expect(userTexts(requests[2])).toContain("Queued prompt")
expect(yield* SessionInbox.find((yield* Database.Service).db, compaction.id)).toBeUndefined()
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
@@ -2426,13 +2421,7 @@ describe("SessionRunnerLLM", () => {
currentModel = recoveryModel
const stream = yield* TestLLM.gate
yield* TestLLM.push(
TestLLM.complete(
{ reason: { normalized: "tool-calls" } },
LLMEvent.reasoningStart({ id: "reasoning-active" }),
LLMEvent.reasoningDelta({ id: "reasoning-active", text: "Check the active work" }),
LLMEvent.reasoningEnd({ id: "reasoning-active", providerMetadata: { openai: { signature: "signed" } } }),
LLMEvent.toolCall({ id: "call-active", name: "echo", input: { text: "active" } }),
),
TestLLM.tool("call-active", "echo", { text: "active" }),
[LLMEvent.textDelta({ id: "summary", text: "durable summary" })],
TestLLM.text("Continued", "text-continued-after-compact"),
)
@@ -2446,20 +2435,7 @@ describe("SessionRunnerLLM", () => {
// The compaction summary is requested before the tool turn's continuation step.
expect(requests).toHaveLength(3)
expect(requests[1]?.messages.at(-1)).toEqual(Message.user(SessionCompaction.buildPrompt()))
expect(requests[1]?.system).toEqual(requests[0]?.system)
expect(requests[1]?.tools).toEqual(requests[0]?.tools)
expect(requests[1]?.tools.map((tool) => tool.name)).toContain("echo")
expect(messageRoles(requests[1])).toEqual(["user", "assistant", "tool", "user"])
expect(requests[1]?.messages[0]).toEqual(requests[0]?.messages[0])
expect(requests[1]?.messages[1]?.content).toMatchObject([
{ type: "reasoning", text: "Check the active work", providerMetadata: { openai: { signature: "signed" } } },
{ type: "tool-call", id: "call-active", name: "echo", input: { text: "active" } },
])
expect(requests[1]?.messages[2]?.content).toMatchObject([
{ type: "tool-result", id: "call-active", name: "echo", result: { type: "text", value: "active" } },
])
expect(executions).toEqual(["active"])
expect(userTexts(requests[1])[0]).toContain("Create a new anchored summary")
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "completed",
@@ -2468,121 +2444,6 @@ describe("SessionRunnerLLM", () => {
}),
)
for (const mode of ["manual", "auto"] as const) {
it.effect(`uses the last assistant's custom agent after a switch for ${mode} compaction`, () =>
Effect.gen(function* () {
const session = yield* setup
const agents = yield* Agent.Service
const bus = yield* Bus.Service
const hooks = yield* PluginHooks.Service
const seen: Agent.ID[] = []
yield* agents.transform((draft) =>
draft.update(Agent.ID.make("reviewer"), (agent) => {
agent.mode = "primary"
agent.system = "Reviewer instructions"
}),
)
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
seen.push(event.agent)
event.system.push(SystemPart.make(`Context hook for ${event.agent}`))
if (event.agent === "build") delete event.tools.echo
}),
)
yield* bus.publish(SessionEvent.AgentSelected, { sessionID, agent: Agent.ID.make("reviewer") })
yield* TestLLM.push(TestLLM.textWithUsage("Earlier answer", "text-custom-agent", 3_950))
yield* runPrompt(session, "Earlier question ".repeat(180))
const original = requests[0]
yield* bus.publish(SessionEvent.AgentSelected, { sessionID, agent: Agent.ID.make("build") })
currentModel = compactModel
requests.length = 0
seen.length = 0
yield* TestLLM.push(TestLLM.text("Reviewer summary", "text-custom-summary"))
if (mode === "manual") yield* session.compact({ sessionID })
if (mode === "auto") {
yield* admit(session, "Recent exact request ".repeat(180))
yield* TestLLM.push(TestLLM.text("Continued by build", "text-custom-continuation"))
}
yield* session.resume(sessionID)
expect(seen).toEqual(
mode === "manual" ? [Agent.ID.make("reviewer")] : [Agent.ID.make("reviewer"), Agent.ID.make("build")],
)
expect(requests[0]?.model).toBe(compactModel)
expect(requests[0]?.system).toEqual(original?.system)
expect(requests[0]?.system.map((part) => part.text)).toEqual([
"Reviewer instructions",
"Initial context",
"Context hook for reviewer",
])
expect(requests[0]?.tools).toEqual(original?.tools)
expect(requests[0]?.tools.map((tool) => tool.name)).toContain("echo")
expect(requests[0]?.messages[0]).toEqual(original?.messages[0])
expect(requests[0]?.messages.at(-1)).toEqual(Message.user(SessionCompaction.buildPrompt()))
expect(yield* session.context(sessionID)).toContainEqual(
expect.objectContaining({ type: "compaction", status: "completed", summary: "Reviewer summary" }),
)
expect((yield* session.get(sessionID))?.agent).toBe(Agent.ID.make("build"))
if (mode === "auto") {
expect(requests[1]?.system.map((part) => part.text)).toContain("Context hook for build")
expect(requests[1]?.tools.map((tool) => tool.name)).not.toContain("echo")
}
if (mode === "manual") {
expect((yield* session.context(sessionID)).some((message) => message.type === "assistant")).toBe(false)
yield* bus.publish(SessionEvent.AgentSelected, { sessionID, agent: Agent.ID.make("build") })
const input = yield* admit(session, "New input without an assistant response")
yield* bus.publish(SessionEvent.InboxDelivered, { sessionID, inboxID: input.id })
yield* TestLLM.push(TestLLM.text("Updated reviewer summary", "text-checkpoint-summary"))
yield* session.compact({ sessionID })
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
expect(seen).toEqual([Agent.ID.make("reviewer"), Agent.ID.make("reviewer")])
expect(requests[1]?.system).toEqual(original?.system)
expect(requests[1]?.tools).toEqual(original?.tools)
expect(userTexts(requests[1])[0]).toContain("<summary>\nReviewer summary\n</summary>")
expect(userTexts(requests[1])).toContain("New input without an assistant response")
expect(requests[1]?.messages.at(-1)).toEqual(Message.user(SessionCompaction.buildPrompt()))
expect(yield* session.context(sessionID)).toContainEqual(
expect.objectContaining({ type: "compaction", status: "completed", summary: "Updated reviewer summary" }),
)
}
}),
)
}
it.effect("fails manual compaction without executing a summarizer tool call even when it returns text", () =>
Effect.gen(function* () {
const session = yield* setup
yield* TestLLM.push(TestLLM.text("Earlier answer", "text-tool-summary-history"))
yield* runPrompt(session, "Earlier question")
yield* TestLLM.push(
TestLLM.complete(
{ reason: { normalized: "tool-calls" } },
LLMEvent.textDelta({ id: "summary", text: "Must not become a checkpoint" }),
LLMEvent.toolCall({ id: "call-summary", name: "echo", input: { text: "Must not execute" } }),
),
)
const compaction = yield* session.compact({ sessionID })
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
expect(requests[1]?.tools.map((tool) => tool.name)).toContain("echo")
expect(executions).toEqual([])
expect(authorizations).toEqual([])
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "failed",
error: { type: "compaction.failed", message: "Compaction attempted to call a tool" },
})
expect(yield* session.context(sessionID)).toContainEqual(
expect.objectContaining({ type: "user", text: "Earlier question" }),
)
}),
)
it.effect("preserves provider errors from manual compaction", () =>
Effect.gen(function* () {
const session = yield* setup
@@ -2689,12 +2550,7 @@ describe("SessionRunnerLLM", () => {
yield* runPrompt(session, "Recent exact request ".repeat(180))
expect(requests).toHaveLength(2)
expect(messageRoles(requests[0])).toEqual(["user", "assistant", "user"])
expect(userTexts(requests[0])).toEqual(["Earlier question ".repeat(180), SessionCompaction.buildPrompt()])
expect(requests[0]?.messages[1]?.content).toMatchObject([{ type: "text", text: "Earlier answer" }])
expect(requests[0]?.model).toBe(compactModel)
expect(requests[0]?.system).toEqual(requests[1]?.system)
expect(requests[0]?.tools).toEqual(requests[1]?.tools)
expect(userTexts(requests[0])[0]).toContain("## Objective")
expect(userTexts(requests[1])).toHaveLength(1)
expect(userTexts(requests[1])[0]).toContain("<summary>\n## Objective\n- Preserve the task\n</summary>")
expect(userTexts(requests[1])[0]).toContain(`[User]: ${"Recent exact request ".repeat(180)}`)
@@ -2704,10 +2560,8 @@ describe("SessionRunnerLLM", () => {
expect(context[0]).toMatchObject({
type: "compaction",
summary: "## Objective\n- Preserve the task",
recent: `[User]: ${"Recent exact request ".repeat(180)}`,
})
const checkpoint = requests[1]?.messages[0]
requests.length = 0
executions.length = 0
yield* TestLLM.push(
@@ -2717,13 +2571,10 @@ describe("SessionRunnerLLM", () => {
yield* runPrompt(session, "Newest exact request ".repeat(180))
expect(requests).toHaveLength(2)
expect(requests[0]?.messages[0]).toEqual(checkpoint)
expect(requests[0]?.messages.at(-1)).toEqual(Message.user(SessionCompaction.buildPrompt()))
expect(userTexts(requests[0])[0]).toContain("<summary>\n## Objective\n- Preserve the task\n</summary>")
expect(userTexts(requests[0])[0]).toContain(
"<previous-summary>\n## Objective\n- Preserve the task\n</previous-summary>",
)
expect(userTexts(requests[0])[0]).toContain("Recent exact request")
expect(userTexts(requests[0]).join("\n")).not.toContain("<previous-summary>")
expect(userTexts(requests[0]).at(-1)).not.toContain("Preserve the task")
expect(userTexts(requests[0]).join("\n")).not.toContain("Newest exact request")
expect((yield* (yield* SessionStore.Service).context(sessionID))[0]).toMatchObject({
type: "compaction",
summary: "## Objective\n- Preserve the updated task",
@@ -2790,12 +2641,7 @@ describe("SessionRunnerLLM", () => {
yield* runPrompt(session, "Continue")
expect(requests).toHaveLength(3)
expect(requests[1]?.messages.at(-1)).toEqual(Message.user(SessionCompaction.buildPrompt()))
expect(requests[1]?.messages.slice(0, -1)).toEqual(requests[0]?.messages.slice(0, -1))
expect(requests[1]?.system).toEqual(requests[0]?.system)
expect(requests[1]?.tools).toEqual(requests[0]?.tools)
expect(requests[1]?.model).toBe(recoveryModel)
expect(userTexts(requests[1])).not.toContain("Continue")
expect(userTexts(requests[1])[0]).toContain("## Objective")
expect(userTexts(requests[2])[0]).toContain("<summary>\n## Objective\n- Recover overflow\n</summary>")
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "compaction", summary: "## Objective\n- Recover overflow" },
+47
View File
@@ -0,0 +1,47 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { Source } from "../src/source"
import { session } from "./fixture/capabilities"
import { it } from "./lib/effect"
it.effect("mutable sources sample current values and defer writes until execution", () =>
Effect.gen(function* () {
const source = Source.mutable(1)
const independent = Source.mutable(1)
const read = source.get(session)
const set = source.set(2)
const update = source.update((value) => value + 3)
expect(yield* read).toBe(1)
yield* set
expect(yield* read).toBe(2)
yield* update
expect(yield* read).toBe(5)
expect(yield* independent.get(session)).toBe(1)
}),
)
it.effect("constant and plain values retain their identity", () =>
Effect.gen(function* () {
const values = ["read", "write"]
expect(yield* Source.constant(values).get(session)).toBe(values)
expect(yield* Source.from(values).get(session)).toBe(values)
expect(yield* Source.from(null).get(session)).toBeNull()
expect(yield* Source.from(undefined).get(session)).toBeUndefined()
expect(yield* Source.from(0).get(session)).toBe(0)
const record = { get: "not a source" }
expect(yield* Source.from(record).get(session)).toBe(record)
}),
)
it.effect("structural sources receive the session and preserve typed failures", () =>
Effect.gen(function* () {
const source: Source.Interface<string, "unavailable"> = {
get: (current) => (current.title === "unavailable" ? Effect.fail("unavailable") : Effect.succeed(current.id)),
}
const converted: Source.Interface<string, "unavailable"> = Source.from(source)
expect(converted).toBe(source)
expect(yield* converted.get(session)).toBe(session.id)
expect(yield* converted.get({ ...session, title: "unavailable" }).pipe(Effect.flip)).toBe("unavailable")
}),
)
+51 -8
View File
@@ -1,12 +1,13 @@
import { expect, test } from "bun:test"
import { CodeModeTool } from "@opencode-ai/core/codemode/tool"
import { Permission } from "@opencode-ai/core/permission"
import { Tool } from "@opencode-ai/core/tool"
import { execute } from "@opencode-ai/core/tool/runtime"
import { Agent } from "@opencode-ai/schema/agent"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { Info } from "@opencode-ai/schema/tool"
import { Effect, Schema } from "effect"
import { Cause, Effect, Exit, Schema } from "effect"
const context = {
sessionID: Session.ID.make("ses_execute"),
@@ -98,18 +99,60 @@ test("foreign typed failures settle as Tool.Error at the untrusted boundary", as
class ForeignFailure extends Schema.TaggedError<ForeignFailure>()("Plugin.ForeignFailure", {
message: Schema.String,
}) {}
const lying: Info = {
name: "lying",
const tool: Info = {
name: "foreign",
description: "Fails with a non-Tool.Error typed failure",
input: Schema.Struct({}),
execute: () => new ForeignFailure({ message: "transport died" }) as never,
execute: () => new ForeignFailure({ message: "transport died" }),
}
const exit = await Effect.runPromiseExit(execute(lying, {}, context))
expect(exit._tag).toBe("Failure")
const error = exit._tag === "Failure" ? exit.cause.reasons.find((reason) => "error" in reason)?.error : undefined
const error = await Effect.runPromise(execute(tool, {}, context).pipe(Effect.flip))
expect(error).toBeInstanceOf(Tool.Error)
expect((error as Tool.Error).message).toBe("transport died")
expect(error.message).toBe("transport died")
})
test("execution preserves Tool.Error identity and tunnels only canonical permission declines", async () => {
const failure = new Tool.Error({ message: "failed", metadata: { reason: "test" } })
const tool: Info = {
name: "failure",
description: "Fails",
input: Schema.Struct({}),
execute: () => failure,
}
expect(await Effect.runPromise(execute(tool, {}, context).pipe(Effect.flip))).toBe(failure)
const decline = new Permission.DeclinedError({})
const exit = await Effect.runPromiseExit(execute({ ...tool, execute: () => decline }, {}, context))
expect(
Exit.isFailure(exit) && exit.cause.reasons.some((reason) => reason._tag === "Die" && reason.defect === decline),
).toBe(true)
class ForeignDecline extends Schema.TaggedError<ForeignDecline>()("Permission.DeclinedError", {
message: Schema.String,
}) {}
const normalized = await Effect.runPromise(
execute({ ...tool, execute: () => new ForeignDecline({ message: "not a host decline" }) }, {}, context).pipe(
Effect.flip,
),
)
expect(normalized).toBeInstanceOf(Tool.Error)
expect(normalized.message).toBe("not a host decline")
})
test("execution leaves defects and interruption untouched", async () => {
const defect = new Error("unexpected")
const tool: Info = {
name: "defect",
description: "Dies",
input: Schema.Struct({}),
execute: () => Effect.die(defect),
}
const died = await Effect.runPromiseExit(execute(tool, {}, context))
expect(
Exit.isFailure(died) && died.cause.reasons.some((reason) => reason._tag === "Die" && reason.defect === defect),
).toBe(true)
const interrupted = await Effect.runPromiseExit(execute({ ...tool, execute: () => Effect.interrupt }, {}, context))
expect(Exit.isFailure(interrupted) && Cause.hasInterruptsOnly(interrupted.cause)).toBe(true)
})
test("execute supports callable namespace tools", async () => {
+8 -20
View File
@@ -1298,17 +1298,6 @@ describe("ShellTool", () => {
const shell = yield* Shell.Service
if (!shellID) return
const id = ShellSchema.ID.make(shellID)
const info = yield* shell.get(id)
expect(settled.content).toEqual([
{
type: "text",
text: `Command moved to the background (shell ID: ${shellID}).\nOutput is streaming to: ${info.file}`,
},
{
type: "text",
text: "You will be notified automatically when the command finishes. Avoid sleep commands or polling for completion; if you need the output before then, read the file directly.",
},
])
expect((yield* shell.list()).map((info) => info.id)).toContain(id)
expect((yield* shell.wait(id)).status).toBe("timeout")
expect((yield* Fiber.join(admitted)).valueOrUndefined?.data.item.payload).toMatchObject({
@@ -1534,20 +1523,19 @@ describe("ShellTool", () => {
const settled = yield* Fiber.join(waiting)
const shellID = typeof settled.metadata?.shellID === "string" ? settled.metadata.shellID : undefined
expect(settled.metadata).toMatchObject({ truncated: false })
expect(settled.content?.[0]).toEqual({
type: "text",
text: "The command was moved to the background.",
})
expect(settled.content?.[1]).toMatchObject({
type: "text",
text: expect.stringContaining("DO NOT sleep, poll"),
})
expect(shellID).toStartWith("sh_")
const shell = yield* Shell.Service
if (!shellID) return
const id = ShellSchema.ID.make(shellID)
const info = yield* shell.get(id)
expect(settled.content?.[0]).toEqual({
type: "text",
text: `Command moved to the background (shell ID: ${shellID}).\nOutput is streaming to: ${info.file}`,
})
expect(settled.content?.[1]).toEqual({
type: "text",
text: "You will be notified automatically when the command finishes. Avoid sleep commands or polling for completion; if you need the output before then, read the file directly.",
})
yield* Effect.sleep(Duration.millis(100))
expect((yield* shell.get(id)).status).toBe("running")
expect((yield* shell.list()).map((info) => info.id)).toContain(id)
+147
View File
@@ -0,0 +1,147 @@
import { expect } from "bun:test"
import { Agent } from "@opencode-ai/schema/agent"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Effect, Schema } from "effect"
import { Image } from "../src/image"
import { PluginHooks } from "../src/plugin/hooks"
import { SessionSchema } from "../src/session/schema"
import { SessionMessage } from "../src/session/message"
import { Tool } from "../src/tool"
import { echo } from "./fixture/capabilities"
import { testEffect } from "./lib/effect"
const it = testEffect(LayerNode.compile(LayerNode.group([PluginHooks.node, Image.node])))
const identity = {
sessionID: SessionSchema.ID.make("ses_tool_values"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_tool_values"),
}
const call = (name: string, input: unknown): Parameters<Tool.Snapshot["execute"]>[0] => ({
...identity,
call: { type: "tool-call", id: `call_${name}`, name, input },
})
it.effect("value snapshots preserve definitions and executors after sampling and replacement", () =>
Effect.gen(function* () {
const tool = { ...echo(Effect.succeed), options: { namespace: "demo", codemode: false } }
const values: Tool.Info[] = [tool]
const first = yield* Tool.snapshot(values)
tool.execute = () => Effect.succeed({ output: "changed executor" })
tool.description = "Changed description"
values[0] = {
...tool,
input: Schema.Struct({ count: Schema.Finite }),
output: Schema.Finite,
execute: ({ count }) => Effect.succeed({ output: count + 1 }),
}
const second = yield* Tool.snapshot(values)
values.length = 0
expect((yield* Tool.snapshot(values)).definitions.map((tool) => tool.name)).toEqual(["execute"])
expect(first.definitions[0]?.description).toBe("Echo text with echo")
expect(first.definitions[0]?.inputSchema.properties).toEqual({ text: { type: "string" } })
expect(second.definitions[0]?.description).toBe("Changed description")
expect(second.definitions[0]?.inputSchema.properties).toEqual({ count: { type: "number" } })
expect(yield* first.execute(call("demo_echo", { text: "original" }))).toEqual({
output: "original",
content: [{ type: "text", text: "original" }],
})
expect((yield* second.execute(call("demo_echo", { count: 2 }))).output).toBe(3)
expect(yield* first.execute(call("demo_echo", { count: 2 })).pipe(Effect.flip)).toBeInstanceOf(Tool.Error)
expect(yield* second.execute(call("demo_echo", { text: "original" })).pipe(Effect.flip)).toBeInstanceOf(Tool.Error)
}),
)
it.effect("value snapshots reuse name normalization, validation, and last-valid precedence", () =>
Effect.gen(function* () {
const tool = { ...echo(Effect.succeed, "echo.text"), options: { namespace: "demo.tools", codemode: false } }
const snapshot = yield* Tool.snapshot([
tool,
{ ...tool, name: "echo_text", execute: () => Effect.succeed({ output: "latest" }) },
{ ...tool, options: { namespace: "invalid namespace", codemode: false } },
{ ...echo(Effect.succeed, "execute"), options: { codemode: false } },
])
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["demo_tools_echo_text", "execute"])
expect((yield* snapshot.execute(call("demo_tools_echo_text", { text: "input" }))).output).toBe("latest")
const invalidOutput = yield* Tool.snapshot([
{ ...echo(Effect.succeed), options: { codemode: false }, execute: () => Effect.succeed({ output: 1 }) },
])
expect((yield* invalidOutput.execute(call("echo", { text: "input" })).pipe(Effect.flip)).message).toContain(
"Tool returned an invalid value for its output schema",
)
}),
)
it.live("value snapshots default tools into CodeMode and retain executable catalog entries", () =>
Effect.gen(function* () {
const snapshot = yield* Tool.snapshot([{ ...echo(Effect.succeed), options: { namespace: "demo.tools" } }])
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"])
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["demo.tools.echo"])
expect(yield* snapshot.execute(call("demo_tools_echo", { text: "input" })).pipe(Effect.flip)).toEqual(
new Tool.Error({ message: "Unknown tool: demo_tools_echo" }),
)
expect(
yield* snapshot.execute(call("execute", { code: 'return await tools.demo.tools.echo({ text: "input" })' })),
).toMatchObject({
content: [{ type: "text", text: "input" }],
metadata: { toolCalls: [{ tool: "demo.tools.echo", status: "completed", input: { text: "input" } }] },
})
}),
)
it.effect("value snapshot permissions filter visibility without authorizing execution", () =>
Effect.gen(function* () {
const tools = [{ ...echo(Effect.succeed), options: { permission: "read", codemode: false } }]
const visible = yield* Tool.snapshot(tools, [{ action: "read", resource: "private/*", effect: "deny" }])
expect((yield* visible.execute(call("echo", { text: "private/file.ts" }))).output).toBe("private/file.ts")
const hidden = yield* Tool.snapshot(tools, [{ action: "read", resource: "*", effect: "deny" }])
expect(hidden.definitions.map((tool) => tool.name)).toEqual(["execute"])
expect(yield* hidden.execute(call("echo", { text: "input" })).pipe(Effect.flip)).toBeInstanceOf(Tool.Error)
const directOnly = yield* Tool.snapshot(tools, [{ action: "execute", resource: "*", effect: "deny" }])
expect(directOnly.definitions.map((tool) => tool.name)).toEqual(["echo"])
expect(directOnly.codeModeCatalog).toBeUndefined()
}),
)
it.live("value snapshots use externally scoped hooks and image normalization", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const image = yield* Image.Service
const seen: string[] = []
yield* image.transform((draft) => draft.configure({ autoResize: false, maxBase64Bytes: 0 }))
yield* hooks.register("tool", "execute.before", (event) =>
Effect.sync(() => {
seen.push(`before:${event.tool}`)
event.input = { text: "reviewed" }
}),
)
yield* hooks.register("tool", "execute.after", (event) =>
Effect.sync(() => {
seen.push(`after:${event.tool}`)
if (event.status !== "completed") return
event.result = {
...event.result,
content: [
{ type: "text", text: "reviewed content" },
{
type: "file",
mime: "image/png",
uri: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
},
],
}
}),
)
const snapshot = yield* Tool.snapshot([{ ...echo(Effect.succeed), options: { codemode: false } }])
expect(yield* hooks.has("tool", "execute.before")).toBe(true)
expect(yield* snapshot.execute(call("echo", { text: "input" }))).toEqual({
output: "reviewed",
content: [
{ type: "text", text: "reviewed content" },
{ type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" },
],
})
expect(seen).toEqual(["before:echo", "after:echo"])
}),
)
+3 -1
View File
@@ -91,7 +91,9 @@ export type Info<
readonly name: string
readonly input: Input
readonly description: string
readonly execute: (input: InputValue<Input>, context: Context) => Effect.Effect<Result<Output>, Error>
// Heterogeneous implementations may fail with domain errors. Core normalizes foreign
// failures to Tool.Error; Permission.DeclinedError identity enters the decline tunnel.
readonly execute: (input: InputValue<Input>, context: Context) => Effect.Effect<Result<Output>, unknown>
readonly output?: Output
readonly options?: Options
}
@@ -1,32 +1,5 @@
import { expect, story } from "../../storybook/playwright/story"
for (const tool of ["shell", "execute", "subagent"]) {
for (const open of [false, true]) {
story(`keeps ${tool} inside an existing ${open ? "open" : "closed"} group through execution`, async ({ mount }) => {
const timeline = await mount("current-session-terminal-work--terminal-commands", {
args: { existingGroup: true, tool },
})
const group = timeline.locator('[data-component="collapsed-tool-group"]')
const trigger = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await expect(group).toHaveAttribute("data-timeline-part-ids", "tool_context_lifecycle")
if (open) await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", String(open))
await timeline.getByRole("button", { name: "Start tool", exact: true }).click()
await expect(group).toHaveAttribute("data-timeline-part-ids", "tool_context_lifecycle,tool_shell_lifecycle")
const original = await group.elementHandle()
for (const action of [undefined, "Complete input", "Run command", "Complete command"]) {
if (action) await timeline.getByRole("button", { name: action, exact: true }).click()
await expect(group).toHaveAttribute("data-timeline-part-ids", "tool_context_lifecycle,tool_shell_lifecycle")
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
await expect(timeline.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(1)
await expect(trigger).toHaveAttribute("aria-expanded", String(open))
expect(await original!.evaluate((node) => node.isConnected)).toBe(true)
if (open) await expect(group.locator('[data-timeline-part-id="tool_shell_lifecycle"]')).toBeVisible()
}
})
}
}
for (const expanded of [false, true]) {
// Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts
story(`preserves shell user intent from a ${expanded ? "expanded" : "collapsed"} default`, async ({ mount }) => {
@@ -1,51 +0,0 @@
import { expect, story } from "../../storybook/playwright/story"
story("summarizes subagents as Agent while retaining their card titles", async ({ mount }) => {
const root = await mount("current-tool-group--mixed-tools")
const group = root.locator('[data-component="collapsed-tool-group"]')
await expect(group.getByRole("button", { name: "Used Shell, Read, Agent", exact: true })).toBeVisible()
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
await expect(group.locator('[data-component="task-tool-title"]')).toHaveText(["General", "Explore"])
})
for (const width of [840, 390]) {
story(`keeps grouped cards inside their trigger bounds at ${width}px`, async ({ mount, page }) => {
await page.setViewportSize({ width, height: 600 })
const root = await mount("current-tool-group--mixed-tools")
const group = root.locator('[data-component="collapsed-tool-group"]')
const cards = group.locator('[data-component="task-tool-surface"]')
await expect(cards).toHaveCount(2)
await expect
.poll(() =>
cards.evaluateAll((nodes) =>
nodes.map((node) => {
const card = node.getBoundingClientRect()
const trigger = node.closest('[data-component="tool-trigger"]')!.getBoundingClientRect()
const item = node.closest('[data-slot="context-tool-group-item"]')!.getBoundingClientRect()
return (
card.height === 36 &&
card.top >= trigger.top &&
card.bottom <= trigger.bottom &&
card.top >= item.top &&
card.bottom <= item.bottom
)
}),
),
)
.toEqual([true, true])
const shell = group.locator('[data-timeline-part-id="group_shell"]')
await expect(shell.locator('[data-slot="collapsible-trigger"]')).toHaveCSS("height", "28px")
await shell.getByRole("button").click()
await expect(shell.locator('[data-slot="bash-command"]')).toHaveText("printf 'group geometry'")
await expect(shell.locator('[data-slot="bash-result"]')).toHaveText("group geometry")
await expect
.poll(() =>
shell.evaluate((node) => {
const card = node.querySelector('[data-component="bash-output"]')!.getBoundingClientRect()
const item = node.closest('[data-slot="context-tool-group-item"]')!.getBoundingClientRect()
return card.top >= item.top && card.bottom <= item.bottom
}),
)
.toBe(true)
})
}
@@ -724,9 +724,7 @@
width: 100%;
}
> [data-component="tool-part-wrapper"]
> [data-component="collapsible"]
> [data-slot="collapsible-trigger"]:not([data-hide-details="true"]) {
> [data-component="tool-part-wrapper"] > [data-component="collapsible"] > [data-slot="collapsible-trigger"] {
height: 28px;
}
+2 -10
View File
@@ -508,9 +508,7 @@ function groupContent(
items.forEach((item) => {
const type =
item.content.type === "tool"
? toolGroupType(item.content, shellToolDefaultOpen, editToolDefaultOpen, adjacent?.type === "context")
: undefined
item.content.type === "tool" ? toolGroupType(item.content, shellToolDefaultOpen, editToolDefaultOpen) : undefined
if (type) {
if (adjacent?.type !== type) flush()
adjacent ??= { type, refs: [] }
@@ -528,12 +526,7 @@ function groupContent(
return groups
}
function toolGroupType(
content: Extract<Content, { type: "tool" }>,
shellExpanded: boolean,
editExpanded: boolean,
hasContextGroup: boolean,
) {
function toolGroupType(content: Extract<Content, { type: "tool" }>, shellExpanded: boolean, editExpanded: boolean) {
if (content.name === "question" || hasLoadedFiles(content)) return undefined
if (content.state.status === "error") {
if ((content.name === "shell" || content.name === "execute") && shellExpanded) return undefined
@@ -542,7 +535,6 @@ function toolGroupType(
return "context"
}
if (
!hasContextGroup &&
(content.state.status !== "completed" ||
("metadata" in content.state && content.state.metadata?.status === "running")) &&
(content.name === "shell" || content.name === "execute" || content.name === "subagent")
@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageAssistantTool, SessionMessageInfo } from "@opencode-ai/client/promise"
import { storyDocument, storyTool } from "../storybook/current-session-scenarios"
import { createTimelineProjection, Timeline, TimelineRow } from "./projection"
import { Timeline, TimelineRow } from "./projection"
describe("current session timeline rows", () => {
test("derives turns and tagged rows from chronological current messages", () => {
@@ -725,72 +724,7 @@ describe("current session timeline rows", () => {
expect(rows.flatMap((row) => (row._tag === "AssistantPart" ? [row.group.type] : []))).toEqual([...types])
})
test.each(["shell", "execute", "subagent"])("keeps %s in an existing group throughout execution", (name) => {
const initial = createTimelineProjection({
sessionMessages: storyDocument([storyTool("earlier", "read", "completed", {})]).messages,
status: { type: "busy" },
showReasoningSummaries: false,
})
const phases = [
{ status: "streaming" },
{ status: "running" },
{ status: "completed", metadata: { status: "running" } },
{ status: "completed" },
{ status: "error" },
] as const
phases.reduce((previousRows, phase, index) => {
const result = createTimelineProjection({
sessionMessages: [
...storyDocument([storyTool("earlier", "read", "completed", {})]).messages,
...storyDocument([
storyTool("active", name, phase.status, {}, "metadata" in phase ? { metadata: phase.metadata } : {}),
])
.messages.filter((message) => message.type === "assistant")
.map((message) => ({ ...message, id: "next-step" })),
],
status: { type: "busy" },
showReasoningSummaries: false,
previousRows,
})
const groups = result.rows.filter((row) => row._tag === "AssistantPart")
expect(groups).toHaveLength(1)
expect(groups[0].group).toMatchObject({
type: "context",
refs: [
{ messageID: "msg_tool_projection_assistant", partID: "earlier" },
{ messageID: "next-step", partID: "active" },
],
})
expect(TimelineRow.key(groups[0])).toBe(TimelineRow.key(initial.rows[1]))
if (index > 0) expect(groups[0]).toBe(previousRows.find((row) => row._tag === "AssistantPart")!)
return result.rows
}, initial.rows)
})
test.each([
{ name: "shell", expanded: true, types: ["context", "part"] },
{ name: "execute", expanded: true, types: ["context", "part"] },
{ name: "subagent", expanded: true, types: ["context"] },
{ name: "shell", separator: "text", types: ["context", "part", "part"] },
{ name: "shell", separator: "reasoning", showReasoning: true, types: ["context", "part", "part"] },
{ name: "shell", separator: "reasoning", showReasoning: false, types: ["context"] },
] as const)("respects active tool grouping boundaries: %j", (profile) => {
const content = [
storyTool("earlier", "read", "completed", {}),
...(profile.separator ? [{ type: profile.separator, text: "Visible boundary" }] : []),
storyTool("active", profile.name, "running", {}),
]
const rows = Timeline.constructSessionMessageRows(
storyDocument(content).messages,
profile.showReasoning ?? false,
{ type: "busy" },
undefined,
profile.expanded ?? false,
).rows
expect(rows.flatMap((row) => (row._tag === "AssistantPart" ? [row.group.type] : []))).toEqual([...profile.types])
})
test("keeps active and background work standalone when no group precedes them", () => {
test("keeps active and background work visible outside collapsed stacks", () => {
const source: SessionMessageInfo[] = [
{ id: "msg_user", type: "user", text: "work", time: { created: 1 } },
{
@@ -125,63 +125,34 @@ export const TestFailed = {
),
}
function InteractiveCommandStory(props: {
expanded?: boolean
streaming?: boolean
existingGroup?: boolean
tool?: "shell" | "execute" | "subagent"
}) {
function InteractiveCommandStory(props: { expanded?: boolean; streaming?: boolean }) {
const [state, setState] = createStore({
phase: props.streaming ? "streaming" : "completed",
started: !props.existingGroup,
lines: 3,
sibling: false,
busy: false,
})
const document = createMemo(() => {
const phase = state.phase as "streaming" | "input" | "running" | "completed"
const command = phase === "streaming" ? "" : "printf ready"
const content: SessionMessageAssistant["content"] = [
...(props.existingGroup
? [storyTool("tool_context_lifecycle", "read", "completed", { filePath: "/workspace/README.md" })]
: []),
...(state.started
? [
storyTool(
"tool_shell_lifecycle",
props.tool ?? "shell",
phase === "input" ? "streaming" : phase,
phase === "streaming"
? {}
: props.tool === "execute"
? { code: 'console.log("ready")' }
: props.tool === "subagent"
? { description: "Inspect lifecycle", agent: "explore", prompt: "Inspect lifecycle" }
: { command: "printf ready" },
{
output:
phase === "running"
? "still running"
: Array.from({ length: state.lines }, (_, index) => `line ${index + 1}`).join("\n"),
...(phase === "streaming" ? { raw: "" } : {}),
},
),
]
: []),
storyTool("tool_shell_lifecycle", "shell", phase === "input" ? "streaming" : phase, command ? { command } : {}, {
output:
phase === "running"
? "still running"
: Array.from({ length: state.lines }, (_, index) => `line ${index + 1}`).join("\n"),
...(phase === "streaming" ? { raw: "" } : {}),
}),
...(state.sibling ? [{ type: "text" as const, text: "Sibling content" }] : []),
]
return {
...storyDocument(content, state.started && phase !== "completed"),
status: { type: (state.started && phase !== "completed") || state.busy ? ("busy" as const) : ("idle" as const) },
...storyDocument(content, phase !== "completed"),
status: { type: phase !== "completed" || state.busy ? ("busy" as const) : ("idle" as const) },
}
})
return (
<section class="mx-auto flex w-full max-w-[720px] flex-col gap-4 p-6">
<div class="flex flex-wrap gap-3">
{props.existingGroup && (
<button type="button" onClick={() => setState({ started: true, phase: "streaming" })}>
Start tool
</button>
)}
<div class="flex gap-3">
<button type="button" onClick={() => setState("phase", "input")}>
Complete input
</button>
@@ -211,19 +182,16 @@ function InteractiveCommandStory(props: {
)
}
const RunACommand = {
args: { expanded: false, streaming: false },
render: (args: { expanded: boolean; streaming: boolean }) => <InteractiveCommandStory {...args} />,
}
export const TerminalCommands = {
args: { scenario: "command", expanded: false, streaming: false, existingGroup: false, tool: "shell" },
argTypes: {
scenario: { control: "select", options: ["command", "collapsed"] },
tool: { control: "select", options: ["shell", "execute", "subagent"] },
},
render: (args: {
scenario: string
expanded: boolean
streaming: boolean
existingGroup: boolean
tool: "shell" | "execute" | "subagent"
}) => (args.scenario === "collapsed" ? CollapsedShell.render() : <InteractiveCommandStory {...args} />),
args: { scenario: "command", expanded: false, streaming: false },
argTypes: { scenario: { control: "select", options: ["command", "collapsed"] } },
render: (args: { scenario: string; expanded: boolean; streaming: boolean }) =>
args.scenario === "collapsed" ? CollapsedShell.render() : RunACommand.render(args),
}
export const FixedAndPassed = {
@@ -1,35 +0,0 @@
import { createSignal } from "solid-js"
import { CurrentSessionProviders } from "../storybook/current-session-story"
import { storyDocument, storyTool } from "../storybook/current-session-scenarios"
import { CurrentContextToolGroup } from "./tool-renderer"
export default {
title: "OpenCode/Work/Tool group",
id: "current-tool-group",
component: CurrentContextToolGroup,
}
export const MixedTools = {
render: () => {
const [open, setOpen] = createSignal(true)
const tools = [
storyTool(
"group_shell",
"shell",
"completed",
{ command: "printf 'group geometry'" },
{ output: "group geometry" },
),
storyTool("group_read", "read", "completed", { path: "src/group.ts" }),
storyTool("group_general", "subagent", "completed", { agent: "general", description: "Inspect grouped tools" }),
storyTool("group_explore", "subagent", "completed", { agent: "explore", description: "Check card geometry" }),
]
return (
<section style={{ width: "100%", "max-width": "720px", padding: "24px" }}>
<CurrentSessionProviders document={storyDocument(tools)}>
<CurrentContextToolGroup tools={tools} busy={false} open={open()} onOpenChange={setOpen} />
</CurrentSessionProviders>
</section>
)
},
}
@@ -487,7 +487,8 @@ export function CurrentContextToolGroup(props: {
props.tools.map((tool) => {
const input = currentToolInput(tool)
if (tool.name === "skill") return i18n.t("ui.tool.skill")
if (tool.name === "subagent") return i18n.t("ui.tool.agent.default")
if (tool.name === "subagent" && typeof input.agent === "string" && input.agent)
return input.agent[0]!.toUpperCase() + input.agent.slice(1)
return getToolInfo(tool.name, input, currentToolMetadata(tool)).title
}),
),
+2 -2
View File
@@ -86,12 +86,12 @@ export const settings: Setting[] = [
keywords: ["syntax", "concealment", "rendering"],
},
{
title: "Tool grouping",
title: "Grouping",
category: "Session",
path: ["session", "grouping"],
default: "auto",
values: ["none", "auto"],
keywords: ["transcript", "messages", "reads", "searches"],
keywords: ["transcript", "messages"],
},
{
title: "Transcript images",
-9
View File
@@ -75,15 +75,6 @@ test("shows the TPS default in session settings", () => {
expect(setting?.default).toBe(true)
})
test("names tool grouping explicitly in settings", () => {
expect(settings.find((setting) => setting.path.join(".") === "session.grouping")).toMatchObject({
title: "Tool grouping",
category: "Session",
default: "auto",
values: ["none", "auto"],
})
})
test("validates terminal copy behavior", () => {
expect(decodeInfo({ terminal: { copy: "manual" } })).toEqual({ terminal: { copy: "manual" } })
expect(decodeInfo({ terminal: { copy: "select" } })).toEqual({ terminal: { copy: "select" } })