mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 12:06:22 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6ce094a67 | ||
|
|
8e9c33a0f2 | ||
|
|
2fce8f8473 | ||
|
|
4623f0a729 | ||
|
|
6292a293f2 | ||
|
|
d223f06dee | ||
|
|
1bfe98de78 | ||
|
|
0145d90378 | ||
|
|
32f3afbe74 |
@@ -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)
|
||||
|
||||
@@ -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>({
|
||||
|
||||
@@ -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
@@ -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
|
||||
}),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -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[]>()
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}`))
|
||||
@@ -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".
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
/**
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 }))),
|
||||
})
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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([])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
)
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
}),
|
||||
)
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" } })
|
||||
|
||||
Reference in New Issue
Block a user