Compare commits

...

14 Commits

Author SHA1 Message Date
Kit Langton 4637ea7599 refactor(session): remove async facade exports (#22396) 2026-04-14 12:42:51 -04:00
Kit Langton 2fc5b00537 refactor(session): remove prompt async facade exports 2026-04-13 23:03:06 -04:00
Kit Langton 2728f6413e refactor(project): remove async facade exports 2026-04-13 22:49:22 -04:00
Kit Langton fe3303163c refactor(share): remove session share async facade exports 2026-04-13 22:46:34 -04:00
Kit Langton f954854232 refactor(instance): remove state helper (#22381) 2026-04-13 22:40:12 -04:00
Kit Langton 6a99079012 kit/env instance state (#22383) 2026-04-13 22:28:16 -04:00
Kit Langton 0a8b6298cd refactor(tui): move config cache to InstanceState (#22378) 2026-04-13 22:24:40 -04:00
Kit Langton f40209bdfb refactor(snapshot): remove async facade exports (#22370) 2026-04-13 21:24:54 -04:00
Kit Langton a2cb4909da refactor(plugin): remove async facade exports (#22367) 2026-04-13 21:24:20 -04:00
Kit Langton 7a05ba47d1 refactor(session): remove compaction async facade exports (#22366) 2026-04-13 21:23:34 -04:00
Kit Langton 36745caa2a refactor(worktree): remove async facade exports (#22369) 2026-04-13 21:23:15 -04:00
Nazar H. c2403d0f15 fix(provider): guard reasoningSummary injection for @ai-sdk/openai-compatible providers (#22352)
Co-authored-by: Nazar Hnatyshen <nazar.hnatyshen@atolls.com>
2026-04-13 20:20:06 -05:00
Aiden Cline 34e2429c49 feat: add experimental.compaction.autocontinue hook to disable auto continuing after compaction (#22361) 2026-04-13 20:14:53 -05:00
opencode-agent[bot] 10ba68c772 chore: update nix node_modules hashes 2026-04-14 00:23:25 +00:00
59 changed files with 3457 additions and 3065 deletions
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-g29OM3dy+sZ3ioTs8zjQOK1N+KnNr9ptP9xtdPcdr64=",
"aarch64-linux": "sha256-Iu91KwDcV5omkf4Ngny1aYpyCkPLjuoWOVUDOJUhW1k=",
"aarch64-darwin": "sha256-bk3G6m+Yo60Ea3Kyglc37QZf5Vm7MLMFcxemjc7HnL0=",
"x86_64-darwin": "sha256-y3hooQw13Z3Cu0KFfXYdpkTEeKTyuKd+a/jsXHQLdqA="
"x86_64-linux": "sha256-fiMi8VxyMhNTaZf0ButrMEwT/ZmfeEg1T3c6HwUz8p4=",
"aarch64-linux": "sha256-1Mzjijq/INZGGEm4EerYN3hu1VxiQ8wuGg6t+XPDf6w=",
"aarch64-darwin": "sha256-3SH8Q2kK/F2kM29FmFUMR1aA23rSei+mPJliRIGfvCM=",
"x86_64-darwin": "sha256-RPsyoNXn84K93gunRFLsBvkZIQilfmUXdwkeieQjbd8="
}
}
+32 -25
View File
@@ -33,31 +33,38 @@ const seed = async () => {
}),
)
const session = await Session.create({ title })
const messageID = MessageID.ascending()
const partID = PartID.ascending()
const message = {
id: messageID,
sessionID: session.id,
role: "user" as const,
time: { created: now },
agent: "build",
model: {
providerID: ProviderID.make(providerID),
modelID: ModelID.make(modelID),
},
}
const part = {
id: partID,
sessionID: session.id,
messageID,
type: "text" as const,
text,
time: { start: now },
}
await Session.updateMessage(message)
await Session.updatePart(part)
await Project.update({ projectID: Instance.project.id, name: "E2E Project" })
await AppRuntime.runPromise(
Effect.gen(function* () {
const session = yield* Session.Service
const result = yield* session.create({ title })
const messageID = MessageID.ascending()
const partID = PartID.ascending()
const message = {
id: messageID,
sessionID: result.id,
role: "user" as const,
time: { created: now },
agent: "build",
model: {
providerID: ProviderID.make(providerID),
modelID: ModelID.make(modelID),
},
}
const part = {
id: partID,
sessionID: result.id,
messageID,
type: "text" as const,
text,
time: { start: now },
}
yield* session.updateMessage(message)
yield* session.updatePart(part)
}),
)
await AppRuntime.runPromise(
Project.Service.use((svc) => svc.update({ projectID: Instance.project.id, name: "E2E Project" })),
)
},
})
} finally {
@@ -0,0 +1,36 @@
# Effect loose ends
Small follow-ups that do not fit neatly into the main facade, route, tool, or schema migration checklists.
## Config / TUI
- [ ] `config/tui.ts` - finish the internal Effect migration after the `Instance.state(...)` removal.
Keep the current precedence and migration semantics intact while converting the remaining internal async helpers (`loadState`, `mergeFile`, `loadFile`, `load`) to `Effect.gen(...)` / `Effect.fn(...)`.
- [ ] `config/tui.ts` callers - once the internal service is stable, migrate plain async callers to use `TuiConfig.Service` directly where that actually simplifies the code.
Likely first callers: `cli/cmd/tui/attach.ts`, `cli/cmd/tui/thread.ts`, `cli/cmd/tui/plugin/runtime.ts`.
- [ ] `env/index.ts` - move the last production `Instance.state(...)` usage onto `InstanceState` (or its replacement) so `Instance.state` can be deleted.
## ConfigPaths
- [ ] `config/paths.ts` - split pure helpers from effectful helpers.
Keep `fileInDirectory(...)` as a plain function.
- [ ] `config/paths.ts` - add a `ConfigPaths.Service` for the effectful operations so callers do not inherit `AppFileSystem.Service` directly.
Initial service surface should cover:
- `projectFiles(...)`
- `directories(...)`
- `readFile(...)`
- `parseText(...)`
- [ ] `config/config.ts` - switch internal config loading from `Effect.promise(() => ConfigPaths.*(...))` to `yield* paths.*(...)` once the service exists.
- [ ] `config/tui.ts` - switch TUI config loading from async `ConfigPaths.*` wrappers to the `ConfigPaths.Service` once that service exists.
- [ ] `config/tui-migrate.ts` - decide whether to leave this as a plain async module using wrapper functions or effectify it fully after `ConfigPaths.Service` lands.
## Instance cleanup
- [ ] `project/instance.ts` - remove `Instance.state(...)` once `env/index.ts` is migrated.
- [ ] `project/state.ts` - delete the bespoke per-instance state helper after the last production caller is gone.
- [ ] `test/project/state.test.ts` - replace or delete the old `Instance.state(...)` tests after the removal.
## Notes
- Prefer small, semantics-preserving config migrations. Config precedence, legacy key migration, and plugin origin tracking are easy to break accidentally.
- When changing config loading internals, rerun the config and TUI suites first before broad package sweeps.
+3 -3
View File
@@ -73,6 +73,7 @@ export namespace Agent {
Effect.gen(function* () {
const config = yield* Config.Service
const auth = yield* Auth.Service
const plugin = yield* Plugin.Service
const skill = yield* Skill.Service
const provider = yield* Provider.Service
@@ -335,9 +336,7 @@ export namespace Agent {
const language = yield* provider.getLanguage(resolved)
const system = [PROMPT_GENERATE]
yield* Effect.promise(() =>
Plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system }),
)
yield* plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system })
const existing = yield* InstanceState.useEffect(state, (s) => s.list())
// TODO: clean this up so provider specific logic doesnt bleed over
@@ -398,6 +397,7 @@ export namespace Agent {
)
export const defaultLayer = layer.pipe(
Layer.provide(Plugin.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
+43 -39
View File
@@ -123,45 +123,49 @@ function parseToolParams(input?: string) {
}
async function createToolContext(agent: Agent.Info) {
const session = await Session.create({ title: `Debug tool run (${agent.name})` })
const messageID = MessageID.ascending()
const model =
agent.model ??
(await AppRuntime.runPromise(
Effect.gen(function* () {
const provider = yield* Provider.Service
return yield* provider.defaultModel()
}),
))
const now = Date.now()
const message: MessageV2.Assistant = {
id: messageID,
sessionID: session.id,
role: "assistant",
time: {
created: now,
},
parentID: messageID,
modelID: model.modelID,
providerID: model.providerID,
mode: "debug",
agent: agent.name,
path: {
cwd: Instance.directory,
root: Instance.worktree,
},
cost: 0,
tokens: {
input: 0,
output: 0,
reasoning: 0,
cache: {
read: 0,
write: 0,
},
},
}
await Session.updateMessage(message)
const { session, messageID } = await AppRuntime.runPromise(
Effect.gen(function* () {
const session = yield* Session.Service
const result = yield* session.create({ title: `Debug tool run (${agent.name})` })
const messageID = MessageID.ascending()
const model = agent.model
? agent.model
: yield* Effect.gen(function* () {
const provider = yield* Provider.Service
return yield* provider.defaultModel()
})
const now = Date.now()
const message: MessageV2.Assistant = {
id: messageID,
sessionID: result.id,
role: "assistant",
time: {
created: now,
},
parentID: messageID,
modelID: model.modelID,
providerID: model.providerID,
mode: "debug",
agent: agent.name,
path: {
cwd: Instance.directory,
root: Instance.worktree,
},
cost: 0,
tokens: {
input: 0,
output: 0,
reasoning: 0,
cache: {
read: 0,
write: 0,
},
},
}
yield* session.updateMessage(message)
return { session: result, messageID }
}),
)
const ruleset = Permission.merge(agent.permission, session.permission ?? [])
@@ -1,3 +1,4 @@
import { AppRuntime } from "@/effect/app-runtime"
import { Snapshot } from "../../../snapshot"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
@@ -14,7 +15,7 @@ const TrackCommand = cmd({
describe: "track current snapshot state",
async handler() {
await bootstrap(process.cwd(), async () => {
console.log(await Snapshot.track())
console.log(await AppRuntime.runPromise(Snapshot.Service.use((svc) => svc.track())))
})
},
})
@@ -30,7 +31,7 @@ const PatchCommand = cmd({
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
console.log(await Snapshot.patch(args.hash))
console.log(await AppRuntime.runPromise(Snapshot.Service.use((svc) => svc.patch(args.hash))))
})
},
})
@@ -46,7 +47,7 @@ const DiffCommand = cmd({
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
console.log(await Snapshot.diff(args.hash))
console.log(await AppRuntime.runPromise(Snapshot.Service.use((svc) => svc.diff(args.hash))))
})
},
})
+12 -2
View File
@@ -6,6 +6,8 @@ import { bootstrap } from "../bootstrap"
import { UI } from "../ui"
import * as prompts from "@clack/prompts"
import { EOL } from "os"
import { AppRuntime } from "@/effect/app-runtime"
import { Effect } from "effect"
export const ExportCommand = cmd({
command: "export [sessionID]",
@@ -67,8 +69,16 @@ export const ExportCommand = cmd({
}
try {
const sessionInfo = await Session.get(sessionID!)
const messages = await Session.messages({ sessionID: sessionInfo.id })
const { sessionInfo, messages } = await AppRuntime.runPromise(
Effect.gen(function* () {
const session = yield* Session.Service
const sessionInfo = yield* session.get(sessionID!)
return {
sessionInfo,
messages: yield* session.messages({ sessionID: sessionInfo.id }),
}
}),
)
const exportData = {
info: sessionInfo,
+89 -94
View File
@@ -33,6 +33,7 @@ import { AppRuntime } from "@/effect/app-runtime"
import { Git } from "@/git"
import { setTimeout as sleep } from "node:timers/promises"
import { Process } from "@/util/process"
import { Effect } from "effect"
type GitHubAuthor = {
login: string
@@ -551,20 +552,24 @@ export const GithubRunCommand = cmd({
// Setup opencode session
const repoData = await fetchRepo()
session = await Session.create({
permission: [
{
permission: "question",
action: "deny",
pattern: "*",
},
],
})
session = await AppRuntime.runPromise(
Session.Service.use((svc) =>
svc.create({
permission: [
{
permission: "question",
action: "deny",
pattern: "*",
},
],
}),
),
)
subscribeSessionEvents()
shareId = await (async () => {
if (share === false) return
if (!share && repoData.data.private) return
await SessionShare.share(session.id)
await AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.share(session.id)))
return session.id.slice(-8)
})()
console.log("opencode session", session.id)
@@ -937,96 +942,86 @@ export const GithubRunCommand = cmd({
async function chat(message: string, files: PromptFiles = []) {
console.log("Sending message to opencode...")
const result = await SessionPrompt.prompt({
sessionID: session.id,
messageID: MessageID.ascending(),
variant,
model: {
providerID,
modelID,
},
// agent is omitted - server will use default_agent from config or fall back to "build"
parts: [
{
id: PartID.ascending(),
type: "text",
text: message,
},
...files.flatMap((f) => [
{
id: PartID.ascending(),
type: "file" as const,
mime: f.mime,
url: `data:${f.mime};base64,${f.content}`,
filename: f.filename,
source: {
type: "file" as const,
text: {
value: f.replacement,
start: f.start,
end: f.end,
},
path: f.filename,
},
return AppRuntime.runPromise(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const result = yield* prompt.prompt({
sessionID: session.id,
messageID: MessageID.ascending(),
variant,
model: {
providerID,
modelID,
},
]),
],
})
// agent is omitted - server will use default_agent from config or fall back to "build"
parts: [
{
id: PartID.ascending(),
type: "text",
text: message,
},
...files.flatMap((f) => [
{
id: PartID.ascending(),
type: "file" as const,
mime: f.mime,
url: `data:${f.mime};base64,${f.content}`,
filename: f.filename,
source: {
type: "file" as const,
text: {
value: f.replacement,
start: f.start,
end: f.end,
},
path: f.filename,
},
},
]),
],
})
// result should always be assistant just satisfying type checker
if (result.info.role === "assistant" && result.info.error) {
const err = result.info.error
console.error("Agent error:", err)
if (result.info.role === "assistant" && result.info.error) {
const err = result.info.error
console.error("Agent error:", err)
if (err.name === "ContextOverflowError") throw new Error(formatPromptTooLargeError(files))
throw new Error(`${err.name}: ${err.data?.message || ""}`)
}
if (err.name === "ContextOverflowError") {
throw new Error(formatPromptTooLargeError(files))
}
const text = extractResponseText(result.parts)
if (text) return text
const errorMsg = err.data?.message || ""
throw new Error(`${err.name}: ${errorMsg}`)
}
console.log("Requesting summary from agent...")
const summary = yield* prompt.prompt({
sessionID: session.id,
messageID: MessageID.ascending(),
variant,
model: {
providerID,
modelID,
},
tools: { "*": false },
parts: [
{
id: PartID.ascending(),
type: "text",
text: "Summarize the actions (tool calls & reasoning) you did for the user in 1-2 sentences.",
},
],
})
const text = extractResponseText(result.parts)
if (text) return text
if (summary.info.role === "assistant" && summary.info.error) {
const err = summary.info.error
console.error("Summary agent error:", err)
if (err.name === "ContextOverflowError") throw new Error(formatPromptTooLargeError(files))
throw new Error(`${err.name}: ${err.data?.message || ""}`)
}
// No text part (tool-only or reasoning-only) - ask agent to summarize
console.log("Requesting summary from agent...")
const summary = await SessionPrompt.prompt({
sessionID: session.id,
messageID: MessageID.ascending(),
variant,
model: {
providerID,
modelID,
},
tools: { "*": false }, // Disable all tools to force text response
parts: [
{
id: PartID.ascending(),
type: "text",
text: "Summarize the actions (tool calls & reasoning) you did for the user in 1-2 sentences.",
},
],
})
if (summary.info.role === "assistant" && summary.info.error) {
const err = summary.info.error
console.error("Summary agent error:", err)
if (err.name === "ContextOverflowError") {
throw new Error(formatPromptTooLargeError(files))
}
const errorMsg = err.data?.message || ""
throw new Error(`${err.name}: ${errorMsg}`)
}
const summaryText = extractResponseText(summary.parts)
if (!summaryText) {
throw new Error("Failed to get summary from agent")
}
return summaryText
const summaryText = extractResponseText(summary.parts)
if (!summaryText) throw new Error("Failed to get summary from agent")
return summaryText
}),
)
}
async function getOidcToken() {
+9 -3
View File
@@ -340,6 +340,12 @@ export const ProvidersLoginCommand = cmd({
}
return filtered
})
const hooks = await AppRuntime.runPromise(
Effect.gen(function* () {
const plugin = yield* Plugin.Service
return yield* plugin.list()
}),
)
const priority: Record<string, number> = {
opencode: 0,
@@ -351,7 +357,7 @@ export const ProvidersLoginCommand = cmd({
vercel: 6,
}
const pluginProviders = resolvePluginProviders({
hooks: await Plugin.list(),
hooks,
existingProviders: providers,
disabled,
enabled,
@@ -408,7 +414,7 @@ export const ProvidersLoginCommand = cmd({
provider = selected as string
}
const plugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
const plugin = hooks.findLast((x) => x.auth?.provider === provider)
if (plugin && plugin.auth) {
const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method)
if (handled) return
@@ -422,7 +428,7 @@ export const ProvidersLoginCommand = cmd({
if (prompts.isCancel(custom)) throw new UI.CancelledError()
provider = custom.replace(/^@ai-sdk\//, "")
const customPlugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
const customPlugin = hooks.findLast((x) => x.auth?.provider === provider)
if (customPlugin && customPlugin.auth) {
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method)
if (handled) return
+3 -2
View File
@@ -11,6 +11,7 @@ import { Process } from "../../util/process"
import { EOL } from "os"
import path from "path"
import { which } from "../../util/which"
import { AppRuntime } from "@/effect/app-runtime"
function pagerCmd(): string[] {
const lessOptions = ["-R", "-S"]
@@ -60,12 +61,12 @@ export const SessionDeleteCommand = cmd({
await bootstrap(process.cwd(), async () => {
const sessionID = SessionID.make(args.sessionID)
try {
await Session.get(sessionID)
await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID)))
} catch {
UI.error(`Session not found: ${args.sessionID}`)
process.exit(1)
}
await Session.remove(sessionID)
await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(sessionID)))
UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL)
})
},
+4 -1
View File
@@ -6,6 +6,7 @@ import { Database } from "../../storage/db"
import { SessionTable } from "../../session/session.sql"
import { Project } from "../../project/project"
import { Instance } from "../../project/instance"
import { AppRuntime } from "@/effect/app-runtime"
interface SessionStats {
totalSessions: number
@@ -167,7 +168,9 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
const batch = filteredSessions.slice(i, i + BATCH_SIZE)
const batchPromises = batch.map(async (session) => {
const messages = await Session.messages({ sessionID: session.id })
const messages = await AppRuntime.runPromise(
Session.Service.use((svc) => svc.messages({ sessionID: session.id })),
)
let sessionCost = 0
let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
+456 -458
View File
@@ -1161,502 +1161,500 @@ export namespace Config {
}),
)
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Auth.Service | Account.Service> =
Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const authSvc = yield* Auth.Service
const accountSvc = yield* Account.Service
export const layer: Layer.Layer<
Service,
never,
AppFileSystem.Service | Auth.Service | Account.Service | Env.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const authSvc = yield* Auth.Service
const accountSvc = yield* Account.Service
const env = yield* Env.Service
const readConfigFile = Effect.fnUntraced(function* (filepath: string) {
return yield* fs.readFileString(filepath).pipe(
Effect.catchIf(
(e) => e.reason._tag === "NotFound",
() => Effect.succeed(undefined),
),
Effect.orDie,
)
})
const loadConfig = Effect.fnUntraced(function* (
text: string,
options: { path: string } | { dir: string; source: string },
) {
const original = text
const source = "path" in options ? options.path : options.source
const isFile = "path" in options
const data = yield* Effect.promise(() =>
ConfigPaths.parseText(
text,
"path" in options ? options.path : { source: options.source, dir: options.dir },
),
)
const normalized = (() => {
if (!data || typeof data !== "object" || Array.isArray(data)) return data
const copy = { ...(data as Record<string, unknown>) }
const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
if (!hadLegacy) return copy
delete copy.theme
delete copy.keybinds
delete copy.tui
log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
return copy
})()
const parsed = Info.safeParse(normalized)
if (parsed.success) {
if (!parsed.data.$schema && isFile) {
parsed.data.$schema = "https://opencode.ai/config.json"
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void))
}
const data = parsed.data
if (data.plugin && isFile) {
const list = data.plugin
for (let i = 0; i < list.length; i++) {
list[i] = yield* Effect.promise(() => resolvePluginSpec(list[i], options.path))
}
}
return data
}
throw new InvalidError({
path: source,
issues: parsed.error.issues,
})
})
const loadFile = Effect.fnUntraced(function* (filepath: string) {
log.info("loading", { path: filepath })
const text = yield* readConfigFile(filepath)
if (!text) return {} as Info
return yield* loadConfig(text, { path: filepath })
})
const loadGlobal = Effect.fnUntraced(function* () {
let result: Info = pipe(
{},
mergeDeep(yield* loadFile(path.join(Global.Path.config, "config.json"))),
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.json"))),
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))),
)
const legacy = path.join(Global.Path.config, "config")
if (existsSync(legacy)) {
yield* Effect.promise(() =>
import(pathToFileURL(legacy).href, { with: { type: "toml" } })
.then(async (mod) => {
const { provider, model, ...rest } = mod.default
if (provider && model) result.model = `${provider}/${model}`
result["$schema"] = "https://opencode.ai/config.json"
result = mergeDeep(result, rest)
await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
await fsNode.unlink(legacy)
})
.catch(() => {}),
)
}
return result
})
const [cachedGlobal, invalidateGlobal] = yield* Effect.cachedInvalidateWithTTL(
loadGlobal().pipe(
Effect.tapError((error) =>
Effect.sync(() => log.error("failed to load global config, using defaults", { error: String(error) })),
),
Effect.orElseSucceed((): Info => ({})),
const readConfigFile = Effect.fnUntraced(function* (filepath: string) {
return yield* fs.readFileString(filepath).pipe(
Effect.catchIf(
(e) => e.reason._tag === "NotFound",
() => Effect.succeed(undefined),
),
Duration.infinity,
Effect.orDie,
)
})
const loadConfig = Effect.fnUntraced(function* (
text: string,
options: { path: string } | { dir: string; source: string },
) {
const original = text
const source = "path" in options ? options.path : options.source
const isFile = "path" in options
const data = yield* Effect.promise(() =>
ConfigPaths.parseText(text, "path" in options ? options.path : { source: options.source, dir: options.dir }),
)
const getGlobal = Effect.fn("Config.getGlobal")(function* () {
return yield* cachedGlobal
const normalized = (() => {
if (!data || typeof data !== "object" || Array.isArray(data)) return data
const copy = { ...(data as Record<string, unknown>) }
const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
if (!hadLegacy) return copy
delete copy.theme
delete copy.keybinds
delete copy.tui
log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
return copy
})()
const parsed = Info.safeParse(normalized)
if (parsed.success) {
if (!parsed.data.$schema && isFile) {
parsed.data.$schema = "https://opencode.ai/config.json"
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void))
}
const data = parsed.data
if (data.plugin && isFile) {
const list = data.plugin
for (let i = 0; i < list.length; i++) {
list[i] = yield* Effect.promise(() => resolvePluginSpec(list[i], options.path))
}
}
return data
}
throw new InvalidError({
path: source,
issues: parsed.error.issues,
})
})
const loadFile = Effect.fnUntraced(function* (filepath: string) {
log.info("loading", { path: filepath })
const text = yield* readConfigFile(filepath)
if (!text) return {} as Info
return yield* loadConfig(text, { path: filepath })
})
const loadGlobal = Effect.fnUntraced(function* () {
let result: Info = pipe(
{},
mergeDeep(yield* loadFile(path.join(Global.Path.config, "config.json"))),
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.json"))),
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))),
)
const legacy = path.join(Global.Path.config, "config")
if (existsSync(legacy)) {
yield* Effect.promise(() =>
import(pathToFileURL(legacy).href, { with: { type: "toml" } })
.then(async (mod) => {
const { provider, model, ...rest } = mod.default
if (provider && model) result.model = `${provider}/${model}`
result["$schema"] = "https://opencode.ai/config.json"
result = mergeDeep(result, rest)
await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
await fsNode.unlink(legacy)
})
.catch(() => {}),
)
}
return result
})
const [cachedGlobal, invalidateGlobal] = yield* Effect.cachedInvalidateWithTTL(
loadGlobal().pipe(
Effect.tapError((error) =>
Effect.sync(() => log.error("failed to load global config, using defaults", { error: String(error) })),
),
Effect.orElseSucceed((): Info => ({})),
),
Duration.infinity,
)
const getGlobal = Effect.fn("Config.getGlobal")(function* () {
return yield* cachedGlobal
})
const install = Effect.fnUntraced(function* (dir: string) {
const pkg = path.join(dir, "package.json")
const gitignore = path.join(dir, ".gitignore")
const plugin = path.join(dir, "node_modules", "@opencode-ai", "plugin", "package.json")
const target = Installation.isLocal() ? "*" : Installation.VERSION
const json = yield* fs.readJson(pkg).pipe(
Effect.catch(() => Effect.succeed({} satisfies Package)),
Effect.map((x): Package => (isRecord(x) ? (x as Package) : {})),
)
const hasDep = json.dependencies?.["@opencode-ai/plugin"] === target
const hasIgnore = yield* fs.existsSafe(gitignore)
const hasPkg = yield* fs.existsSafe(plugin)
if (!hasDep) {
yield* fs.writeJson(pkg, {
...json,
dependencies: {
...json.dependencies,
"@opencode-ai/plugin": target,
},
})
}
if (!hasIgnore) {
yield* fs.writeFileString(
gitignore,
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
)
}
if (hasDep && hasIgnore && hasPkg) return
yield* Effect.promise(() => Npm.install(dir))
})
const installDependencies = Effect.fn("Config.installDependencies")(function* (
dir: string,
input?: InstallInput,
) {
if (
!(yield* fs.access(dir, { writable: true }).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
))
)
return
const key =
process.platform === "win32" ? "config-install:win32" : `config-install:${AppFileSystem.resolve(dir)}`
yield* Effect.acquireUseRelease(
Effect.promise((signal) =>
Flock.acquire(key, {
signal,
onWait: (tick) =>
input?.waitTick?.({
dir,
attempt: tick.attempt,
delay: tick.delay,
waited: tick.waited,
}),
}),
),
() => install(dir),
(lease) => Effect.promise(() => lease.release()),
)
})
const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) {
const auth = yield* authSvc.all().pipe(Effect.orDie)
let result: Info = {}
const consoleManagedProviders = new Set<string>()
let activeOrgName: string | undefined
const scope = Effect.fnUntraced(function* (source: string) {
if (source.startsWith("http://") || source.startsWith("https://")) return "global"
if (source === "OPENCODE_CONFIG_CONTENT") return "local"
if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local"
return "global"
})
const install = Effect.fnUntraced(function* (dir: string) {
const pkg = path.join(dir, "package.json")
const gitignore = path.join(dir, ".gitignore")
const plugin = path.join(dir, "node_modules", "@opencode-ai", "plugin", "package.json")
const target = Installation.isLocal() ? "*" : Installation.VERSION
const json = yield* fs.readJson(pkg).pipe(
Effect.catch(() => Effect.succeed({} satisfies Package)),
Effect.map((x): Package => (isRecord(x) ? (x as Package) : {})),
)
const hasDep = json.dependencies?.["@opencode-ai/plugin"] === target
const hasIgnore = yield* fs.existsSafe(gitignore)
const hasPkg = yield* fs.existsSafe(plugin)
const track = Effect.fnUntraced(function* (source: string, list: PluginSpec[] | undefined, kind?: PluginScope) {
if (!list?.length) return
const hit = kind ?? (yield* scope(source))
const plugins = deduplicatePluginOrigins([
...(result.plugin_origins ?? []),
...list.map((spec) => ({ spec, source, scope: hit })),
])
result.plugin = plugins.map((item) => item.spec)
result.plugin_origins = plugins
})
if (!hasDep) {
yield* fs.writeJson(pkg, {
...json,
dependencies: {
...json.dependencies,
"@opencode-ai/plugin": target,
},
const merge = (source: string, next: Info, kind?: PluginScope) => {
result = mergeConfigConcatArrays(result, next)
return track(source, next.plugin, kind)
}
for (const [key, value] of Object.entries(auth)) {
if (value.type === "wellknown") {
const url = key.replace(/\/+$/, "")
process.env[value.key] = value.token
log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))
if (!response.ok) {
throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
}
const wellknown = (yield* Effect.promise(() => response.json())) as any
const remoteConfig = wellknown.config ?? {}
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
const source = `${url}/.well-known/opencode`
const next = yield* loadConfig(JSON.stringify(remoteConfig), {
dir: path.dirname(source),
source,
})
yield* merge(source, next, "global")
log.debug("loaded remote config from well-known", { url })
}
}
const global = yield* getGlobal()
yield* merge(Global.Path.config, global, "global")
if (Flag.OPENCODE_CONFIG) {
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
}
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of yield* Effect.promise(() =>
ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
)) {
yield* merge(file, yield* loadFile(file), "local")
}
}
result.agent = result.agent || {}
result.mode = result.mode || {}
result.plugin = result.plugin || []
const directories = yield* Effect.promise(() => ConfigPaths.directories(ctx.directory, ctx.worktree))
if (Flag.OPENCODE_CONFIG_DIR) {
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
}
const deps: Fiber.Fiber<void, never>[] = []
for (const dir of unique(directories)) {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(dir, file)
log.debug(`loading config from ${source}`)
yield* merge(source, yield* loadFile(source))
result.agent ??= {}
result.mode ??= {}
result.plugin ??= []
}
}
if (!hasIgnore) {
yield* fs.writeFileString(
gitignore,
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
)
}
if (hasDep && hasIgnore && hasPkg) return
yield* Effect.promise(() => Npm.install(dir))
})
const installDependencies = Effect.fn("Config.installDependencies")(function* (
dir: string,
input?: InstallInput,
) {
if (
!(yield* fs.access(dir, { writable: true }).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
))
)
return
const key =
process.platform === "win32" ? "config-install:win32" : `config-install:${AppFileSystem.resolve(dir)}`
yield* Effect.acquireUseRelease(
Effect.promise((signal) =>
Flock.acquire(key, {
signal,
onWait: (tick) =>
input?.waitTick?.({
dir,
attempt: tick.attempt,
delay: tick.delay,
waited: tick.waited,
}),
}),
const dep = yield* installDependencies(dir).pipe(
Effect.exit,
Effect.tap((exit) =>
Exit.isFailure(exit)
? Effect.sync(() => {
log.warn("background dependency install failed", { dir, error: String(exit.cause) })
})
: Effect.void,
),
() => install(dir),
(lease) => Effect.promise(() => lease.release()),
Effect.asVoid,
Effect.forkScoped,
)
})
deps.push(dep)
const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) {
const auth = yield* authSvc.all().pipe(Effect.orDie)
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir)))
const list = yield* Effect.promise(() => loadPlugin(dir))
yield* track(dir, list)
}
let result: Info = {}
const consoleManagedProviders = new Set<string>()
let activeOrgName: string | undefined
const scope = Effect.fnUntraced(function* (source: string) {
if (source.startsWith("http://") || source.startsWith("https://")) return "global"
if (source === "OPENCODE_CONFIG_CONTENT") return "local"
if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local"
return "global"
if (process.env.OPENCODE_CONFIG_CONTENT) {
const source = "OPENCODE_CONFIG_CONTENT"
const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
dir: ctx.directory,
source,
})
yield* merge(source, next, "local")
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
const track = Effect.fnUntraced(function* (
source: string,
list: PluginSpec[] | undefined,
kind?: PluginScope,
) {
if (!list?.length) return
const hit = kind ?? (yield* scope(source))
const plugins = deduplicatePluginOrigins([
...(result.plugin_origins ?? []),
...list.map((spec) => ({ spec, source, scope: hit })),
])
result.plugin = plugins.map((item) => item.spec)
result.plugin_origins = plugins
})
const activeOrg = Option.getOrUndefined(
yield* accountSvc.activeOrg().pipe(Effect.catch(() => Effect.succeed(Option.none()))),
)
if (activeOrg) {
yield* Effect.gen(function* () {
const [configOpt, tokenOpt] = yield* Effect.all(
[accountSvc.config(activeOrg.account.id, activeOrg.org.id), accountSvc.token(activeOrg.account.id)],
{ concurrency: 2 },
)
if (Option.isSome(tokenOpt)) {
process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value
yield* env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value)
}
const merge = (source: string, next: Info, kind?: PluginScope) => {
result = mergeConfigConcatArrays(result, next)
return track(source, next.plugin, kind)
}
activeOrgName = activeOrg.org.name
for (const [key, value] of Object.entries(auth)) {
if (value.type === "wellknown") {
const url = key.replace(/\/+$/, "")
process.env[value.key] = value.token
log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))
if (!response.ok) {
throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
}
const wellknown = (yield* Effect.promise(() => response.json())) as any
const remoteConfig = wellknown.config ?? {}
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
const source = `${url}/.well-known/opencode`
const next = yield* loadConfig(JSON.stringify(remoteConfig), {
if (Option.isSome(configOpt)) {
const source = `${activeOrg.account.url}/api/config`
const next = yield* loadConfig(JSON.stringify(configOpt.value), {
dir: path.dirname(source),
source,
})
for (const providerID of Object.keys(next.provider ?? {})) {
consoleManagedProviders.add(providerID)
}
yield* merge(source, next, "global")
log.debug("loaded remote config from well-known", { url })
}
}
const global = yield* getGlobal()
yield* merge(Global.Path.config, global, "global")
if (Flag.OPENCODE_CONFIG) {
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
}
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of yield* Effect.promise(() =>
ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
)) {
yield* merge(file, yield* loadFile(file), "local")
}
}
result.agent = result.agent || {}
result.mode = result.mode || {}
result.plugin = result.plugin || []
const directories = yield* Effect.promise(() => ConfigPaths.directories(ctx.directory, ctx.worktree))
if (Flag.OPENCODE_CONFIG_DIR) {
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
}
const deps: Fiber.Fiber<void, never>[] = []
for (const dir of unique(directories)) {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(dir, file)
log.debug(`loading config from ${source}`)
yield* merge(source, yield* loadFile(source))
result.agent ??= {}
result.mode ??= {}
result.plugin ??= []
}
}
const dep = yield* installDependencies(dir).pipe(
Effect.exit,
Effect.tap((exit) =>
Exit.isFailure(exit)
? Effect.sync(() => {
log.warn("background dependency install failed", { dir, error: String(exit.cause) })
})
: Effect.void,
),
Effect.asVoid,
Effect.forkScoped,
)
deps.push(dep)
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir)))
const list = yield* Effect.promise(() => loadPlugin(dir))
yield* track(dir, list)
}
if (process.env.OPENCODE_CONFIG_CONTENT) {
const source = "OPENCODE_CONFIG_CONTENT"
const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
dir: ctx.directory,
source,
})
yield* merge(source, next, "local")
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
const activeOrg = Option.getOrUndefined(
yield* accountSvc.activeOrg().pipe(Effect.catch(() => Effect.succeed(Option.none()))),
}).pipe(
Effect.catch((err) => {
log.debug("failed to fetch remote account config", {
error: err instanceof Error ? err.message : String(err),
})
return Effect.void
}),
)
if (activeOrg) {
yield* Effect.gen(function* () {
const [configOpt, tokenOpt] = yield* Effect.all(
[accountSvc.config(activeOrg.account.id, activeOrg.org.id), accountSvc.token(activeOrg.account.id)],
{ concurrency: 2 },
)
if (Option.isSome(tokenOpt)) {
process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value
Env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value)
}
}
activeOrgName = activeOrg.org.name
if (Option.isSome(configOpt)) {
const source = `${activeOrg.account.url}/api/config`
const next = yield* loadConfig(JSON.stringify(configOpt.value), {
dir: path.dirname(source),
source,
})
for (const providerID of Object.keys(next.provider ?? {})) {
consoleManagedProviders.add(providerID)
}
yield* merge(source, next, "global")
}
}).pipe(
Effect.catch((err) => {
log.debug("failed to fetch remote account config", {
error: err instanceof Error ? err.message : String(err),
})
return Effect.void
}),
)
if (existsSync(managedDir)) {
for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(managedDir, file)
yield* merge(source, yield* loadFile(source), "global")
}
}
if (existsSync(managedDir)) {
for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(managedDir, file)
yield* merge(source, yield* loadFile(source), "global")
}
}
// macOS managed preferences (.mobileconfig deployed via MDM) override everything
result = mergeConfigConcatArrays(result, yield* Effect.promise(() => readManagedPreferences()))
// macOS managed preferences (.mobileconfig deployed via MDM) override everything
result = mergeConfigConcatArrays(result, yield* Effect.promise(() => readManagedPreferences()))
for (const [name, mode] of Object.entries(result.mode ?? {})) {
result.agent = mergeDeep(result.agent ?? {}, {
[name]: {
...mode,
mode: "primary" as const,
},
})
}
if (Flag.OPENCODE_PERMISSION) {
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
}
if (result.tools) {
const perms: Record<string, Config.PermissionAction> = {}
for (const [tool, enabled] of Object.entries(result.tools)) {
const action: Config.PermissionAction = enabled ? "allow" : "deny"
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
perms.edit = action
continue
}
perms[tool] = action
}
result.permission = mergeDeep(perms, result.permission ?? {})
}
if (!result.username) result.username = os.userInfo().username
if (result.autoshare === true && !result.share) {
result.share = "auto"
}
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
result.compaction = { ...result.compaction, auto: false }
}
if (Flag.OPENCODE_DISABLE_PRUNE) {
result.compaction = { ...result.compaction, prune: false }
}
return {
config: result,
directories,
deps,
consoleState: {
consoleManagedProviders: Array.from(consoleManagedProviders),
activeOrgName,
switchableOrgCount: 0,
for (const [name, mode] of Object.entries(result.mode ?? {})) {
result.agent = mergeDeep(result.agent ?? {}, {
[name]: {
...mode,
mode: "primary" as const,
},
})
}
if (Flag.OPENCODE_PERMISSION) {
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
}
if (result.tools) {
const perms: Record<string, Config.PermissionAction> = {}
for (const [tool, enabled] of Object.entries(result.tools)) {
const action: Config.PermissionAction = enabled ? "allow" : "deny"
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
perms.edit = action
continue
}
perms[tool] = action
}
})
result.permission = mergeDeep(perms, result.permission ?? {})
}
const state = yield* InstanceState.make<State>(
Effect.fn("Config.state")(function* (ctx) {
return yield* loadInstanceState(ctx)
}),
)
if (!result.username) result.username = os.userInfo().username
const get = Effect.fn("Config.get")(function* () {
return yield* InstanceState.use(state, (s) => s.config)
})
if (result.autoshare === true && !result.share) {
result.share = "auto"
}
const directories = Effect.fn("Config.directories")(function* () {
return yield* InstanceState.use(state, (s) => s.directories)
})
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
result.compaction = { ...result.compaction, auto: false }
}
if (Flag.OPENCODE_DISABLE_PRUNE) {
result.compaction = { ...result.compaction, prune: false }
}
const getConsoleState = Effect.fn("Config.getConsoleState")(function* () {
return yield* InstanceState.use(state, (s) => s.consoleState)
})
const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
yield* InstanceState.useEffect(state, (s) =>
Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
)
})
const update = Effect.fn("Config.update")(function* (config: Info) {
const dir = yield* InstanceState.directory
const file = path.join(dir, "config.json")
const existing = yield* loadFile(file)
yield* fs
.writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2))
.pipe(Effect.orDie)
yield* Effect.promise(() => Instance.dispose())
})
const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) {
yield* invalidateGlobal
const task = Instance.disposeAll()
.catch(() => undefined)
.finally(() =>
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Event.Disposed.type,
properties: {},
},
}),
)
if (wait) yield* Effect.promise(() => task)
else void task
})
const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
const file = globalConfigFile()
const before = (yield* readConfigFile(file)) ?? "{}"
const input = writable(config)
let next: Info
if (!file.endsWith(".jsonc")) {
const existing = parseConfig(before, file)
const merged = mergeDeep(writable(existing), input)
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
next = merged
} else {
const updated = patchJsonc(before, input)
next = parseConfig(updated, file)
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
}
yield* invalidate()
return next
})
return Service.of({
get,
getGlobal,
getConsoleState,
installDependencies,
update,
updateGlobal,
invalidate,
return {
config: result,
directories,
waitForDependencies,
})
}),
)
deps,
consoleState: {
consoleManagedProviders: Array.from(consoleManagedProviders),
activeOrgName,
switchableOrgCount: 0,
},
}
})
const state = yield* InstanceState.make<State>(
Effect.fn("Config.state")(function* (ctx) {
return yield* loadInstanceState(ctx)
}),
)
const get = Effect.fn("Config.get")(function* () {
return yield* InstanceState.use(state, (s) => s.config)
})
const directories = Effect.fn("Config.directories")(function* () {
return yield* InstanceState.use(state, (s) => s.directories)
})
const getConsoleState = Effect.fn("Config.getConsoleState")(function* () {
return yield* InstanceState.use(state, (s) => s.consoleState)
})
const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
yield* InstanceState.useEffect(state, (s) =>
Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
)
})
const update = Effect.fn("Config.update")(function* (config: Info) {
const dir = yield* InstanceState.directory
const file = path.join(dir, "config.json")
const existing = yield* loadFile(file)
yield* fs
.writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2))
.pipe(Effect.orDie)
yield* Effect.promise(() => Instance.dispose())
})
const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) {
yield* invalidateGlobal
const task = Instance.disposeAll()
.catch(() => undefined)
.finally(() =>
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Event.Disposed.type,
properties: {},
},
}),
)
if (wait) yield* Effect.promise(() => task)
else void task
})
const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
const file = globalConfigFile()
const before = (yield* readConfigFile(file)) ?? "{}"
const input = writable(config)
let next: Info
if (!file.endsWith(".jsonc")) {
const existing = parseConfig(before, file)
const merged = mergeDeep(writable(existing), input)
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
next = merged
} else {
const updated = patchJsonc(before, input)
next = parseConfig(updated, file)
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
}
yield* invalidate()
return next
})
return Service.of({
get,
getGlobal,
getConsoleState,
installDependencies,
update,
updateGlobal,
invalidate,
directories,
waitForDependencies,
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Account.defaultLayer),
)
+68 -33
View File
@@ -1,16 +1,19 @@
import { existsSync } from "fs"
import z from "zod"
import { mergeDeep, unique } from "remeda"
import { Context, Effect, Fiber, Layer } from "effect"
import { Config } from "./config"
import { ConfigPaths } from "./paths"
import { migrateTuiConfig } from "./tui-migrate"
import { TuiInfo } from "./tui-schema"
import { Instance } from "@/project/instance"
import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { isRecord } from "@/util/record"
import { Global } from "@/global"
import { AppRuntime } from "@/effect/app-runtime"
import { Filesystem } from "@/util/filesystem"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
export namespace TuiConfig {
const log = Log.create({ service: "tui.config" })
@@ -21,13 +24,26 @@ export namespace TuiConfig {
result: Info
}
type State = {
config: Info
deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
}
export type Info = z.output<typeof Info> & {
// Internal resolved plugin list used by runtime loading.
plugin_origins?: Config.PluginOrigin[]
}
function pluginScope(file: string): Config.PluginScope {
if (Instance.containsPath(file)) return "local"
export interface Interface {
readonly get: () => Effect.Effect<Info>
readonly waitForDependencies: () => Effect.Effect<void, AppFileSystem.Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
function pluginScope(file: string, ctx: { directory: string; worktree: string }): Config.PluginScope {
if (Filesystem.contains(ctx.directory, file)) return "local"
if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local"
return "global"
}
@@ -51,16 +67,12 @@ export namespace TuiConfig {
}
}
function installDeps(dir: string): Promise<void> {
return AppRuntime.runPromise(Config.Service.use((cfg) => cfg.installDependencies(dir)))
}
async function mergeFile(acc: Acc, file: string) {
async function mergeFile(acc: Acc, file: string, ctx: { directory: string; worktree: string }) {
const data = await loadFile(file)
acc.result = mergeDeep(acc.result, data)
if (!data.plugin?.length) return
const scope = pluginScope(file)
const scope = pluginScope(file, ctx)
const plugins = Config.deduplicatePluginOrigins([
...(acc.result.plugin_origins ?? []),
...data.plugin.map((spec) => ({ spec, scope, source: file })),
@@ -69,46 +81,48 @@ export namespace TuiConfig {
acc.result.plugin_origins = plugins
}
const state = Instance.state(async () => {
async function loadState(ctx: { directory: string; worktree: string }) {
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? []
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
const directories = await ConfigPaths.directories(Instance.directory, Instance.worktree)
: await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree)
const directories = await ConfigPaths.directories(ctx.directory, ctx.worktree)
const custom = customPath()
const managed = Config.managedConfigDir()
await migrateTuiConfig({ directories, custom, managed })
// Re-compute after migration since migrateTuiConfig may have created new tui.json files
projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? []
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
: await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree)
const acc: Acc = {
result: {},
}
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
await mergeFile(acc, file)
await mergeFile(acc, file, ctx)
}
if (custom) {
await mergeFile(acc, custom)
await mergeFile(acc, custom, ctx)
log.debug("loaded custom tui config", { path: custom })
}
for (const file of projectFiles) {
await mergeFile(acc, file)
await mergeFile(acc, file, ctx)
}
for (const dir of unique(directories)) {
const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
for (const dir of dirs) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
await mergeFile(acc, file)
await mergeFile(acc, file, ctx)
}
}
if (existsSync(managed)) {
for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
await mergeFile(acc, file)
await mergeFile(acc, file, ctx)
}
}
@@ -122,27 +136,48 @@ export namespace TuiConfig {
}
acc.result.keybinds = Config.Keybinds.parse(keybinds)
const deps: Promise<void>[] = []
if (acc.result.plugin?.length) {
for (const dir of unique(directories)) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
deps.push(installDeps(dir))
}
}
return {
config: acc.result,
deps,
dirs: acc.result.plugin?.length ? dirs : [],
}
})
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const cfg = yield* Config.Service
const state = yield* InstanceState.make<State>(
Effect.fn("TuiConfig.state")(function* (ctx) {
const data = yield* Effect.promise(() => loadState(ctx))
const deps = yield* Effect.forEach(data.dirs, (dir) => cfg.installDependencies(dir).pipe(Effect.forkScoped), {
concurrency: "unbounded",
})
return { config: data.config, deps }
}),
)
const get = Effect.fn("TuiConfig.get")(() => InstanceState.use(state, (s) => s.config))
const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
InstanceState.useEffect(state, (s) =>
Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
),
)
return Service.of({ get, waitForDependencies })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function get() {
return state().then((x) => x.config)
return runPromise((svc) => svc.get())
}
export async function waitForDependencies() {
const deps = await state().then((x) => x.deps)
await Promise.all(deps)
await runPromise((svc) => svc.waitForDependencies())
}
async function loadFile(filepath: string): Promise<Info> {
@@ -1,4 +1,5 @@
import z from "zod"
import { AppRuntime } from "@/effect/app-runtime"
import { Worktree } from "@/worktree"
import { type WorkspaceAdaptor, WorkspaceInfo } from "../types"
@@ -12,7 +13,7 @@ export const WorktreeAdaptor: WorkspaceAdaptor = {
name: "Worktree",
description: "Create a git worktree",
async configure(info) {
const worktree = await Worktree.makeWorktreeInfo(undefined)
const worktree = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo()))
return {
...info,
name: worktree.name,
@@ -22,15 +23,19 @@ export const WorktreeAdaptor: WorkspaceAdaptor = {
},
async create(info) {
const config = WorktreeConfig.parse(info)
await Worktree.createFromInfo({
name: config.name,
directory: config.directory,
branch: config.branch,
})
await AppRuntime.runPromise(
Worktree.Service.use((svc) =>
svc.createFromInfo({
name: config.name,
directory: config.directory,
branch: config.branch,
}),
),
)
},
async remove(info) {
const config = WorktreeConfig.parse(info)
await Worktree.remove({ directory: config.directory })
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove({ directory: config.directory })))
},
target(info) {
const config = WorktreeConfig.parse(info)
+41 -13
View File
@@ -1,28 +1,56 @@
import { Instance } from "../project/instance"
import { Context, Effect, Layer } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
export namespace Env {
const state = Instance.state(() => {
// Create a shallow copy to isolate environment per instance
// Prevents parallel tests from interfering with each other's env vars
return { ...process.env } as Record<string, string | undefined>
})
type State = Record<string, string | undefined>
export interface Interface {
readonly get: (key: string) => Effect.Effect<string | undefined>
readonly all: () => Effect.Effect<State>
readonly set: (key: string, value: string) => Effect.Effect<void>
readonly remove: (key: string) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Env") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = yield* InstanceState.make<State>(Effect.fn("Env.state")(() => Effect.succeed({ ...process.env })))
const get = Effect.fn("Env.get")((key: string) => InstanceState.use(state, (env) => env[key]))
const all = Effect.fn("Env.all")(() => InstanceState.get(state))
const set = Effect.fn("Env.set")(function* (key: string, value: string) {
const env = yield* InstanceState.get(state)
env[key] = value
})
const remove = Effect.fn("Env.remove")(function* (key: string) {
const env = yield* InstanceState.get(state)
delete env[key]
})
return Service.of({ get, all, set, remove })
}),
)
export const defaultLayer = layer
const rt = makeRuntime(Service, defaultLayer)
export function get(key: string) {
const env = state()
return env[key]
return rt.runSync((svc) => svc.get(key))
}
export function all() {
return state()
return rt.runSync((svc) => svc.all())
}
export function set(key: string, value: string) {
const env = state()
env[key] = value
return rt.runSync((svc) => svc.set(key, value))
}
export function remove(key: string) {
const env = state()
delete env[key]
return rt.runSync((svc) => svc.remove(key))
}
}
-18
View File
@@ -20,7 +20,6 @@ import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cl
import { Effect, Layer, Context, Stream } from "effect"
import { EffectLogger } from "@/effect/logger"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { errorMessage } from "@/util/error"
import { PluginLoader } from "./loader"
import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
@@ -290,21 +289,4 @@ export namespace Plugin {
)
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function trigger<
Name extends TriggerName,
Input = Parameters<Required<Hooks>[Name]>[0],
Output = Parameters<Required<Hooks>[Name]>[1],
>(name: Name, input: Input, output: Output): Promise<Output> {
return runPromise((svc) => svc.trigger(name, input, output))
}
export async function list(): Promise<Hooks[]> {
return runPromise((svc) => svc.list())
}
export async function init() {
return runPromise((svc) => svc.init())
}
}
+6 -7
View File
@@ -3,10 +3,10 @@ import { disposeInstance } from "@/effect/instance-registry"
import { Filesystem } from "@/util/filesystem"
import { iife } from "@/util/iife"
import { Log } from "@/util/log"
import { Effect } from "effect"
import { LocalContext } from "../util/local-context"
import { Project } from "./project"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { State } from "./state"
export interface InstanceContext {
directory: string
@@ -30,7 +30,9 @@ function boot(input: { directory: string; init?: () => Promise<any>; worktree?:
worktree: input.worktree,
project: input.project,
}
: await Project.fromDirectory(input.directory).then(({ project, sandbox }) => ({
: await Effect.runPromise(
Project.Service.use((svc) => svc.fromDirectory(input.directory)).pipe(Effect.provide(Project.defaultLayer)),
).then(({ project, sandbox }) => ({
directory: input.directory,
worktree: sandbox,
project,
@@ -113,13 +115,10 @@ export const Instance = {
restore<R>(ctx: InstanceContext, fn: () => R): R {
return context.provide(ctx, fn)
},
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
return State.create(() => Instance.directory, init, dispose)
},
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
const directory = Filesystem.resolve(input.directory)
Log.Default.info("reloading instance", { directory })
await Promise.all([State.dispose(directory), disposeInstance(directory)])
await disposeInstance(directory)
cache.delete(directory)
const next = track(directory, boot({ ...input, directory }))
@@ -141,7 +140,7 @@ export const Instance = {
const directory = Instance.directory
const project = Instance.project
Log.Default.info("disposing instance", { directory })
await Promise.all([State.dispose(directory), disposeInstance(directory)])
await disposeInstance(directory)
cache.delete(directory)
GlobalBus.emit("event", {
+1 -35
View File
@@ -10,8 +10,7 @@ import { which } from "../util/which"
import { ProjectID } from "./schema"
import { Effect, Layer, Path, Scope, Context, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { makeRuntime } from "@/effect/run-service"
import { NodePath } from "@effect/platform-node"
import { AppFileSystem } from "@/filesystem"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
@@ -463,19 +462,6 @@ export namespace Project {
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodePath.layer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
// ---------------------------------------------------------------------------
// Promise-based API (delegates to Effect service via runPromise)
// ---------------------------------------------------------------------------
export function fromDirectory(directory: string) {
return runPromise((svc) => svc.fromDirectory(directory))
}
export function discover(input: Info) {
return runPromise((svc) => svc.discover(input))
}
export function list() {
return Database.use((db) =>
@@ -498,24 +484,4 @@ export namespace Project {
db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
)
}
export function initGit(input: { directory: string; project: Info }) {
return runPromise((svc) => svc.initGit(input))
}
export function update(input: UpdateInput) {
return runPromise((svc) => svc.update(input))
}
export function sandboxes(id: ProjectID) {
return runPromise((svc) => svc.sandboxes(id))
}
export function addSandbox(id: ProjectID, directory: string) {
return runPromise((svc) => svc.addSandbox(id, directory))
}
export function removeSandbox(id: ProjectID, directory: string) {
return runPromise((svc) => svc.removeSandbox(id, directory))
}
}
-70
View File
@@ -1,70 +0,0 @@
import { Log } from "@/util/log"
export namespace State {
interface Entry {
state: any
dispose?: (state: any) => Promise<void>
}
const log = Log.create({ service: "state" })
const recordsByKey = new Map<string, Map<any, Entry>>()
export function create<S>(root: () => string, init: () => S, dispose?: (state: Awaited<S>) => Promise<void>) {
return () => {
const key = root()
let entries = recordsByKey.get(key)
if (!entries) {
entries = new Map<string, Entry>()
recordsByKey.set(key, entries)
}
const exists = entries.get(init)
if (exists) return exists.state as S
const state = init()
entries.set(init, {
state,
dispose,
})
return state
}
}
export async function dispose(key: string) {
const entries = recordsByKey.get(key)
if (!entries) return
log.info("waiting for state disposal to complete", { key })
let disposalFinished = false
setTimeout(() => {
if (!disposalFinished) {
log.warn(
"state disposal is taking an unusually long time - if it does not complete in a reasonable time, please report this as a bug",
{ key },
)
}
}, 10000).unref()
const tasks: Promise<void>[] = []
for (const [init, entry] of entries) {
if (!entry.dispose) continue
const label = typeof init === "function" ? init.name : String(init)
const task = Promise.resolve(entry.state)
.then((state) => entry.dispose!(state))
.catch((error) => {
log.error("Error while disposing state:", { error, key, init: label })
})
tasks.push(task)
}
await Promise.all(tasks)
entries.clear()
recordsByKey.delete(key)
disposalFinished = true
log.info("state disposal completed", { key })
}
}
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -832,7 +832,16 @@ export namespace ProviderTransform {
if (input.model.api.id.includes("gpt-5") && !input.model.api.id.includes("gpt-5-chat")) {
if (!input.model.api.id.includes("gpt-5-pro")) {
result["reasoningEffort"] = "medium"
result["reasoningSummary"] = "auto"
// Only inject reasoningSummary for providers that support it natively.
// @ai-sdk/openai-compatible proxies (e.g. LiteLLM) do not understand this
// parameter and return "Unknown parameter: 'reasoningSummary'".
if (
input.model.api.npm === "@ai-sdk/openai" ||
input.model.api.npm === "@ai-sdk/azure" ||
input.model.api.npm === "@ai-sdk/github-copilot"
) {
result["reasoningSummary"] = "auto"
}
}
// Only set textVerbosity for non-chat gpt-5.x models
@@ -254,7 +254,7 @@ export const ExperimentalRoutes = lazy(() =>
validator("json", Worktree.CreateInput.optional()),
async (c) => {
const body = c.req.valid("json")
const worktree = await Worktree.create(body)
const worktree = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.create(body)))
return c.json(worktree)
},
)
@@ -276,7 +276,7 @@ export const ExperimentalRoutes = lazy(() =>
},
}),
async (c) => {
const sandboxes = await Project.sandboxes(Instance.project.id)
const sandboxes = await AppRuntime.runPromise(Project.Service.use((svc) => svc.sandboxes(Instance.project.id)))
return c.json(sandboxes)
},
)
@@ -301,8 +301,10 @@ export const ExperimentalRoutes = lazy(() =>
validator("json", Worktree.RemoveInput),
async (c) => {
const body = c.req.valid("json")
await Worktree.remove(body)
await Project.removeSandbox(Instance.project.id, body.directory)
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove(body)))
await AppRuntime.runPromise(
Project.Service.use((svc) => svc.removeSandbox(Instance.project.id, body.directory)),
)
return c.json(true)
},
)
@@ -327,7 +329,7 @@ export const ExperimentalRoutes = lazy(() =>
validator("json", Worktree.ResetInput),
async (c) => {
const body = c.req.valid("json")
await Worktree.reset(body)
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.reset(body)))
return c.json(true)
},
)
@@ -41,7 +41,7 @@ async function getSessionWorkspace(url: URL) {
const id = getSessionID(url)
if (!id) return null
const session = await Session.get(id).catch(() => undefined)
const session = await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(id))).catch(() => undefined)
return session?.workspaceID
}
@@ -75,10 +75,9 @@ export const ProjectRoutes = lazy(() =>
async (c) => {
const dir = Instance.directory
const prev = Instance.project
const next = await Project.initGit({
directory: dir,
project: prev,
})
const next = await AppRuntime.runPromise(
Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })),
)
if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next)
await Instance.reload({
directory: dir,
@@ -112,7 +111,7 @@ export const ProjectRoutes = lazy(() =>
async (c) => {
const projectID = c.req.valid("param").projectID
const body = c.req.valid("json")
const project = await Project.update({ ...body, projectID })
const project = await AppRuntime.runPromise(Project.Service.use((svc) => svc.update({ ...body, projectID })))
return c.json(project)
},
),
@@ -121,12 +121,12 @@ export const SessionRoutes = lazy(() =>
validator(
"param",
z.object({
sessionID: Session.get.schema,
sessionID: Session.GetInput,
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const session = await Session.get(sessionID)
const session = await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID)))
return c.json(session)
},
)
@@ -152,12 +152,12 @@ export const SessionRoutes = lazy(() =>
validator(
"param",
z.object({
sessionID: Session.children.schema,
sessionID: Session.ChildrenInput,
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const session = await Session.children(sessionID)
const session = await AppRuntime.runPromise(Session.Service.use((svc) => svc.children(sessionID)))
return c.json(session)
},
)
@@ -209,10 +209,10 @@ export const SessionRoutes = lazy(() =>
},
},
}),
validator("json", Session.create.schema),
validator("json", Session.CreateInput),
async (c) => {
const body = c.req.valid("json") ?? {}
const session = await SessionShare.create(body)
const session = await AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.create(body)))
return c.json(session)
},
)
@@ -237,12 +237,12 @@ export const SessionRoutes = lazy(() =>
validator(
"param",
z.object({
sessionID: Session.remove.schema,
sessionID: Session.RemoveInput,
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
await Session.remove(sessionID)
await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(sessionID)))
return c.json(true)
},
)
@@ -285,22 +285,27 @@ export const SessionRoutes = lazy(() =>
async (c) => {
const sessionID = c.req.valid("param").sessionID
const updates = c.req.valid("json")
const current = await Session.get(sessionID)
const session = await AppRuntime.runPromise(
Effect.gen(function* () {
const session = yield* Session.Service
const current = yield* session.get(sessionID)
if (updates.title !== undefined) {
await Session.setTitle({ sessionID, title: updates.title })
}
if (updates.permission !== undefined) {
await Session.setPermission({
sessionID,
permission: Permission.merge(current.permission ?? [], updates.permission),
})
}
if (updates.time?.archived !== undefined) {
await Session.setArchived({ sessionID, time: updates.time.archived })
}
if (updates.title !== undefined) {
yield* session.setTitle({ sessionID, title: updates.title })
}
if (updates.permission !== undefined) {
yield* session.setPermission({
sessionID,
permission: Permission.merge(current.permission ?? [], updates.permission),
})
}
if (updates.time?.archived !== undefined) {
yield* session.setArchived({ sessionID, time: updates.time.archived })
}
const session = await Session.get(sessionID)
return yield* session.get(sessionID)
}),
)
return c.json(session)
},
)
@@ -341,13 +346,17 @@ export const SessionRoutes = lazy(() =>
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
await SessionPrompt.command({
sessionID,
messageID: body.messageID,
model: body.providerID + "/" + body.modelID,
command: Command.Default.INIT,
arguments: "",
})
await AppRuntime.runPromise(
SessionPrompt.Service.use((svc) =>
svc.command({
sessionID,
messageID: body.messageID,
model: body.providerID + "/" + body.modelID,
command: Command.Default.INIT,
arguments: "",
}),
),
)
return c.json(true)
},
)
@@ -371,14 +380,14 @@ export const SessionRoutes = lazy(() =>
validator(
"param",
z.object({
sessionID: Session.fork.schema.shape.sessionID,
sessionID: Session.ForkInput.shape.sessionID,
}),
),
validator("json", Session.fork.schema.omit({ sessionID: true })),
validator("json", Session.ForkInput.omit({ sessionID: true })),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const result = await Session.fork({ ...body, sessionID })
const result = await AppRuntime.runPromise(Session.Service.use((svc) => svc.fork({ ...body, sessionID })))
return c.json(result)
},
)
@@ -407,7 +416,7 @@ export const SessionRoutes = lazy(() =>
}),
),
async (c) => {
await SessionPrompt.cancel(c.req.valid("param").sessionID)
await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.cancel(c.req.valid("param").sessionID)))
return c.json(true)
},
)
@@ -437,8 +446,14 @@ export const SessionRoutes = lazy(() =>
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
await SessionShare.share(sessionID)
const session = await Session.get(sessionID)
const session = await AppRuntime.runPromise(
Effect.gen(function* () {
const share = yield* SessionShare.Service
const session = yield* Session.Service
yield* share.share(sessionID)
return yield* session.get(sessionID)
}),
)
return c.json(session)
},
)
@@ -511,8 +526,14 @@ export const SessionRoutes = lazy(() =>
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
await SessionShare.unshare(sessionID)
const session = await Session.get(sessionID)
const session = await AppRuntime.runPromise(
Effect.gen(function* () {
const share = yield* SessionShare.Service
const session = yield* Session.Service
yield* share.unshare(sessionID)
return yield* session.get(sessionID)
}),
)
return c.json(session)
},
)
@@ -645,15 +666,14 @@ export const SessionRoutes = lazy(() =>
async (c) => {
const query = c.req.valid("query")
const sessionID = c.req.valid("param").sessionID
if (query.limit === undefined) {
await Session.get(sessionID)
const messages = await Session.messages({ sessionID })
return c.json(messages)
}
if (query.limit === 0) {
await Session.get(sessionID)
const messages = await Session.messages({ sessionID })
if (query.limit === undefined || query.limit === 0) {
const messages = await AppRuntime.runPromise(
Effect.gen(function* () {
const session = yield* Session.Service
yield* session.get(sessionID)
return yield* session.messages({ sessionID })
}),
)
return c.json(messages)
}
@@ -781,11 +801,15 @@ export const SessionRoutes = lazy(() =>
),
async (c) => {
const params = c.req.valid("param")
await Session.removePart({
sessionID: params.sessionID,
messageID: params.messageID,
partID: params.partID,
})
await AppRuntime.runPromise(
Session.Service.use((svc) =>
svc.removePart({
sessionID: params.sessionID,
messageID: params.messageID,
partID: params.partID,
}),
),
)
return c.json(true)
},
)
@@ -823,7 +847,7 @@ export const SessionRoutes = lazy(() =>
`Part mismatch: body.id='${body.id}' vs partID='${params.partID}', body.messageID='${body.messageID}' vs messageID='${params.messageID}', body.sessionID='${body.sessionID}' vs sessionID='${params.sessionID}'`,
)
}
const part = await Session.updatePart(body)
const part = await AppRuntime.runPromise(Session.Service.use((svc) => svc.updatePart(body)))
return c.json(part)
},
)
@@ -863,7 +887,9 @@ export const SessionRoutes = lazy(() =>
return stream(c, async (stream) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const msg = await SessionPrompt.prompt({ ...body, sessionID })
const msg = await AppRuntime.runPromise(
SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })),
)
stream.write(JSON.stringify(msg))
})
},
@@ -892,7 +918,7 @@ export const SessionRoutes = lazy(() =>
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
SessionPrompt.prompt({ ...body, sessionID }).catch((err) => {
AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID }))).catch((err) => {
log.error("prompt_async failed", { sessionID, error: err })
Bus.publish(Session.Event.Error, {
sessionID,
@@ -936,7 +962,7 @@ export const SessionRoutes = lazy(() =>
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const msg = await SessionPrompt.command({ ...body, sessionID })
const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.command({ ...body, sessionID })))
return c.json(msg)
},
)
@@ -968,7 +994,7 @@ export const SessionRoutes = lazy(() =>
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const msg = await SessionPrompt.shell({ ...body, sessionID })
const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.shell({ ...body, sessionID })))
return c.json(msg)
},
)
+2 -1
View File
@@ -4,6 +4,7 @@ import z from "zod"
import { Bus } from "../../bus"
import { Session } from "../../session"
import { TuiEvent } from "@/cli/cmd/tui/event"
import { AppRuntime } from "@/effect/app-runtime"
import { AsyncQueue } from "../../util/queue"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
@@ -370,7 +371,7 @@ export const TuiRoutes = lazy(() =>
validator("json", TuiEvent.SessionSelect.properties),
async (c) => {
const { sessionID } = c.req.valid("json")
await Session.get(sessionID)
await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID)))
await Bus.publish(TuiEvent.SessionSelect, { sessionID })
return c.json(true)
},
+45 -48
View File
@@ -9,14 +9,12 @@ import z from "zod"
import { Token } from "../util/token"
import { Log } from "../util/log"
import { SessionProcessor } from "./processor"
import { fn } from "@/util/fn"
import { Agent } from "@/agent/agent"
import { Plugin } from "@/plugin"
import { Config } from "@/config/config"
import { NotFoundError } from "@/storage/db"
import { ModelID, ProviderID } from "@/provider/schema"
import { Effect, Layer, Context } from "effect"
import { makeRuntime } from "@/effect/run-service"
import { InstanceState } from "@/effect/instance-state"
import { isOverflow as overflow } from "./overflow"
@@ -310,31 +308,51 @@ When constructing the summary, try to stick to this template:
}
if (!replay) {
const continueMsg = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: input.sessionID,
time: { created: Date.now() },
agent: userMessage.agent,
model: userMessage.model,
})
const text =
(input.overflow
? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n"
: "") +
"Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed."
yield* session.updatePart({
id: PartID.ascending(),
messageID: continueMsg.id,
sessionID: input.sessionID,
type: "text",
synthetic: true,
text,
time: {
start: Date.now(),
end: Date.now(),
},
})
const info = yield* provider.getProvider(userMessage.model.providerID)
if (
(yield* plugin.trigger(
"experimental.compaction.autocontinue",
{
sessionID: input.sessionID,
agent: userMessage.agent,
model: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID),
provider: {
source: info.source,
info,
options: info.options,
},
message: userMessage,
overflow: input.overflow === true,
},
{ enabled: true },
)).enabled
) {
const continueMsg = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: input.sessionID,
time: { created: Date.now() },
agent: userMessage.agent,
model: userMessage.model,
})
const text =
(input.overflow
? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n"
: "") +
"Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed."
yield* session.updatePart({
id: PartID.ascending(),
messageID: continueMsg.id,
sessionID: input.sessionID,
type: "text",
synthetic: true,
text,
time: {
start: Date.now(),
end: Date.now(),
},
})
}
}
}
@@ -388,25 +406,4 @@ When constructing the summary, try to stick to this template:
Layer.provide(Config.defaultLayer),
),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) {
return runPromise((svc) => svc.isOverflow(input))
}
export async function prune(input: { sessionID: SessionID }) {
return runPromise((svc) => svc.prune(input))
}
export const create = fn(
z.object({
sessionID: SessionID.zod,
agent: z.string(),
model: z.object({ providerID: ProviderID.zod, modelID: ModelID.zod }),
auto: z.boolean(),
overflow: z.boolean().optional(),
}),
(input) => runPromise((svc) => svc.create(input)),
)
}
+24 -65
View File
@@ -19,7 +19,6 @@ import { updateSchema } from "../util/update-schema"
import { MessageV2 } from "./message-v2"
import { Instance } from "../project/instance"
import { InstanceState } from "@/effect/instance-state"
import { fn } from "@/util/fn"
import { Snapshot } from "@/snapshot"
import { ProjectID } from "../project/schema"
import { WorkspaceID } from "../control-plane/schema"
@@ -29,7 +28,6 @@ import type { Provider } from "@/provider/provider"
import { Permission } from "@/permission"
import { Global } from "@/global"
import { Effect, Layer, Option, Context } from "effect"
import { makeRuntime } from "@/effect/run-service"
export namespace Session {
const log = Log.create({ service: "session" })
@@ -179,6 +177,30 @@ export namespace Session {
})
export type GlobalInfo = z.output<typeof GlobalInfo>
export const CreateInput = z
.object({
parentID: SessionID.zod.optional(),
title: z.string().optional(),
permission: Info.shape.permission,
workspaceID: WorkspaceID.zod.optional(),
})
.optional()
export type CreateInput = z.output<typeof CreateInput>
export const ForkInput = z.object({ sessionID: SessionID.zod, messageID: MessageID.zod.optional() })
export const GetInput = SessionID.zod
export const ChildrenInput = SessionID.zod
export const RemoveInput = SessionID.zod
export const SetTitleInput = z.object({ sessionID: SessionID.zod, title: z.string() })
export const SetArchivedInput = z.object({ sessionID: SessionID.zod, time: z.number().optional() })
export const SetPermissionInput = z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset })
export const SetRevertInput = z.object({
sessionID: SessionID.zod,
revert: Info.shape.revert,
summary: Info.shape.summary,
})
export const MessagesInput = z.object({ sessionID: SessionID.zod, limit: z.number().optional() })
export const Event = {
Created: SyncEvent.define({
type: "session.created",
@@ -682,48 +704,6 @@ export namespace Session {
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Storage.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export const create = fn(
z
.object({
parentID: SessionID.zod.optional(),
title: z.string().optional(),
permission: Info.shape.permission,
workspaceID: WorkspaceID.zod.optional(),
})
.optional(),
(input) => runPromise((svc) => svc.create(input)),
)
export const fork = fn(z.object({ sessionID: SessionID.zod, messageID: MessageID.zod.optional() }), (input) =>
runPromise((svc) => svc.fork(input)),
)
export const get = fn(SessionID.zod, (id) => runPromise((svc) => svc.get(id)))
export const setTitle = fn(z.object({ sessionID: SessionID.zod, title: z.string() }), (input) =>
runPromise((svc) => svc.setTitle(input)),
)
export const setArchived = fn(z.object({ sessionID: SessionID.zod, time: z.number().optional() }), (input) =>
runPromise((svc) => svc.setArchived(input)),
)
export const setPermission = fn(z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset }), (input) =>
runPromise((svc) => svc.setPermission(input)),
)
export const setRevert = fn(
z.object({ sessionID: SessionID.zod, revert: Info.shape.revert, summary: Info.shape.summary }),
(input) =>
runPromise((svc) => svc.setRevert({ sessionID: input.sessionID, revert: input.revert, summary: input.summary })),
)
export const messages = fn(z.object({ sessionID: SessionID.zod, limit: z.number().optional() }), (input) =>
runPromise((svc) => svc.messages(input)),
)
export function* list(input?: {
directory?: string
workspaceID?: WorkspaceID
@@ -835,25 +815,4 @@ export namespace Session {
yield { ...fromRow(row), project }
}
}
export const children = fn(SessionID.zod, (id) => runPromise((svc) => svc.children(id)))
export const remove = fn(SessionID.zod, (id) => runPromise((svc) => svc.remove(id)))
export async function updateMessage<T extends MessageV2.Info>(msg: T): Promise<T> {
MessageV2.Info.parse(msg)
return runPromise((svc) => svc.updateMessage(msg))
}
export const removeMessage = fn(z.object({ sessionID: SessionID.zod, messageID: MessageID.zod }), (input) =>
runPromise((svc) => svc.removeMessage(input)),
)
export const removePart = fn(
z.object({ sessionID: SessionID.zod, messageID: MessageID.zod, partID: PartID.zod }),
(input) => runPromise((svc) => svc.removePart(input)),
)
export async function updatePart<T extends MessageV2.Part>(part: T): Promise<T> {
MessageV2.Part.parse(part)
return runPromise((svc) => svc.updatePart(part))
}
}
-27
View File
@@ -46,7 +46,6 @@ import { Process } from "@/util/process"
import { Cause, Effect, Exit, Layer, Option, Scope, Context } from "effect"
import { EffectLogger } from "@/effect/logger"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { TaskTool, type TaskPromptOps } from "@/tool/task"
import { SessionRunState } from "./run-state"
@@ -1708,8 +1707,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
),
),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export const PromptInput = z.object({
sessionID: SessionID.zod,
messageID: MessageID.zod.optional(),
@@ -1777,26 +1774,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the
})
export type PromptInput = z.infer<typeof PromptInput>
export async function prompt(input: PromptInput) {
return runPromise((svc) => svc.prompt(PromptInput.parse(input)))
}
export async function resolvePromptParts(template: string) {
return runPromise((svc) => svc.resolvePromptParts(z.string().parse(template)))
}
export async function cancel(sessionID: SessionID) {
return runPromise((svc) => svc.cancel(SessionID.zod.parse(sessionID)))
}
export const LoopInput = z.object({
sessionID: SessionID.zod,
})
export async function loop(input: z.infer<typeof LoopInput>) {
return runPromise((svc) => svc.loop(LoopInput.parse(input)))
}
export const ShellInput = z.object({
sessionID: SessionID.zod,
messageID: MessageID.zod.optional(),
@@ -1811,10 +1792,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
})
export type ShellInput = z.infer<typeof ShellInput>
export async function shell(input: ShellInput) {
return runPromise((svc) => svc.shell(ShellInput.parse(input)))
}
export const CommandInput = z.object({
messageID: MessageID.zod.optional(),
sessionID: SessionID.zod,
@@ -1838,10 +1815,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
})
export type CommandInput = z.infer<typeof CommandInput>
export async function command(input: CommandInput) {
return runPromise((svc) => svc.command(CommandInput.parse(input)))
}
/** @internal Exported for testing */
export function createStructuredOutputTool(input: {
schema: Record<string, any>
+2 -10
View File
@@ -1,8 +1,6 @@
import { makeRuntime } from "@/effect/run-service"
import { Session } from "@/session"
import { SessionID } from "@/session/schema"
import { SyncEvent } from "@/sync"
import { fn } from "@/util/fn"
import { Effect, Layer, Scope, Context } from "effect"
import { Config } from "../config/config"
import { Flag } from "../flag/flag"
@@ -10,7 +8,7 @@ import { ShareNext } from "./share-next"
export namespace SessionShare {
export interface Interface {
readonly create: (input?: Parameters<typeof Session.create>[0]) => Effect.Effect<Session.Info>
readonly create: (input?: Session.CreateInput) => Effect.Effect<Session.Info>
readonly share: (sessionID: SessionID) => Effect.Effect<{ url: string }, unknown>
readonly unshare: (sessionID: SessionID) => Effect.Effect<void, unknown>
}
@@ -40,7 +38,7 @@ export namespace SessionShare {
yield* Effect.sync(() => SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: null } } }))
})
const create = Effect.fn("SessionShare.create")(function* (input?: Parameters<typeof Session.create>[0]) {
const create = Effect.fn("SessionShare.create")(function* (input?: Session.CreateInput) {
const result = yield* session.create(input)
if (result.parentID) return result
const conf = yield* cfg.get()
@@ -58,10 +56,4 @@ export namespace SessionShare {
Layer.provide(Session.defaultLayer),
Layer.provide(Config.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export const create = fn(Session.create.schema, (input) => runPromise((svc) => svc.create(input)))
export const share = fn(SessionID.zod, (sessionID) => runPromise((svc) => svc.share(sessionID)))
export const unshare = fn(SessionID.zod, (sessionID) => runPromise((svc) => svc.unshare(sessionID)))
}
-31
View File
@@ -5,7 +5,6 @@ import path from "path"
import z from "zod"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
import { Hash } from "@/util/hash"
import { Config } from "../config/config"
@@ -770,34 +769,4 @@ export namespace Snapshot {
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Config.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function init() {
return runPromise((svc) => svc.init())
}
export async function track() {
return runPromise((svc) => svc.track())
}
export async function patch(hash: string) {
return runPromise((svc) => svc.patch(hash))
}
export async function restore(snapshot: string) {
return runPromise((svc) => svc.restore(snapshot))
}
export async function revert(patches: Patch[]) {
return runPromise((svc) => svc.revert(patches))
}
export async function diff(hash: string) {
return runPromise((svc) => svc.diff(hash))
}
export async function diffFull(from: string, to: string) {
return runPromise((svc) => svc.diffFull(from, to))
}
}
+5 -1
View File
@@ -78,6 +78,7 @@ export namespace ToolRegistry {
Service,
never,
| Config.Service
| Env.Service
| Plugin.Service
| Question.Service
| Todo.Service
@@ -99,6 +100,7 @@ export namespace ToolRegistry {
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const env = yield* Env.Service
const plugin = yield* Plugin.Service
const agents = yield* Agent.Service
const skill = yield* Skill.Service
@@ -272,13 +274,14 @@ export namespace ToolRegistry {
})
const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {
const e2e = !!(yield* env.get("OPENCODE_E2E_LLM_URL"))
const filtered = (yield* all()).filter((tool) => {
if (tool.id === CodeSearchTool.id || tool.id === WebSearchTool.id) {
return input.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
}
const usePatch =
!!Env.get("OPENCODE_E2E_LLM_URL") ||
e2e ||
(input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4"))
if (tool.id === ApplyPatchTool.id) return usePatch
if (tool.id === EditTool.id || tool.id === WriteTool.id) return !usePatch
@@ -325,6 +328,7 @@ export namespace ToolRegistry {
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(Question.defaultLayer),
Layer.provide(Todo.defaultLayer),
-22
View File
@@ -18,7 +18,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodePath } from "@effect/platform-node"
import { AppFileSystem } from "@/filesystem"
import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
import { makeRuntime } from "@/effect/run-service"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { InstanceState } from "@/effect/instance-state"
@@ -598,25 +597,4 @@ export namespace Worktree {
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodePath.layer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function makeWorktreeInfo(name?: string) {
return runPromise((svc) => svc.makeWorktreeInfo(name))
}
export async function createFromInfo(info: Info, startCommand?: string) {
return runPromise((svc) => svc.createFromInfo(info, startCommand))
}
export async function create(input?: CreateInput) {
return runPromise((svc) => svc.create(input))
}
export async function remove(input: RemoveInput) {
return runPromise((svc) => svc.remove(input))
}
export async function reset(input: ResetInput) {
return runPromise((svc) => svc.reset(input))
}
}
@@ -6,6 +6,7 @@ import { Instance } from "../../src/project/instance"
import { Auth } from "../../src/auth"
import { AccessToken, Account, AccountID, OrgID } from "../../src/account"
import { AppFileSystem } from "../../src/filesystem"
import { Env } from "../../src/env"
import { provideTmpdirInstance } from "../fixture/fixture"
import { tmpdir, tmpdirScoped } from "../fixture/fixture"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
@@ -35,6 +36,7 @@ const emptyAuth = Layer.mock(Auth.Service)({
const layer = Config.layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(emptyAuth),
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
@@ -332,6 +334,7 @@ test("resolves env templates in account config with account token", async () =>
const layer = Config.layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(emptyAuth),
Layer.provide(fakeAccount),
Layer.provideMerge(infra),
@@ -1824,6 +1827,7 @@ test("project config overrides remote well-known config", async () => {
const layer = Config.layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(fakeAuth),
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
@@ -1879,6 +1883,7 @@ test("wellknown URL with trailing slash is normalized", async () => {
const layer = Config.layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(fakeAuth),
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
@@ -1,4 +1,5 @@
import { afterAll, afterEach, describe, expect, spyOn, test } from "bun:test"
import { Effect } from "effect"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
@@ -29,9 +30,11 @@ afterEach(async () => {
async function load(dir: string) {
return Instance.provide({
directory: dir,
fn: async () => {
await Plugin.list()
},
fn: async () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
yield* plugin.list()
}).pipe(Effect.provide(Plugin.defaultLayer), Effect.runPromise),
})
}
+33 -28
View File
@@ -1,4 +1,5 @@
import { afterAll, afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../fixture/fixture"
@@ -56,20 +57,22 @@ describe("plugin.trigger", () => {
const out = await Instance.provide({
directory: tmp.path,
fn: async () => {
const out = { system: [] as string[] }
await Plugin.trigger(
"experimental.chat.system.transform",
{
model: {
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
} as any,
},
out,
)
return out
},
fn: async () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const out = { system: [] as string[] }
yield* plugin.trigger(
"experimental.chat.system.transform",
{
model: {
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
} as any,
},
out,
)
return out
}).pipe(Effect.provide(Plugin.defaultLayer), Effect.runPromise),
})
expect(out.system).toEqual(["sync"])
@@ -90,20 +93,22 @@ describe("plugin.trigger", () => {
const out = await Instance.provide({
directory: tmp.path,
fn: async () => {
const out = { system: [] as string[] }
await Plugin.trigger(
"experimental.chat.system.transform",
{
model: {
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
} as any,
},
out,
)
return out
},
fn: async () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const out = { system: [] as string[] }
yield* plugin.trigger(
"experimental.chat.system.transform",
{
model: {
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
} as any,
},
out,
)
return out
}).pipe(Effect.provide(Plugin.defaultLayer), Effect.runPromise),
})
expect(out.system).toEqual(["async"])
@@ -1,4 +1,5 @@
import { afterAll, afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../fixture/fixture"
@@ -72,15 +73,17 @@ describe("plugin.workspace", () => {
const info = await Instance.provide({
directory: tmp.path,
fn: async () => {
await Plugin.init()
return Workspace.create({
type: tmp.extra.type,
branch: null,
extra: { key: "value" },
projectID: Instance.project.id,
})
},
fn: async () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
yield* plugin.init()
return Workspace.create({
type: tmp.extra.type,
branch: null,
extra: { key: "value" },
projectID: Instance.project.id,
})
}).pipe(Effect.provide(Plugin.defaultLayer), Effect.runPromise),
})
expect(info.type).toBe(tmp.extra.type)
@@ -8,9 +8,19 @@ import { SessionID } from "../../src/session/schema"
import { Log } from "../../src/util/log"
import { $ } from "bun"
import { tmpdir } from "../fixture/fixture"
import { Effect } from "effect"
Log.init({ print: false })
function run<A>(fn: (svc: Project.Interface) => Effect.Effect<A>) {
return Effect.runPromise(
Effect.gen(function* () {
const svc = yield* Project.Service
return yield* fn(svc)
}).pipe(Effect.provide(Project.defaultLayer)),
)
}
function uid() {
return SessionID.make(crypto.randomUUID())
}
@@ -58,7 +68,7 @@ describe("migrateFromGlobal", () => {
await $`git config user.name "Test"`.cwd(tmp.path).quiet()
await $`git config user.email "test@opencode.test"`.cwd(tmp.path).quiet()
await $`git config commit.gpgsign false`.cwd(tmp.path).quiet()
const { project: pre } = await Project.fromDirectory(tmp.path)
const { project: pre } = await run((svc) => svc.fromDirectory(tmp.path))
expect(pre.id).toBe(ProjectID.global)
// 2. Seed a session under "global" with matching directory
@@ -68,7 +78,7 @@ describe("migrateFromGlobal", () => {
// 3. Make a commit so the project gets a real ID
await $`git commit --allow-empty -m "root"`.cwd(tmp.path).quiet()
const { project: real } = await Project.fromDirectory(tmp.path)
const { project: real } = await run((svc) => svc.fromDirectory(tmp.path))
expect(real.id).not.toBe(ProjectID.global)
// 4. The session should have been migrated to the real project ID
@@ -80,7 +90,7 @@ describe("migrateFromGlobal", () => {
test("migrates global sessions even when project row already exists", async () => {
// 1. Create a repo with a commit — real project ID created immediately
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project.id).not.toBe(ProjectID.global)
// 2. Ensure "global" project row exists (as it would from a prior no-git session)
@@ -94,7 +104,7 @@ describe("migrateFromGlobal", () => {
// 4. Call fromDirectory again — project row already exists,
// so the current code skips migration entirely. This is the bug.
await Project.fromDirectory(tmp.path)
await run((svc) => svc.fromDirectory(tmp.path))
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
expect(row).toBeDefined()
@@ -103,7 +113,7 @@ describe("migrateFromGlobal", () => {
test("does not claim sessions with empty directory", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project.id).not.toBe(ProjectID.global)
ensureGlobal()
@@ -113,7 +123,7 @@ describe("migrateFromGlobal", () => {
const id = uid()
seed({ id, dir: "", project: ProjectID.global })
await Project.fromDirectory(tmp.path)
await run((svc) => svc.fromDirectory(tmp.path))
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
expect(row).toBeDefined()
@@ -122,7 +132,7 @@ describe("migrateFromGlobal", () => {
test("does not steal sessions from unrelated directories", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project.id).not.toBe(ProjectID.global)
ensureGlobal()
@@ -131,7 +141,7 @@ describe("migrateFromGlobal", () => {
const id = uid()
seed({ id, dir: "/some/other/dir", project: ProjectID.global })
await Project.fromDirectory(tmp.path)
await run((svc) => svc.fromDirectory(tmp.path))
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
expect(row).toBeDefined()
+83 -69
View File
@@ -8,7 +8,7 @@ import { GlobalBus } from "../../src/bus/global"
import { ProjectID } from "../../src/project/schema"
import { Effect, Layer, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { NodePath } from "@effect/platform-node"
import { AppFileSystem } from "../../src/filesystem"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
@@ -16,6 +16,15 @@ Log.init({ print: false })
const encoder = new TextEncoder()
function run<A>(fn: (svc: Project.Interface) => Effect.Effect<A>, layer = Project.defaultLayer) {
return Effect.runPromise(
Effect.gen(function* () {
const svc = yield* Project.Service
return yield* fn(svc)
}).pipe(Effect.provide(layer)),
)
}
/**
* Creates a mock ChildProcessSpawner layer that intercepts git subcommands
* matching `failArg` and returns exit code 128, while delegating everything
@@ -64,7 +73,7 @@ describe("Project.fromDirectory", () => {
await using tmp = await tmpdir()
await $`git init`.cwd(tmp.path).quiet()
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project).toBeDefined()
expect(project.id).toBe(ProjectID.global)
@@ -78,7 +87,7 @@ describe("Project.fromDirectory", () => {
test("should handle git repository with commits", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project).toBeDefined()
expect(project.id).not.toBe(ProjectID.global)
@@ -91,14 +100,14 @@ describe("Project.fromDirectory", () => {
test("returns global for non-git directory", async () => {
await using tmp = await tmpdir()
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project.id).toBe(ProjectID.global)
})
test("derives stable project ID from root commit", async () => {
await using tmp = await tmpdir({ git: true })
const { project: a } = await Project.fromDirectory(tmp.path)
const { project: b } = await Project.fromDirectory(tmp.path)
const { project: a } = await run((svc) => svc.fromDirectory(tmp.path))
const { project: b } = await run((svc) => svc.fromDirectory(tmp.path))
expect(b.id).toBe(a.id)
})
})
@@ -109,7 +118,7 @@ describe("Project.fromDirectory git failure paths", () => {
await $`git init`.cwd(tmp.path).quiet()
// rev-list fails because HEAD doesn't exist yet — this is the natural scenario
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project.vcs).toBe("git")
expect(project.id).toBe(ProjectID.global)
expect(project.worktree).toBe(tmp.path)
@@ -119,9 +128,7 @@ describe("Project.fromDirectory git failure paths", () => {
await using tmp = await tmpdir({ git: true })
const layer = projectLayerWithFailure("--show-toplevel")
const { project, sandbox } = await Effect.runPromise(
Project.Service.use((svc) => svc.fromDirectory(tmp.path)).pipe(Effect.provide(layer)),
)
const { project, sandbox } = await run((svc) => svc.fromDirectory(tmp.path), layer)
expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(tmp.path)
})
@@ -130,9 +137,7 @@ describe("Project.fromDirectory git failure paths", () => {
await using tmp = await tmpdir({ git: true })
const layer = projectLayerWithFailure("--git-common-dir")
const { project, sandbox } = await Effect.runPromise(
Project.Service.use((svc) => svc.fromDirectory(tmp.path)).pipe(Effect.provide(layer)),
)
const { project, sandbox } = await run((svc) => svc.fromDirectory(tmp.path), layer)
expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(tmp.path)
})
@@ -142,7 +147,7 @@ describe("Project.fromDirectory with worktrees", () => {
test("should set worktree to root when called from root", async () => {
await using tmp = await tmpdir({ git: true })
const { project, sandbox } = await Project.fromDirectory(tmp.path)
const { project, sandbox } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(tmp.path)
@@ -156,7 +161,7 @@ describe("Project.fromDirectory with worktrees", () => {
try {
await $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp.path).quiet()
const { project, sandbox } = await Project.fromDirectory(worktreePath)
const { project, sandbox } = await run((svc) => svc.fromDirectory(worktreePath))
expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(worktreePath)
@@ -173,13 +178,13 @@ describe("Project.fromDirectory with worktrees", () => {
test("worktree should share project ID with main repo", async () => {
await using tmp = await tmpdir({ git: true })
const { project: main } = await Project.fromDirectory(tmp.path)
const { project: main } = await run((svc) => svc.fromDirectory(tmp.path))
const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt-shared")
try {
await $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp.path).quiet()
const { project: wt } = await Project.fromDirectory(worktreePath)
const { project: wt } = await run((svc) => svc.fromDirectory(worktreePath))
expect(wt.id).toBe(main.id)
@@ -205,8 +210,8 @@ describe("Project.fromDirectory with worktrees", () => {
await $`git clone --bare ${tmp.path} ${bare}`.quiet()
await $`git clone ${bare} ${clone}`.quiet()
const { project: a } = await Project.fromDirectory(tmp.path)
const { project: b } = await Project.fromDirectory(clone)
const { project: a } = await run((svc) => svc.fromDirectory(tmp.path))
const { project: b } = await run((svc) => svc.fromDirectory(clone))
expect(b.id).toBe(a.id)
} finally {
@@ -223,8 +228,8 @@ describe("Project.fromDirectory with worktrees", () => {
await $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp.path).quiet()
await $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp.path).quiet()
await Project.fromDirectory(worktree1)
const { project } = await Project.fromDirectory(worktree2)
await run((svc) => svc.fromDirectory(worktree1))
const { project } = await run((svc) => svc.fromDirectory(worktree2))
expect(project.worktree).toBe(tmp.path)
expect(project.sandboxes).toContain(worktree1)
@@ -246,12 +251,12 @@ describe("Project.fromDirectory with worktrees", () => {
describe("Project.discover", () => {
test("should discover favicon.png in root", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
await Bun.write(path.join(tmp.path, "favicon.png"), pngData)
await Project.discover(project)
await run((svc) => svc.discover(project))
const updated = Project.get(project.id)
expect(updated).toBeDefined()
@@ -263,11 +268,11 @@ describe("Project.discover", () => {
test("should not discover non-image files", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
await Bun.write(path.join(tmp.path, "favicon.txt"), "not an image")
await Project.discover(project)
await run((svc) => svc.discover(project))
const updated = Project.get(project.id)
expect(updated).toBeDefined()
@@ -278,12 +283,14 @@ describe("Project.discover", () => {
describe("Project.update", () => {
test("should update name", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const updated = await Project.update({
projectID: project.id,
name: "New Project Name",
})
const updated = await run((svc) =>
svc.update({
projectID: project.id,
name: "New Project Name",
}),
)
expect(updated.name).toBe("New Project Name")
@@ -293,12 +300,14 @@ describe("Project.update", () => {
test("should update icon url", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const updated = await Project.update({
projectID: project.id,
icon: { url: "https://example.com/icon.png" },
})
const updated = await run((svc) =>
svc.update({
projectID: project.id,
icon: { url: "https://example.com/icon.png" },
}),
)
expect(updated.icon?.url).toBe("https://example.com/icon.png")
@@ -308,12 +317,14 @@ describe("Project.update", () => {
test("should update icon color", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const updated = await Project.update({
projectID: project.id,
icon: { color: "#ff0000" },
})
const updated = await run((svc) =>
svc.update({
projectID: project.id,
icon: { color: "#ff0000" },
}),
)
expect(updated.icon?.color).toBe("#ff0000")
@@ -323,12 +334,14 @@ describe("Project.update", () => {
test("should update commands", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const updated = await Project.update({
projectID: project.id,
commands: { start: "npm run dev" },
})
const updated = await run((svc) =>
svc.update({
projectID: project.id,
commands: { start: "npm run dev" },
}),
)
expect(updated.commands?.start).toBe("npm run dev")
@@ -338,16 +351,18 @@ describe("Project.update", () => {
test("should throw error when project not found", async () => {
await expect(
Project.update({
projectID: ProjectID.make("nonexistent-project-id"),
name: "Should Fail",
}),
run((svc) =>
svc.update({
projectID: ProjectID.make("nonexistent-project-id"),
name: "Should Fail",
}),
),
).rejects.toThrow("Project not found: nonexistent-project-id")
})
test("should emit GlobalBus event on update", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
let eventPayload: any = null
const on = (data: any) => {
@@ -356,10 +371,7 @@ describe("Project.update", () => {
GlobalBus.on("event", on)
try {
await Project.update({
projectID: project.id,
name: "Updated Name",
})
await run((svc) => svc.update({ projectID: project.id, name: "Updated Name" }))
expect(eventPayload).not.toBeNull()
expect(eventPayload.payload.type).toBe("project.updated")
@@ -371,14 +383,16 @@ describe("Project.update", () => {
test("should update multiple fields at once", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const updated = await Project.update({
projectID: project.id,
name: "Multi Update",
icon: { url: "https://example.com/favicon.ico", color: "#00ff00" },
commands: { start: "make start" },
})
const updated = await run((svc) =>
svc.update({
projectID: project.id,
name: "Multi Update",
icon: { url: "https://example.com/favicon.ico", color: "#00ff00" },
commands: { start: "make start" },
}),
)
expect(updated.name).toBe("Multi Update")
expect(updated.icon?.url).toBe("https://example.com/favicon.ico")
@@ -390,7 +404,7 @@ describe("Project.update", () => {
describe("Project.list and Project.get", () => {
test("list returns all projects", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const all = Project.list()
expect(all.length).toBeGreaterThan(0)
@@ -399,7 +413,7 @@ describe("Project.list and Project.get", () => {
test("get returns project by id", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const found = Project.get(project.id)
expect(found).toBeDefined()
@@ -415,7 +429,7 @@ describe("Project.list and Project.get", () => {
describe("Project.setInitialized", () => {
test("sets time_initialized on project", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project.time.initialized).toBeUndefined()
@@ -429,15 +443,15 @@ describe("Project.setInitialized", () => {
describe("Project.addSandbox and Project.removeSandbox", () => {
test("addSandbox adds directory and removeSandbox removes it", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const sandboxDir = path.join(tmp.path, "sandbox-test")
await Project.addSandbox(project.id, sandboxDir)
await run((svc) => svc.addSandbox(project.id, sandboxDir))
let found = Project.get(project.id)
expect(found?.sandboxes).toContain(sandboxDir)
await Project.removeSandbox(project.id, sandboxDir)
await run((svc) => svc.removeSandbox(project.id, sandboxDir))
found = Project.get(project.id)
expect(found?.sandboxes).not.toContain(sandboxDir)
@@ -445,14 +459,14 @@ describe("Project.addSandbox and Project.removeSandbox", () => {
test("addSandbox emits GlobalBus event", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const sandboxDir = path.join(tmp.path, "sandbox-event")
const events: any[] = []
const on = (evt: any) => events.push(evt)
GlobalBus.on("event", on)
await Project.addSandbox(project.id, sandboxDir)
await run((svc) => svc.addSandbox(project.id, sandboxDir))
GlobalBus.off("event", on)
expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true)
@@ -1,115 +0,0 @@
import { afterEach, expect, test } from "bun:test"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
afterEach(async () => {
await Instance.disposeAll()
})
test("Instance.state caches values for the same instance", async () => {
await using tmp = await tmpdir()
let n = 0
const state = Instance.state(() => ({ n: ++n }))
await Instance.provide({
directory: tmp.path,
fn: async () => {
const a = state()
const b = state()
expect(a).toBe(b)
expect(n).toBe(1)
},
})
})
test("Instance.state isolates values by directory", async () => {
await using a = await tmpdir()
await using b = await tmpdir()
let n = 0
const state = Instance.state(() => ({ n: ++n }))
const x = await Instance.provide({
directory: a.path,
fn: async () => state(),
})
const y = await Instance.provide({
directory: b.path,
fn: async () => state(),
})
const z = await Instance.provide({
directory: a.path,
fn: async () => state(),
})
expect(x).toBe(z)
expect(x).not.toBe(y)
expect(n).toBe(2)
})
test("Instance.state is disposed on instance reload", async () => {
await using tmp = await tmpdir()
const seen: string[] = []
let n = 0
const state = Instance.state(
() => ({ n: ++n }),
async (value) => {
seen.push(String(value.n))
},
)
const a = await Instance.provide({
directory: tmp.path,
fn: async () => state(),
})
await Instance.reload({ directory: tmp.path })
const b = await Instance.provide({
directory: tmp.path,
fn: async () => state(),
})
expect(a).not.toBe(b)
expect(seen).toEqual(["1"])
})
test("Instance.state is disposed on disposeAll", async () => {
await using a = await tmpdir()
await using b = await tmpdir()
const seen: string[] = []
const state = Instance.state(
() => ({ dir: Instance.directory }),
async (value) => {
seen.push(value.dir)
},
)
await Instance.provide({
directory: a.path,
fn: async () => state(),
})
await Instance.provide({
directory: b.path,
fn: async () => state(),
})
await Instance.disposeAll()
expect(seen.sort()).toEqual([a.path, b.path].sort())
})
test("Instance.state dedupes concurrent promise initialization", async () => {
await using tmp = await tmpdir()
let n = 0
const state = Instance.state(async () => {
n += 1
await Bun.sleep(10)
return { n }
})
const [a, b] = await Instance.provide({
directory: tmp.path,
fn: async () => Promise.all([state(), state()]),
})
expect(a).toBe(b)
expect(n).toBe(1)
})
@@ -1,96 +1,126 @@
import { describe, expect, test } from "bun:test"
import { $ } from "bun"
import fs from "fs/promises"
import { describe, expect } from "bun:test"
import * as fs from "fs/promises"
import path from "path"
import { Instance } from "../../src/project/instance"
import { Effect, Layer } from "effect"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { Worktree } from "../../src/worktree"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const wintest = process.platform === "win32" ? test : test.skip
const it = testEffect(Layer.mergeAll(Worktree.defaultLayer, CrossSpawnSpawner.defaultLayer))
const wintest = process.platform === "win32" ? it.live : it.live.skip
describe("Worktree.remove", () => {
test("continues when git remove exits non-zero after detaching", async () => {
await using tmp = await tmpdir({ git: true })
const root = tmp.path
const name = `remove-regression-${Date.now().toString(36)}`
const branch = `opencode/${name}`
const dir = path.join(root, "..", name)
it.live("continues when git remove exits non-zero after detaching", () =>
provideTmpdirInstance(
(root) =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const name = `remove-regression-${Date.now().toString(36)}`
const branch = `opencode/${name}`
const dir = path.join(root, "..", name)
await $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet()
await $`git reset --hard`.cwd(dir).quiet()
yield* Effect.promise(() => $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet())
yield* Effect.promise(() => $`git reset --hard`.cwd(dir).quiet())
const real = (await $`which git`.quiet().text()).trim()
expect(real).toBeTruthy()
const real = (yield* Effect.promise(() => $`which git`.quiet().text())).trim()
expect(real).toBeTruthy()
const bin = path.join(root, "bin")
const shim = path.join(bin, "git")
await fs.mkdir(bin, { recursive: true })
await Bun.write(
shim,
[
"#!/bin/bash",
`REAL_GIT=${JSON.stringify(real)}`,
'if [ "$1" = "worktree" ] && [ "$2" = "remove" ]; then',
' "$REAL_GIT" "$@" >/dev/null 2>&1',
' echo "fatal: failed to remove worktree: Directory not empty" >&2',
" exit 1",
"fi",
'exec "$REAL_GIT" "$@"',
].join("\n"),
)
await fs.chmod(shim, 0o755)
const bin = path.join(root, "bin")
const shim = path.join(bin, "git")
yield* Effect.promise(() => fs.mkdir(bin, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
shim,
[
"#!/bin/bash",
`REAL_GIT=${JSON.stringify(real)}`,
'if [ "$1" = "worktree" ] && [ "$2" = "remove" ]; then',
' "$REAL_GIT" "$@" >/dev/null 2>&1',
' echo "fatal: failed to remove worktree: Directory not empty" >&2',
" exit 1",
"fi",
'exec "$REAL_GIT" "$@"',
].join("\n"),
),
)
yield* Effect.promise(() => fs.chmod(shim, 0o755))
const prev = process.env.PATH ?? ""
process.env.PATH = `${bin}${path.delimiter}${prev}`
const prev = yield* Effect.acquireRelease(
Effect.sync(() => {
const prev = process.env.PATH ?? ""
process.env.PATH = `${bin}${path.delimiter}${prev}`
return prev
}),
(prev) =>
Effect.sync(() => {
process.env.PATH = prev
}),
)
void prev
const ok = await (async () => {
try {
return await Instance.provide({
directory: root,
fn: () => Worktree.remove({ directory: dir }),
})
} finally {
process.env.PATH = prev
}
})()
const ok = yield* svc.remove({ directory: dir })
expect(ok).toBe(true)
expect(await Filesystem.exists(dir)).toBe(false)
expect(ok).toBe(true)
expect(
yield* Effect.promise(() =>
fs
.stat(dir)
.then(() => true)
.catch(() => false),
),
).toBe(false)
const list = await $`git worktree list --porcelain`.cwd(root).quiet().text()
expect(list).not.toContain(`worktree ${dir}`)
const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(root).quiet().text())
expect(list).not.toContain(`worktree ${dir}`)
const ref = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow()
expect(ref.exitCode).not.toBe(0)
})
const ref = yield* Effect.promise(() =>
$`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow(),
)
expect(ref.exitCode).not.toBe(0)
}),
{ git: true },
),
)
wintest("stops fsmonitor before removing a worktree", async () => {
await using tmp = await tmpdir({ git: true })
const root = tmp.path
const name = `remove-fsmonitor-${Date.now().toString(36)}`
const branch = `opencode/${name}`
const dir = path.join(root, "..", name)
wintest("stops fsmonitor before removing a worktree", () =>
provideTmpdirInstance(
(root) =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const name = `remove-fsmonitor-${Date.now().toString(36)}`
const branch = `opencode/${name}`
const dir = path.join(root, "..", name)
await $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet()
await $`git reset --hard`.cwd(dir).quiet()
await $`git config core.fsmonitor true`.cwd(dir).quiet()
await $`git fsmonitor--daemon stop`.cwd(dir).quiet().nothrow()
await Bun.write(path.join(dir, "tracked.txt"), "next\n")
await $`git diff`.cwd(dir).quiet()
yield* Effect.promise(() => $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet())
yield* Effect.promise(() => $`git reset --hard`.cwd(dir).quiet())
yield* Effect.promise(() => $`git config core.fsmonitor true`.cwd(dir).quiet())
yield* Effect.promise(() => $`git fsmonitor--daemon stop`.cwd(dir).quiet().nothrow())
yield* Effect.promise(() => Bun.write(path.join(dir, "tracked.txt"), "next\n"))
yield* Effect.promise(() => $`git diff`.cwd(dir).quiet())
const before = await $`git fsmonitor--daemon status`.cwd(dir).quiet().nothrow()
expect(before.exitCode).toBe(0)
const before = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(dir).quiet().nothrow())
expect(before.exitCode).toBe(0)
const ok = await Instance.provide({
directory: root,
fn: () => Worktree.remove({ directory: dir }),
})
const ok = yield* svc.remove({ directory: dir })
expect(ok).toBe(true)
expect(await Filesystem.exists(dir)).toBe(false)
expect(ok).toBe(true)
expect(
yield* Effect.promise(() =>
fs
.stat(dir)
.then(() => true)
.catch(() => false),
),
).toBe(false)
const ref = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow()
expect(ref.exitCode).not.toBe(0)
})
const ref = yield* Effect.promise(() =>
$`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow(),
)
expect(ref.exitCode).not.toBe(0)
}),
{ git: true },
),
)
})
+147 -106
View File
@@ -1,16 +1,16 @@
import { $ } from "bun"
import { afterEach, describe, expect, test } from "bun:test"
const wintest = process.platform !== "win32" ? test : test.skip
import fs from "fs/promises"
import { afterEach, describe, expect } from "bun:test"
import * as fs from "fs/promises"
import path from "path"
import { Cause, Effect, Exit, Layer } from "effect"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { Instance } from "../../src/project/instance"
import { Worktree } from "../../src/worktree"
import { tmpdir } from "../fixture/fixture"
import { provideInstance, provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
function withInstance(directory: string, fn: () => Promise<any>) {
return Instance.provide({ directory, fn })
}
const it = testEffect(Layer.mergeAll(Worktree.defaultLayer, CrossSpawnSpawner.defaultLayer))
const wintest = process.platform !== "win32" ? it.live : it.live.skip
function normalize(input: string) {
return input.replace(/\\/g, "/").toLowerCase()
@@ -40,134 +40,175 @@ describe("Worktree", () => {
afterEach(() => Instance.disposeAll())
describe("makeWorktreeInfo", () => {
test("returns info with name, branch, and directory", async () => {
await using tmp = await tmpdir({ git: true })
it.live("returns info with name, branch, and directory", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const info = yield* svc.makeWorktreeInfo()
const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo())
expect(info.name).toBeDefined()
expect(typeof info.name).toBe("string")
expect(info.branch).toBe(`opencode/${info.name}`)
expect(info.directory).toContain(info.name)
}),
{ git: true },
),
)
expect(info.name).toBeDefined()
expect(typeof info.name).toBe("string")
expect(info.branch).toBe(`opencode/${info.name}`)
expect(info.directory).toContain(info.name)
})
it.live("uses provided name as base", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const info = yield* svc.makeWorktreeInfo("my-feature")
test("uses provided name as base", async () => {
await using tmp = await tmpdir({ git: true })
expect(info.name).toBe("my-feature")
expect(info.branch).toBe("opencode/my-feature")
}),
{ git: true },
),
)
const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo("my-feature"))
it.live("slugifies the provided name", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const info = yield* svc.makeWorktreeInfo("My Feature Branch!")
expect(info.name).toBe("my-feature")
expect(info.branch).toBe("opencode/my-feature")
})
expect(info.name).toBe("my-feature-branch")
}),
{ git: true },
),
)
test("slugifies the provided name", async () => {
await using tmp = await tmpdir({ git: true })
it.live("throws NotGitError for non-git directories", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const exit = yield* Effect.exit(svc.makeWorktreeInfo())
const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo("My Feature Branch!"))
expect(info.name).toBe("my-feature-branch")
})
test("throws NotGitError for non-git directories", async () => {
await using tmp = await tmpdir()
await expect(withInstance(tmp.path, () => Worktree.makeWorktreeInfo())).rejects.toThrow("WorktreeNotGitError")
})
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError)
}),
),
)
})
describe("create + remove lifecycle", () => {
test("create returns worktree info and remove cleans up", async () => {
await using tmp = await tmpdir({ git: true })
it.live("create returns worktree info and remove cleans up", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const info = yield* svc.create()
const info = await withInstance(tmp.path, () => Worktree.create())
expect(info.name).toBeDefined()
expect(info.branch).toStartWith("opencode/")
expect(info.directory).toBeDefined()
expect(info.name).toBeDefined()
expect(info.branch).toStartWith("opencode/")
expect(info.directory).toBeDefined()
yield* Effect.promise(() => Bun.sleep(1000))
// Wait for bootstrap to complete
await Bun.sleep(1000)
const ok = yield* svc.remove({ directory: info.directory })
expect(ok).toBe(true)
}),
{ git: true },
),
)
const ok = await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory }))
expect(ok).toBe(true)
})
it.live("create returns after setup and fires Event.Ready after bootstrap", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const ready = waitReady()
const info = yield* svc.create()
test("create returns after setup and fires Event.Ready after bootstrap", async () => {
await using tmp = await tmpdir({ git: true })
const ready = waitReady()
expect(info.name).toBeDefined()
expect(info.branch).toStartWith("opencode/")
const info = await withInstance(tmp.path, () => Worktree.create())
const text = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text())
const next = yield* Effect.promise(() => fs.realpath(info.directory).catch(() => info.directory))
expect(normalize(text)).toContain(normalize(next))
// create returns before bootstrap completes, but the worktree already exists
expect(info.name).toBeDefined()
expect(info.branch).toStartWith("opencode/")
const props = yield* Effect.promise(() => ready)
expect(props.name).toBe(info.name)
expect(props.branch).toBe(info.branch)
const text = await $`git worktree list --porcelain`.cwd(tmp.path).quiet().text()
const dir = await fs.realpath(info.directory).catch(() => info.directory)
expect(normalize(text)).toContain(normalize(dir))
yield* Effect.promise(() => Instance.dispose()).pipe(provideInstance(info.directory))
yield* Effect.promise(() => Bun.sleep(100))
yield* svc.remove({ directory: info.directory })
}),
{ git: true },
),
)
// Event.Ready fires after bootstrap finishes in the background
const props = await ready
expect(props.name).toBe(info.name)
expect(props.branch).toBe(info.branch)
it.live("create with custom name", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const ready = waitReady()
const info = yield* svc.create({ name: "test-workspace" })
// Cleanup
await withInstance(info.directory, () => Instance.dispose())
await Bun.sleep(100)
await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory }))
})
expect(info.name).toBe("test-workspace")
expect(info.branch).toBe("opencode/test-workspace")
test("create with custom name", async () => {
await using tmp = await tmpdir({ git: true })
const ready = waitReady()
const info = await withInstance(tmp.path, () => Worktree.create({ name: "test-workspace" }))
expect(info.name).toBe("test-workspace")
expect(info.branch).toBe("opencode/test-workspace")
// Cleanup
await ready
await withInstance(info.directory, () => Instance.dispose())
await Bun.sleep(100)
await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory }))
})
yield* Effect.promise(() => ready)
yield* Effect.promise(() => Instance.dispose()).pipe(provideInstance(info.directory))
yield* Effect.promise(() => Bun.sleep(100))
yield* svc.remove({ directory: info.directory })
}),
{ git: true },
),
)
})
describe("createFromInfo", () => {
wintest("creates and bootstraps git worktree", async () => {
await using tmp = await tmpdir({ git: true })
wintest("creates and bootstraps git worktree", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const info = yield* svc.makeWorktreeInfo("from-info-test")
yield* svc.createFromInfo(info)
const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo("from-info-test"))
await withInstance(tmp.path, () => Worktree.createFromInfo(info))
const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text())
const normalizedList = list.replace(/\\/g, "/")
const normalizedDir = info.directory.replace(/\\/g, "/")
expect(normalizedList).toContain(normalizedDir)
// Worktree should exist in git (normalize slashes for Windows)
const list = await $`git worktree list --porcelain`.cwd(tmp.path).quiet().text()
const normalizedList = list.replace(/\\/g, "/")
const normalizedDir = info.directory.replace(/\\/g, "/")
expect(normalizedList).toContain(normalizedDir)
// Cleanup
await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory }))
})
yield* svc.remove({ directory: info.directory })
}),
{ git: true },
),
)
})
describe("remove edge cases", () => {
test("remove non-existent directory succeeds silently", async () => {
await using tmp = await tmpdir({ git: true })
it.live("remove non-existent directory succeeds silently", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const ok = yield* svc.remove({ directory: path.join(dir, "does-not-exist") })
expect(ok).toBe(true)
}),
{ git: true },
),
)
const ok = await withInstance(tmp.path, () =>
Worktree.remove({ directory: path.join(tmp.path, "does-not-exist") }),
)
expect(ok).toBe(true)
})
it.live("throws NotGitError for non-git directories", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const exit = yield* Effect.exit(svc.remove({ directory: "/tmp/fake" }))
test("throws NotGitError for non-git directories", async () => {
await using tmp = await tmpdir()
await expect(withInstance(tmp.path, () => Worktree.remove({ directory: "/tmp/fake" }))).rejects.toThrow(
"WorktreeNotGitError",
)
})
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError)
}),
),
)
})
})
@@ -2436,10 +2436,15 @@ test("plugin config providers persist after instance dispose", async () => {
const first = await Instance.provide({
directory: tmp.path,
fn: async () => {
await Plugin.init()
return list()
},
fn: async () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const provider = yield* Provider.Service
yield* plugin.init()
return yield* provider.list()
}),
),
})
expect(first[ProviderID.make("demo")]).toBeDefined()
expect(first[ProviderID.make("demo")].models[ModelID.make("chat")]).toBeDefined()
@@ -1,27 +1,43 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import z from "zod"
import { Instance } from "../../src/project/instance"
import { Project } from "../../src/project/project"
import { Session } from "../../src/session"
import { Session as SessionNs } from "../../src/session"
import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
describe("Session.listGlobal", () => {
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
}
const svc = {
...SessionNs,
create(input?: SessionNs.CreateInput) {
return run(SessionNs.Service.use((svc) => svc.create(input)))
},
setArchived(input: z.output<typeof SessionNs.SetArchivedInput>) {
return run(SessionNs.Service.use((svc) => svc.setArchived(input)))
},
}
describe("session.listGlobal", () => {
test("lists sessions across projects with project metadata", async () => {
await using first = await tmpdir({ git: true })
await using second = await tmpdir({ git: true })
const firstSession = await Instance.provide({
directory: first.path,
fn: async () => Session.create({ title: "first-session" }),
fn: async () => svc.create({ title: "first-session" }),
})
const secondSession = await Instance.provide({
directory: second.path,
fn: async () => Session.create({ title: "second-session" }),
fn: async () => svc.create({ title: "second-session" }),
})
const sessions = [...Session.listGlobal({ limit: 200 })]
const sessions = [...svc.listGlobal({ limit: 200 })]
const ids = sessions.map((session) => session.id)
expect(ids).toContain(firstSession.id)
@@ -44,20 +60,20 @@ describe("Session.listGlobal", () => {
const archived = await Instance.provide({
directory: tmp.path,
fn: async () => Session.create({ title: "archived-session" }),
fn: async () => svc.create({ title: "archived-session" }),
})
await Instance.provide({
directory: tmp.path,
fn: async () => Session.setArchived({ sessionID: archived.id, time: Date.now() }),
fn: async () => svc.setArchived({ sessionID: archived.id, time: Date.now() }),
})
const sessions = [...Session.listGlobal({ limit: 200 })]
const sessions = [...svc.listGlobal({ limit: 200 })]
const ids = sessions.map((session) => session.id)
expect(ids).not.toContain(archived.id)
const allSessions = [...Session.listGlobal({ limit: 200, archived: true })]
const allSessions = [...svc.listGlobal({ limit: 200, archived: true })]
const allIds = allSessions.map((session) => session.id)
expect(allIds).toContain(archived.id)
@@ -68,19 +84,19 @@ describe("Session.listGlobal", () => {
const first = await Instance.provide({
directory: tmp.path,
fn: async () => Session.create({ title: "page-one" }),
fn: async () => svc.create({ title: "page-one" }),
})
await new Promise((resolve) => setTimeout(resolve, 5))
const second = await Instance.provide({
directory: tmp.path,
fn: async () => Session.create({ title: "page-two" }),
fn: async () => svc.create({ title: "page-two" }),
})
const page = [...Session.listGlobal({ directory: tmp.path, limit: 1 })]
const page = [...svc.listGlobal({ directory: tmp.path, limit: 1 })]
expect(page.length).toBe(1)
expect(page[0].id).toBe(second.id)
const next = [...Session.listGlobal({ directory: tmp.path, limit: 10, cursor: page[0].time.updated })]
const next = [...svc.listGlobal({ directory: tmp.path, limit: 10, cursor: page[0].time.updated })]
const ids = next.map((session) => session.id)
expect(ids).toContain(first.id)
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, spyOn, test } from "bun:test"
import { Effect } from "effect"
import path from "path"
import { GlobalBus } from "../../src/bus/global"
import { Snapshot } from "../../src/snapshot"
@@ -8,7 +9,7 @@ import { Server } from "../../src/server/server"
import { Filesystem } from "../../src/util/filesystem"
import { Log } from "../../src/util/log"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
import { provideInstance, tmpdir } from "../fixture/fixture"
Log.init({ print: false })
@@ -60,12 +61,14 @@ describe("project.initGit endpoint", () => {
worktree: tmp.path,
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
expect(await Snapshot.track()).toBeTruthy()
},
})
expect(
await Effect.runPromise(
Snapshot.Service.use((svc) => svc.track()).pipe(
provideInstance(tmp.path),
Effect.provide(Snapshot.defaultLayer),
),
),
).toBeTruthy()
} finally {
await Instance.disposeAll()
reloadSpy.mockRestore()
@@ -1,36 +1,48 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { afterEach, describe, expect, mock, test } from "bun:test"
import { Effect } from "effect"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { Session } from "../../src/session"
import { SessionPrompt } from "../../src/session/prompt"
import { Session as SessionNs } from "../../src/session"
import type { SessionID } from "../../src/session/schema"
import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
}
const svc = {
...SessionNs,
create(input?: SessionNs.CreateInput) {
return run(SessionNs.Service.use((svc) => svc.create(input)))
},
remove(id: SessionID) {
return run(SessionNs.Service.use((svc) => svc.remove(id)))
},
}
afterEach(async () => {
mock.restore()
await Instance.disposeAll()
})
describe("session action routes", () => {
test("abort route calls SessionPrompt.cancel", async () => {
test("abort route returns success", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const cancel = spyOn(SessionPrompt, "cancel").mockResolvedValue()
const session = await svc.create({})
const app = Server.Default().app
const res = await app.request(`/session/${session.id}/abort`, { method: "POST" })
expect(res.status).toBe(200)
expect(await res.json()).toBe(true)
expect(cancel).toHaveBeenCalledWith(session.id)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -1,30 +1,42 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Instance } from "../../src/project/instance"
import { Session } from "../../src/session"
import { Session as SessionNs } from "../../src/session"
import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
}
const svc = {
...SessionNs,
create(input?: SessionNs.CreateInput) {
return run(SessionNs.Service.use((svc) => svc.create(input)))
},
}
afterEach(async () => {
await Instance.disposeAll()
})
describe("Session.list", () => {
describe("session.list", () => {
test("filters by directory", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const first = await Session.create({})
const first = await svc.create({})
await using other = await tmpdir({ git: true })
const second = await Instance.provide({
directory: other.path,
fn: async () => Session.create({}),
fn: async () => svc.create({}),
})
const sessions = [...Session.list({ directory: tmp.path })]
const sessions = [...svc.list({ directory: tmp.path })]
const ids = sessions.map((s) => s.id)
expect(ids).toContain(first.id)
@@ -38,10 +50,10 @@ describe("Session.list", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const root = await Session.create({ title: "root-session" })
const child = await Session.create({ title: "child-session", parentID: root.id })
const root = await svc.create({ title: "root-session" })
const child = await svc.create({ title: "child-session", parentID: root.id })
const sessions = [...Session.list({ roots: true })]
const sessions = [...svc.list({ roots: true })]
const ids = sessions.map((s) => s.id)
expect(ids).toContain(root.id)
@@ -55,10 +67,10 @@ describe("Session.list", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({ title: "new-session" })
const session = await svc.create({ title: "new-session" })
const futureStart = Date.now() + 86400000
const sessions = [...Session.list({ start: futureStart })]
const sessions = [...svc.list({ start: futureStart })]
expect(sessions.length).toBe(0)
},
})
@@ -69,10 +81,10 @@ describe("Session.list", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await Session.create({ title: "unique-search-term-abc" })
await Session.create({ title: "other-session-xyz" })
await svc.create({ title: "unique-search-term-abc" })
await svc.create({ title: "other-session-xyz" })
const sessions = [...Session.list({ search: "unique-search" })]
const sessions = [...svc.list({ search: "unique-search" })]
const titles = sessions.map((s) => s.title)
expect(titles).toContain("unique-search-term-abc")
@@ -86,11 +98,11 @@ describe("Session.list", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await Session.create({ title: "session-1" })
await Session.create({ title: "session-2" })
await Session.create({ title: "session-3" })
await svc.create({ title: "session-1" })
await svc.create({ title: "session-2" })
await svc.create({ title: "session-3" })
const sessions = [...Session.list({ limit: 2 })]
const sessions = [...svc.list({ limit: 2 })]
expect(sessions.length).toBe(2)
},
})
@@ -1,7 +1,8 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { Session } from "../../src/session"
import { Session as SessionNs } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { Log } from "../../src/util/log"
@@ -9,6 +10,26 @@ import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
}
const svc = {
...SessionNs,
create(input?: SessionNs.CreateInput) {
return run(SessionNs.Service.use((svc) => svc.create(input)))
},
remove(id: SessionID) {
return run(SessionNs.Service.use((svc) => svc.remove(id)))
},
updateMessage<T extends MessageV2.Info>(msg: T) {
return run(SessionNs.Service.use((svc) => svc.updateMessage(msg)))
},
updatePart<T extends MessageV2.Part>(part: T) {
return run(SessionNs.Service.use((svc) => svc.updatePart(part)))
},
}
afterEach(async () => {
await Instance.disposeAll()
})
@@ -30,7 +51,7 @@ async function fill(sessionID: SessionID, count: number, time = (i: number) => D
for (let i = 0; i < count; i++) {
const id = MessageID.ascending()
ids.push(id)
await Session.updateMessage({
await svc.updateMessage({
id,
sessionID,
role: "user",
@@ -40,7 +61,7 @@ async function fill(sessionID: SessionID, count: number, time = (i: number) => D
tools: {},
mode: "",
} as unknown as MessageV2.Info)
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID,
messageID: id,
@@ -58,7 +79,7 @@ describe("session messages endpoint", () => {
Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 5)
const app = Server.Default().app
@@ -75,7 +96,7 @@ describe("session messages endpoint", () => {
const bBody = (await b.json()) as MessageV2.WithParts[]
expect(bBody.map((item) => item.info.id)).toEqual(ids.slice(-4, -2))
await Session.remove(session.id)
await svc.remove(session.id)
},
}),
)
@@ -87,7 +108,7 @@ describe("session messages endpoint", () => {
Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 3)
const app = Server.Default().app
@@ -96,7 +117,7 @@ describe("session messages endpoint", () => {
const body = (await res.json()) as MessageV2.WithParts[]
expect(body.map((item) => item.info.id)).toEqual(ids)
await Session.remove(session.id)
await svc.remove(session.id)
},
}),
)
@@ -108,7 +129,7 @@ describe("session messages endpoint", () => {
Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const app = Server.Default().app
const bad = await app.request(`/session/${session.id}/message?limit=2&before=bad`)
@@ -117,7 +138,7 @@ describe("session messages endpoint", () => {
const miss = await app.request(`/session/ses_missing/message?limit=2`)
expect(miss.status).toBe(404)
await Session.remove(session.id)
await svc.remove(session.id)
},
}),
)
@@ -129,7 +150,7 @@ describe("session messages endpoint", () => {
Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
await fill(session.id, 520)
const app = Server.Default().app
@@ -138,7 +159,7 @@ describe("session messages endpoint", () => {
const body = (await res.json()) as MessageV2.WithParts[]
expect(body).toHaveLength(510)
await Session.remove(session.id)
await svc.remove(session.id)
},
}),
)
@@ -1,5 +1,7 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Session } from "../../src/session"
import { Effect } from "effect"
import { Session as SessionNs } from "../../src/session"
import type { SessionID } from "../../src/session/schema"
import { Log } from "../../src/util/log"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
@@ -7,6 +9,20 @@ import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
}
const svc = {
...SessionNs,
create(input?: SessionNs.CreateInput) {
return run(SessionNs.Service.use((svc) => svc.create(input)))
},
remove(id: SessionID) {
return run(SessionNs.Service.use((svc) => svc.remove(id)))
},
}
afterEach(async () => {
await Instance.disposeAll()
})
@@ -18,7 +34,7 @@ describe("tui.selectSession endpoint", () => {
directory: tmp.path,
fn: async () => {
// #given
const session = await Session.create({})
const session = await svc.create({})
// #when
const app = Server.Default().app
@@ -33,7 +49,7 @@ describe("tui.selectSession endpoint", () => {
const body = await response.json()
expect(body).toBe(true)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
+425 -199
View File
@@ -3,6 +3,7 @@ import { APICallError } from "ai"
import { Cause, Effect, Exit, Layer, ManagedRuntime } from "effect"
import * as Stream from "effect/Stream"
import path from "path"
import z from "zod"
import { Bus } from "../../src/bus"
import { Config } from "../../src/config/config"
import { Agent } from "../../src/agent/agent"
@@ -13,8 +14,8 @@ import { Instance } from "../../src/project/instance"
import { Log } from "../../src/util/log"
import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin"
import { tmpdir } from "../fixture/fixture"
import { Session } from "../../src/session"
import { provideTmpdirInstance, tmpdir } from "../fixture/fixture"
import { Session as SessionNs } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status"
@@ -24,9 +25,31 @@ import type { Provider } from "../../src/provider/provider"
import * as SessionProcessorModule from "../../src/session/processor"
import { Snapshot } from "../../src/snapshot"
import { ProviderTest } from "../fake/provider"
import { testEffect } from "../lib/effect"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
Log.init({ print: false })
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
}
const svc = {
...SessionNs,
create(input?: SessionNs.CreateInput) {
return run(SessionNs.Service.use((svc) => svc.create(input)))
},
messages(input: z.output<typeof SessionNs.MessagesInput>) {
return run(SessionNs.Service.use((svc) => svc.messages(input)))
},
updateMessage<T extends MessageV2.Info>(msg: T) {
return run(SessionNs.Service.use((svc) => svc.updateMessage(msg)))
},
updatePart<T extends MessageV2.Part>(part: T) {
return run(SessionNs.Service.use((svc) => svc.updatePart(part)))
},
}
const summary = Layer.succeed(
SessionSummary.Service,
SessionSummary.Service.of({
@@ -78,7 +101,7 @@ function createModel(opts: {
const wide = () => ProviderTest.fake({ model: createModel({ context: 100_000, output: 32_000 }) })
async function user(sessionID: SessionID, text: string) {
const msg = await Session.updateMessage({
const msg = await svc.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID,
@@ -86,7 +109,7 @@ async function user(sessionID: SessionID, text: string) {
model: ref,
time: { created: Date.now() },
})
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
messageID: msg.id,
sessionID,
@@ -117,12 +140,12 @@ async function assistant(sessionID: SessionID, parentID: MessageID, root: string
time: { created: Date.now() },
finish: "end_turn",
}
await Session.updateMessage(msg)
await svc.updateMessage(msg)
return msg
}
async function tool(sessionID: SessionID, messageID: MessageID, tool: string, output: string) {
return Session.updatePart({
return svc.updatePart({
id: PartID.ascending(),
messageID,
sessionID,
@@ -169,7 +192,7 @@ function runtime(result: "continue" | "compact", plugin = Plugin.defaultLayer, p
return ManagedRuntime.make(
Layer.mergeAll(SessionCompaction.layer, bus).pipe(
Layer.provide(provider.layer),
Layer.provide(Session.defaultLayer),
Layer.provide(SessionNs.defaultLayer),
Layer.provide(layer(result)),
Layer.provide(Agent.defaultLayer),
Layer.provide(plugin),
@@ -179,6 +202,23 @@ function runtime(result: "continue" | "compact", plugin = Plugin.defaultLayer, p
)
}
const deps = Layer.mergeAll(
ProviderTest.fake().layer,
layer("continue"),
Agent.defaultLayer,
Plugin.defaultLayer,
Bus.layer,
Config.defaultLayer,
)
const env = Layer.mergeAll(
SessionNs.defaultLayer,
CrossSpawnSpawner.defaultLayer,
SessionCompaction.layer.pipe(Layer.provide(SessionNs.defaultLayer), Layer.provideMerge(deps)),
)
const it = testEffect(env)
function llm() {
const queue: Array<
Stream.Stream<LLM.Event, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown>)
@@ -208,7 +248,7 @@ function liveRuntime(layer: Layer.Layer<LLM.Service>, provider = ProviderTest.fa
return ManagedRuntime.make(
Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe(
Layer.provide(provider.layer),
Layer.provide(Session.defaultLayer),
Layer.provide(SessionNs.defaultLayer),
Layer.provide(Snapshot.defaultLayer),
Layer.provide(layer),
Layer.provide(Permission.defaultLayer),
@@ -244,78 +284,92 @@ function plugin(ready: ReturnType<typeof defer>) {
})
}
function autocontinue(enabled: boolean) {
return Layer.mock(Plugin.Service)({
trigger: <Name extends string, Input, Output>(name: Name, _input: Input, output: Output) => {
if (name !== "experimental.compaction.autocontinue") return Effect.succeed(output)
return Effect.sync(() => {
;(output as { enabled: boolean }).enabled = enabled
return output
})
},
list: () => Effect.succeed([]),
init: () => Effect.void,
})
}
describe("session.compaction.isOverflow", () => {
test("returns true when token count exceeds usable context", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
it.live(
"returns true when token count exceeds usable context",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const compact = yield* SessionCompaction.Service
const model = createModel({ context: 100_000, output: 32_000 })
const tokens = { input: 75_000, output: 5_000, reasoning: 0, cache: { read: 0, write: 0 } }
expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(true)
},
})
})
expect(yield* compact.isOverflow({ tokens, model })).toBe(true)
}),
),
)
test("returns false when token count within usable context", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
it.live(
"returns false when token count within usable context",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const compact = yield* SessionCompaction.Service
const model = createModel({ context: 200_000, output: 32_000 })
const tokens = { input: 100_000, output: 10_000, reasoning: 0, cache: { read: 0, write: 0 } }
expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(false)
},
})
})
expect(yield* compact.isOverflow({ tokens, model })).toBe(false)
}),
),
)
test("includes cache.read in token count", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
it.live(
"includes cache.read in token count",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const compact = yield* SessionCompaction.Service
const model = createModel({ context: 100_000, output: 32_000 })
const tokens = { input: 60_000, output: 10_000, reasoning: 0, cache: { read: 10_000, write: 0 } }
expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(true)
},
})
})
expect(yield* compact.isOverflow({ tokens, model })).toBe(true)
}),
),
)
test("respects input limit for input caps", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
it.live(
"respects input limit for input caps",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const compact = yield* SessionCompaction.Service
const model = createModel({ context: 400_000, input: 272_000, output: 128_000 })
const tokens = { input: 271_000, output: 1_000, reasoning: 0, cache: { read: 2_000, write: 0 } }
expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(true)
},
})
})
expect(yield* compact.isOverflow({ tokens, model })).toBe(true)
}),
),
)
test("returns false when input/output are within input caps", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
it.live(
"returns false when input/output are within input caps",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const compact = yield* SessionCompaction.Service
const model = createModel({ context: 400_000, input: 272_000, output: 128_000 })
const tokens = { input: 200_000, output: 20_000, reasoning: 0, cache: { read: 10_000, write: 0 } }
expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(false)
},
})
})
expect(yield* compact.isOverflow({ tokens, model })).toBe(false)
}),
),
)
test("returns false when output within limit with input caps", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
it.live(
"returns false when output within limit with input caps",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const compact = yield* SessionCompaction.Service
const model = createModel({ context: 200_000, input: 120_000, output: 10_000 })
const tokens = { input: 50_000, output: 9_999, reasoning: 0, cache: { read: 0, write: 0 } }
expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(false)
},
})
})
expect(yield* compact.isOverflow({ tokens, model })).toBe(false)
}),
),
)
// ─── Bug reproduction tests ───────────────────────────────────────────
// These tests demonstrate that when limit.input is set, isOverflow()
@@ -329,11 +383,11 @@ describe("session.compaction.isOverflow", () => {
// Related issues: #10634, #8089, #11086, #12621
// Open PRs: #6875, #12924
test("BUG: no headroom when limit.input is set — compaction should trigger near boundary but does not", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
it.live(
"BUG: no headroom when limit.input is set — compaction should trigger near boundary but does not",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const compact = yield* SessionCompaction.Service
// Simulate Claude with prompt caching: input limit = 200K, output limit = 32K
const model = createModel({ context: 200_000, input: 200_000, output: 32_000 })
@@ -350,16 +404,16 @@ describe("session.compaction.isOverflow", () => {
// With 198K used and only 2K headroom, the next turn will overflow.
// Compaction MUST trigger here.
expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(true)
},
})
})
expect(yield* compact.isOverflow({ tokens, model })).toBe(true)
}),
),
)
test("BUG: without limit.input, same token count correctly triggers compaction", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
it.live(
"BUG: without limit.input, same token count correctly triggers compaction",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const compact = yield* SessionCompaction.Service
// Same model but without limit.input — uses context - output instead
const model = createModel({ context: 200_000, output: 32_000 })
@@ -369,17 +423,17 @@ describe("session.compaction.isOverflow", () => {
// usable = context - output = 200K - 32K = 168K
// 198K > 168K = true → compaction correctly triggered
const result = await SessionCompaction.isOverflow({ tokens, model })
const result = yield* compact.isOverflow({ tokens, model })
expect(result).toBe(true) // ← Correct: headroom is reserved
},
})
})
}),
),
)
test("BUG: asymmetry — limit.input model allows 30K more usage before compaction than equivalent model without it", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
it.live(
"BUG: asymmetry — limit.input model allows 30K more usage before compaction than equivalent model without it",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const compact = yield* SessionCompaction.Service
// Two models with identical context/output limits, differing only in limit.input
const withInputLimit = createModel({ context: 200_000, input: 200_000, output: 32_000 })
const withoutInputLimit = createModel({ context: 200_000, output: 32_000 })
@@ -387,67 +441,66 @@ describe("session.compaction.isOverflow", () => {
// 170K total tokens — well above context-output (168K) but below input limit (200K)
const tokens = { input: 166_000, output: 10_000, reasoning: 0, cache: { read: 5_000, write: 0 } }
const withLimit = await SessionCompaction.isOverflow({ tokens, model: withInputLimit })
const withoutLimit = await SessionCompaction.isOverflow({ tokens, model: withoutInputLimit })
const withLimit = yield* compact.isOverflow({ tokens, model: withInputLimit })
const withoutLimit = yield* compact.isOverflow({ tokens, model: withoutInputLimit })
// Both models have identical real capacity — they should agree:
expect(withLimit).toBe(true) // should compact (170K leaves no room for 32K output)
expect(withoutLimit).toBe(true) // correctly compacts (170K > 168K)
},
})
})
}),
),
)
test("returns false when model context limit is 0", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
it.live(
"returns false when model context limit is 0",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const compact = yield* SessionCompaction.Service
const model = createModel({ context: 0, output: 32_000 })
const tokens = { input: 100_000, output: 10_000, reasoning: 0, cache: { read: 0, write: 0 } }
expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(false)
},
})
})
expect(yield* compact.isOverflow({ tokens, model })).toBe(false)
}),
),
)
test("returns false when compaction.auto is disabled", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
compaction: { auto: false },
}),
)
it.live(
"returns false when compaction.auto is disabled",
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const compact = yield* SessionCompaction.Service
const model = createModel({ context: 100_000, output: 32_000 })
const tokens = { input: 75_000, output: 5_000, reasoning: 0, cache: { read: 0, write: 0 } }
expect(yield* compact.isOverflow({ tokens, model })).toBe(false)
}),
{
config: {
compaction: { auto: false },
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const model = createModel({ context: 100_000, output: 32_000 })
const tokens = { input: 75_000, output: 5_000, reasoning: 0, cache: { read: 0, write: 0 } }
expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(false)
},
})
})
),
)
})
describe("session.compaction.create", () => {
test("creates a compaction user message and part", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
it.live(
"creates a compaction user message and part",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const compact = yield* SessionCompaction.Service
const ssn = yield* SessionNs.Service
await SessionCompaction.create({
sessionID: session.id,
const info = yield* ssn.create({})
yield* compact.create({
sessionID: info.id,
agent: "build",
model: ref,
auto: true,
overflow: true,
})
const msgs = await Session.messages({ sessionID: session.id })
const msgs = yield* ssn.messages({ sessionID: info.id })
expect(msgs).toHaveLength(1)
expect(msgs[0].info.role).toBe("user")
expect(msgs[0].parts).toHaveLength(1)
@@ -456,60 +509,190 @@ describe("session.compaction.create", () => {
auto: true,
overflow: true,
})
},
})
})
}),
),
)
})
describe("session.compaction.prune", () => {
test("compacts old completed tool output", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const a = await user(session.id, "first")
const b = await assistant(session.id, a.id, tmp.path)
await tool(session.id, b.id, "bash", "x".repeat(200_000))
await user(session.id, "second")
await user(session.id, "third")
it.live(
"compacts old completed tool output",
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const compact = yield* SessionCompaction.Service
const ssn = yield* SessionNs.Service
const info = yield* ssn.create({})
const a = yield* ssn.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: info.id,
agent: "build",
model: ref,
time: { created: Date.now() },
})
yield* ssn.updatePart({
id: PartID.ascending(),
messageID: a.id,
sessionID: info.id,
type: "text",
text: "first",
})
const b: MessageV2.Assistant = {
id: MessageID.ascending(),
role: "assistant",
sessionID: info.id,
mode: "build",
agent: "build",
path: { cwd: dir, root: dir },
cost: 0,
tokens: {
output: 0,
input: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: ref.modelID,
providerID: ref.providerID,
parentID: a.id,
time: { created: Date.now() },
finish: "end_turn",
}
yield* ssn.updateMessage(b)
yield* ssn.updatePart({
id: PartID.ascending(),
messageID: b.id,
sessionID: info.id,
type: "tool",
callID: crypto.randomUUID(),
tool: "bash",
state: {
status: "completed",
input: {},
output: "x".repeat(200_000),
title: "done",
metadata: {},
time: { start: Date.now(), end: Date.now() },
},
})
for (const text of ["second", "third"]) {
const msg = yield* ssn.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: info.id,
agent: "build",
model: ref,
time: { created: Date.now() },
})
yield* ssn.updatePart({
id: PartID.ascending(),
messageID: msg.id,
sessionID: info.id,
type: "text",
text,
})
}
await SessionCompaction.prune({ sessionID: session.id })
yield* compact.prune({ sessionID: info.id })
const msgs = await Session.messages({ sessionID: session.id })
const msgs = yield* ssn.messages({ sessionID: info.id })
const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool")
expect(part?.type).toBe("tool")
expect(part?.state.status).toBe("completed")
if (part?.type === "tool" && part.state.status === "completed") {
expect(part.state.time.compacted).toBeNumber()
}
},
})
})
}),
),
)
test("skips protected skill tool output", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const a = await user(session.id, "first")
const b = await assistant(session.id, a.id, tmp.path)
await tool(session.id, b.id, "skill", "x".repeat(200_000))
await user(session.id, "second")
await user(session.id, "third")
it.live(
"skips protected skill tool output",
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const compact = yield* SessionCompaction.Service
const ssn = yield* SessionNs.Service
const info = yield* ssn.create({})
const a = yield* ssn.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: info.id,
agent: "build",
model: ref,
time: { created: Date.now() },
})
yield* ssn.updatePart({
id: PartID.ascending(),
messageID: a.id,
sessionID: info.id,
type: "text",
text: "first",
})
const b: MessageV2.Assistant = {
id: MessageID.ascending(),
role: "assistant",
sessionID: info.id,
mode: "build",
agent: "build",
path: { cwd: dir, root: dir },
cost: 0,
tokens: {
output: 0,
input: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: ref.modelID,
providerID: ref.providerID,
parentID: a.id,
time: { created: Date.now() },
finish: "end_turn",
}
yield* ssn.updateMessage(b)
yield* ssn.updatePart({
id: PartID.ascending(),
messageID: b.id,
sessionID: info.id,
type: "tool",
callID: crypto.randomUUID(),
tool: "skill",
state: {
status: "completed",
input: {},
output: "x".repeat(200_000),
title: "done",
metadata: {},
time: { start: Date.now(), end: Date.now() },
},
})
for (const text of ["second", "third"]) {
const msg = yield* ssn.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: info.id,
agent: "build",
model: ref,
time: { created: Date.now() },
})
yield* ssn.updatePart({
id: PartID.ascending(),
messageID: msg.id,
sessionID: info.id,
type: "text",
text,
})
}
await SessionCompaction.prune({ sessionID: session.id })
yield* compact.prune({ sessionID: info.id })
const msgs = await Session.messages({ sessionID: session.id })
const msgs = yield* ssn.messages({ sessionID: info.id })
const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool")
expect(part?.type).toBe("tool")
if (part?.type === "tool" && part.state.status === "completed") {
expect(part.state.time.compacted).toBeUndefined()
}
},
})
})
}),
),
)
})
describe("session.compaction.process", () => {
@@ -518,12 +701,12 @@ describe("session.compaction.process", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const msg = await user(session.id, "hello")
const reply = await assistant(session.id, msg.id, tmp.path)
const rt = runtime("continue")
try {
const msgs = await Session.messages({ sessionID: session.id })
const msgs = await svc.messages({ sessionID: session.id })
await expect(
rt.runPromise(
SessionCompaction.Service.use((svc) =>
@@ -548,9 +731,9 @@ describe("session.compaction.process", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const msg = await user(session.id, "hello")
const msgs = await Session.messages({ sessionID: session.id })
const msgs = await svc.messages({ sessionID: session.id })
const done = defer()
let seen = false
const rt = runtime("continue", Plugin.defaultLayer, wide())
@@ -598,11 +781,11 @@ describe("session.compaction.process", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const msg = await user(session.id, "hello")
const rt = runtime("compact", Plugin.defaultLayer, wide())
try {
const msgs = await Session.messages({ sessionID: session.id })
const msgs = await svc.messages({ sessionID: session.id })
const result = await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
@@ -614,7 +797,7 @@ describe("session.compaction.process", () => {
),
)
const summary = (await Session.messages({ sessionID: session.id })).find(
const summary = (await svc.messages({ sessionID: session.id })).find(
(msg) => msg.info.role === "assistant" && msg.info.summary,
)
@@ -636,11 +819,11 @@ describe("session.compaction.process", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const msg = await user(session.id, "hello")
const rt = runtime("continue", Plugin.defaultLayer, wide())
try {
const msgs = await Session.messages({ sessionID: session.id })
const msgs = await svc.messages({ sessionID: session.id })
const result = await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
@@ -652,7 +835,7 @@ describe("session.compaction.process", () => {
),
)
const all = await Session.messages({ sessionID: session.id })
const all = await svc.messages({ sessionID: session.id })
const last = all.at(-1)
expect(result).toBe("continue")
@@ -671,15 +854,58 @@ describe("session.compaction.process", () => {
})
})
test("allows plugins to disable synthetic continue prompt", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
const msg = await user(session.id, "hello")
const rt = runtime("continue", autocontinue(false), wide())
try {
const msgs = await svc.messages({ sessionID: session.id })
const result = await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: msg.id,
messages: msgs,
sessionID: session.id,
auto: true,
}),
),
)
const all = await svc.messages({ sessionID: session.id })
const last = all.at(-1)
expect(result).toBe("continue")
expect(last?.info.role).toBe("assistant")
expect(
all.some(
(msg) =>
msg.info.role === "user" &&
msg.parts.some(
(part) =>
part.type === "text" && part.synthetic && part.text.includes("Continue if you have next steps"),
),
),
).toBe(false)
} finally {
await rt.dispose()
}
},
})
})
test("replays the prior user turn on overflow when earlier context exists", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
await user(session.id, "root")
const replay = await user(session.id, "image")
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
messageID: replay.id,
sessionID: session.id,
@@ -691,7 +917,7 @@ describe("session.compaction.process", () => {
const msg = await user(session.id, "current")
const rt = runtime("continue", Plugin.defaultLayer, wide())
try {
const msgs = await Session.messages({ sessionID: session.id })
const msgs = await svc.messages({ sessionID: session.id })
const result = await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
@@ -704,7 +930,7 @@ describe("session.compaction.process", () => {
),
)
const last = (await Session.messages({ sessionID: session.id })).at(-1)
const last = (await svc.messages({ sessionID: session.id })).at(-1)
expect(result).toBe("continue")
expect(last?.info.role).toBe("user")
@@ -724,13 +950,13 @@ describe("session.compaction.process", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
await user(session.id, "earlier")
const msg = await user(session.id, "current")
const rt = runtime("continue", Plugin.defaultLayer, wide())
try {
const msgs = await Session.messages({ sessionID: session.id })
const msgs = await svc.messages({ sessionID: session.id })
const result = await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
@@ -743,7 +969,7 @@ describe("session.compaction.process", () => {
),
)
const last = (await Session.messages({ sessionID: session.id })).at(-1)
const last = (await svc.messages({ sessionID: session.id })).at(-1)
expect(result).toBe("continue")
expect(last?.info.role).toBe("user")
@@ -784,9 +1010,9 @@ describe("session.compaction.process", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const msg = await user(session.id, "hello")
const msgs = await Session.messages({ sessionID: session.id })
const msgs = await svc.messages({ sessionID: session.id })
const abort = new AbortController()
const rt = liveRuntime(stub.layer, wide())
let off: (() => void) | undefined
@@ -858,9 +1084,9 @@ describe("session.compaction.process", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const msg = await user(session.id, "hello")
const msgs = await Session.messages({ sessionID: session.id })
const msgs = await svc.messages({ sessionID: session.id })
const abort = new AbortController()
const rt = runtime("continue", plugin(ready), wide())
let run: Promise<"continue" | "stop"> | undefined
@@ -895,7 +1121,7 @@ describe("session.compaction.process", () => {
abort.abort()
expect(await run).toBe("stop")
const all = await Session.messages({ sessionID: session.id })
const all = await svc.messages({ sessionID: session.id })
expect(all.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(false)
} finally {
abort.abort()
@@ -960,11 +1186,11 @@ describe("session.compaction.process", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const msg = await user(session.id, "hello")
const rt = liveRuntime(stub.layer, wide())
try {
const msgs = await Session.messages({ sessionID: session.id })
const msgs = await svc.messages({ sessionID: session.id })
await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
@@ -976,7 +1202,7 @@ describe("session.compaction.process", () => {
),
)
const summary = (await Session.messages({ sessionID: session.id })).find(
const summary = (await svc.messages({ sessionID: session.id })).find(
(item) => item.info.role === "assistant" && item.info.summary,
)
@@ -1006,10 +1232,10 @@ describe("util.token.estimate", () => {
})
})
describe("session.getUsage", () => {
describe("SessionNs.getUsage", () => {
test("normalizes standard usage to token format", () => {
const model = createModel({ context: 100_000, output: 32_000 })
const result = Session.getUsage({
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 1000,
@@ -1036,7 +1262,7 @@ describe("session.getUsage", () => {
test("extracts cached tokens to cache.read", () => {
const model = createModel({ context: 100_000, output: 32_000 })
const result = Session.getUsage({
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 1000,
@@ -1060,7 +1286,7 @@ describe("session.getUsage", () => {
test("handles anthropic cache write metadata", () => {
const model = createModel({ context: 100_000, output: 32_000 })
const result = Session.getUsage({
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 1000,
@@ -1089,7 +1315,7 @@ describe("session.getUsage", () => {
test("subtracts cached tokens for anthropic provider", () => {
const model = createModel({ context: 100_000, output: 32_000 })
// AI SDK v6 normalizes inputTokens to include cached tokens for all providers
const result = Session.getUsage({
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 1000,
@@ -1116,7 +1342,7 @@ describe("session.getUsage", () => {
test("separates reasoning tokens from output tokens", () => {
const model = createModel({ context: 100_000, output: 32_000 })
const result = Session.getUsage({
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 1000,
@@ -1150,7 +1376,7 @@ describe("session.getUsage", () => {
cache: { read: 0, write: 0 },
},
})
const result = Session.getUsage({
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 0,
@@ -1175,7 +1401,7 @@ describe("session.getUsage", () => {
test("handles undefined optional values gracefully", () => {
const model = createModel({ context: 100_000, output: 32_000 })
const result = Session.getUsage({
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 0,
@@ -1211,7 +1437,7 @@ describe("session.getUsage", () => {
cache: { read: 0.3, write: 3.75 },
},
})
const result = Session.getUsage({
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 1_000_000,
@@ -1252,7 +1478,7 @@ describe("session.getUsage", () => {
},
}
if (npm === "@ai-sdk/amazon-bedrock") {
const result = Session.getUsage({
const result = SessionNs.getUsage({
model,
usage,
metadata: {
@@ -1273,7 +1499,7 @@ describe("session.getUsage", () => {
return
}
const result = Session.getUsage({
const result = SessionNs.getUsage({
model,
usage,
metadata: {
@@ -1294,7 +1520,7 @@ describe("session.getUsage", () => {
test("extracts cache write tokens from vertex metadata key", () => {
const model = createModel({ context: 100_000, output: 32_000, npm: "@ai-sdk/google-vertex/anthropic" })
const result = Session.getUsage({
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 1000,
@@ -1,7 +1,8 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import path from "path"
import { Instance } from "../../src/project/instance"
import { Session } from "../../src/session"
import { Session as SessionNs } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
@@ -10,12 +11,32 @@ import { Log } from "../../src/util/log"
const root = path.join(__dirname, "../..")
Log.init({ print: false })
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
}
const svc = {
...SessionNs,
create(input?: SessionNs.CreateInput) {
return run(SessionNs.Service.use((svc) => svc.create(input)))
},
remove(id: SessionID) {
return run(SessionNs.Service.use((svc) => svc.remove(id)))
},
updateMessage<T extends MessageV2.Info>(msg: T) {
return run(SessionNs.Service.use((svc) => svc.updateMessage(msg)))
},
updatePart<T extends MessageV2.Part>(part: T) {
return run(SessionNs.Service.use((svc) => svc.updatePart(part)))
},
}
async function fill(sessionID: SessionID, count: number, time = (i: number) => Date.now() + i) {
const ids = [] as MessageID[]
for (let i = 0; i < count; i++) {
const id = MessageID.ascending()
ids.push(id)
await Session.updateMessage({
await svc.updateMessage({
id,
sessionID,
role: "user",
@@ -25,7 +46,7 @@ async function fill(sessionID: SessionID, count: number, time = (i: number) => D
tools: {},
mode: "",
} as unknown as MessageV2.Info)
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID,
messageID: id,
@@ -38,7 +59,7 @@ async function fill(sessionID: SessionID, count: number, time = (i: number) => D
async function addUser(sessionID: SessionID, text?: string) {
const id = MessageID.ascending()
await Session.updateMessage({
await svc.updateMessage({
id,
sessionID,
role: "user",
@@ -49,7 +70,7 @@ async function addUser(sessionID: SessionID, text?: string) {
mode: "",
} as unknown as MessageV2.Info)
if (text) {
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID,
messageID: id,
@@ -66,7 +87,7 @@ async function addAssistant(
opts?: { summary?: boolean; finish?: string; error?: MessageV2.Assistant["error"] },
) {
const id = MessageID.ascending()
await Session.updateMessage({
await svc.updateMessage({
id,
sessionID,
role: "assistant",
@@ -87,7 +108,7 @@ async function addAssistant(
}
async function addCompactionPart(sessionID: SessionID, messageID: MessageID) {
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID,
messageID,
@@ -101,14 +122,14 @@ describe("MessageV2.page", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
await fill(session.id, 2)
const result = MessageV2.page({ sessionID: session.id, limit: 10 })
expect(result).toBeDefined()
expect(result.items).toBeArray()
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -117,7 +138,7 @@ describe("MessageV2.page", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 6)
const a = MessageV2.page({ sessionID: session.id, limit: 2 })
@@ -136,7 +157,7 @@ describe("MessageV2.page", () => {
expect(c.more).toBe(false)
expect(c.cursor).toBeUndefined()
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -145,13 +166,13 @@ describe("MessageV2.page", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 4)
const result = MessageV2.page({ sessionID: session.id, limit: 4 })
expect(result.items.map((item) => item.info.id)).toEqual(ids)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -160,14 +181,14 @@ describe("MessageV2.page", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const result = MessageV2.page({ sessionID: session.id, limit: 10 })
expect(result.items).toEqual([])
expect(result.more).toBe(false)
expect(result.cursor).toBeUndefined()
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -186,7 +207,7 @@ describe("MessageV2.page", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 3)
const result = MessageV2.page({ sessionID: session.id, limit: 3 })
@@ -194,7 +215,7 @@ describe("MessageV2.page", () => {
expect(result.more).toBe(false)
expect(result.cursor).toBeUndefined()
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -203,7 +224,7 @@ describe("MessageV2.page", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 5)
const result = MessageV2.page({ sessionID: session.id, limit: 1 })
@@ -211,7 +232,7 @@ describe("MessageV2.page", () => {
expect(result.items[0].info.id).toBe(ids[ids.length - 1])
expect(result.more).toBe(true)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -220,10 +241,10 @@ describe("MessageV2.page", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const [id] = await fill(session.id, 1)
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: id,
@@ -235,7 +256,7 @@ describe("MessageV2.page", () => {
expect(result.items).toHaveLength(1)
expect(result.items[0].parts).toHaveLength(2)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -244,7 +265,7 @@ describe("MessageV2.page", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 4, (i) => 1000.5 + i)
const a = MessageV2.page({ sessionID: session.id, limit: 2 })
@@ -253,7 +274,7 @@ describe("MessageV2.page", () => {
expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(-2))
expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2))
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -262,7 +283,7 @@ describe("MessageV2.page", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 4, () => 1000)
const a = MessageV2.page({ sessionID: session.id, limit: 2 })
@@ -273,7 +294,7 @@ describe("MessageV2.page", () => {
expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2))
expect(b.more).toBe(false)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -282,8 +303,8 @@ describe("MessageV2.page", () => {
await Instance.provide({
directory: root,
fn: async () => {
const a = await Session.create({})
const b = await Session.create({})
const a = await svc.create({})
const b = await svc.create({})
await fill(a.id, 3)
await fill(b.id, 2)
@@ -294,8 +315,8 @@ describe("MessageV2.page", () => {
expect(resultA.items.every((item) => item.info.sessionID === a.id)).toBe(true)
expect(resultB.items.every((item) => item.info.sessionID === b.id)).toBe(true)
await Session.remove(a.id)
await Session.remove(b.id)
await svc.remove(a.id)
await svc.remove(b.id)
},
})
})
@@ -304,7 +325,7 @@ describe("MessageV2.page", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 10)
const result = MessageV2.page({ sessionID: session.id, limit: 100 })
@@ -313,7 +334,7 @@ describe("MessageV2.page", () => {
expect(result.more).toBe(false)
expect(result.cursor).toBeUndefined()
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -324,13 +345,13 @@ describe("MessageV2.stream", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 5)
const items = Array.from(MessageV2.stream(session.id))
expect(items.map((item) => item.info.id)).toEqual(ids.slice().reverse())
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -339,12 +360,12 @@ describe("MessageV2.stream", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const items = Array.from(MessageV2.stream(session.id))
expect(items).toHaveLength(0)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -353,14 +374,14 @@ describe("MessageV2.stream", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 1)
const items = Array.from(MessageV2.stream(session.id))
expect(items).toHaveLength(1)
expect(items[0].info.id).toBe(ids[0])
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -369,7 +390,7 @@ describe("MessageV2.stream", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
await fill(session.id, 3)
const items = Array.from(MessageV2.stream(session.id))
@@ -378,7 +399,7 @@ describe("MessageV2.stream", () => {
expect(item.parts[0].type).toBe("text")
}
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -387,7 +408,7 @@ describe("MessageV2.stream", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 60)
const items = Array.from(MessageV2.stream(session.id))
@@ -395,7 +416,7 @@ describe("MessageV2.stream", () => {
expect(items[0].info.id).toBe(ids[ids.length - 1])
expect(items[59].info.id).toBe(ids[0])
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -404,7 +425,7 @@ describe("MessageV2.stream", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
await fill(session.id, 1)
const gen = MessageV2.stream(session.id)
@@ -414,7 +435,7 @@ describe("MessageV2.stream", () => {
expect(first).toHaveProperty("done")
expect(first.done).toBe(false)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -425,7 +446,7 @@ describe("MessageV2.parts", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const [id] = await fill(session.id, 1)
const result = MessageV2.parts(id)
@@ -433,7 +454,7 @@ describe("MessageV2.parts", () => {
expect(result[0].type).toBe("text")
expect((result[0] as MessageV2.TextPart).text).toBe("m0")
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -442,13 +463,13 @@ describe("MessageV2.parts", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const id = await addUser(session.id)
const result = MessageV2.parts(id)
expect(result).toEqual([])
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -457,17 +478,17 @@ describe("MessageV2.parts", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const [id] = await fill(session.id, 1)
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: id,
type: "text",
text: "second",
})
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: id,
@@ -481,7 +502,7 @@ describe("MessageV2.parts", () => {
expect((result[1] as MessageV2.TextPart).text).toBe("second")
expect((result[2] as MessageV2.TextPart).text).toBe("third")
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -490,7 +511,7 @@ describe("MessageV2.parts", () => {
await Instance.provide({
directory: root,
fn: async () => {
await Session.create({})
await svc.create({})
const result = MessageV2.parts(MessageID.ascending())
expect(result).toEqual([])
},
@@ -501,14 +522,14 @@ describe("MessageV2.parts", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const [id] = await fill(session.id, 1)
const result = MessageV2.parts(id)
expect(result[0].sessionID).toBe(session.id)
expect(result[0].messageID).toBe(id)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -519,7 +540,7 @@ describe("MessageV2.get", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const [id] = await fill(session.id, 1)
const result = MessageV2.get({ sessionID: session.id, messageID: id })
@@ -529,7 +550,7 @@ describe("MessageV2.get", () => {
expect(result.parts).toHaveLength(1)
expect((result.parts[0] as MessageV2.TextPart).text).toBe("m0")
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -538,13 +559,13 @@ describe("MessageV2.get", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
expect(() => MessageV2.get({ sessionID: session.id, messageID: MessageID.ascending() })).toThrow(
"NotFoundError",
)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -553,16 +574,16 @@ describe("MessageV2.get", () => {
await Instance.provide({
directory: root,
fn: async () => {
const a = await Session.create({})
const b = await Session.create({})
const a = await svc.create({})
const b = await svc.create({})
const [id] = await fill(a.id, 1)
expect(() => MessageV2.get({ sessionID: b.id, messageID: id })).toThrow("NotFoundError")
const result = MessageV2.get({ sessionID: a.id, messageID: id })
expect(result.info.id).toBe(id)
await Session.remove(a.id)
await Session.remove(b.id)
await svc.remove(a.id)
await svc.remove(b.id)
},
})
})
@@ -571,10 +592,10 @@ describe("MessageV2.get", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const [id] = await fill(session.id, 1)
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: id,
@@ -585,7 +606,7 @@ describe("MessageV2.get", () => {
const result = MessageV2.get({ sessionID: session.id, messageID: id })
expect(result.parts).toHaveLength(2)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -594,11 +615,11 @@ describe("MessageV2.get", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const uid = await addUser(session.id, "hello")
const aid = await addAssistant(session.id, uid)
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: aid,
@@ -611,7 +632,7 @@ describe("MessageV2.get", () => {
expect(result.parts).toHaveLength(1)
expect((result.parts[0] as MessageV2.TextPart).text).toBe("response")
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -620,14 +641,14 @@ describe("MessageV2.get", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const id = await addUser(session.id)
const result = MessageV2.get({ sessionID: session.id, messageID: id })
expect(result.info.id).toBe(id)
expect(result.parts).toEqual([])
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -638,7 +659,7 @@ describe("MessageV2.filterCompacted", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 5)
const result = MessageV2.filterCompacted(MessageV2.stream(session.id))
@@ -646,7 +667,7 @@ describe("MessageV2.filterCompacted", () => {
// reversed from newest-first to chronological
expect(result.map((item) => item.info.id)).toEqual(ids)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -655,13 +676,13 @@ describe("MessageV2.filterCompacted", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
// Chronological: u1(+compaction part), a1(summary, parentID=u1), u2, a2
// Stream (newest first): a2, u2, a1(adds u1 to completed), u1(in completed + compaction) -> break
const u1 = await addUser(session.id, "first question")
const a1 = await addAssistant(session.id, u1, { summary: true, finish: "end_turn" })
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: a1,
@@ -672,7 +693,7 @@ describe("MessageV2.filterCompacted", () => {
const u2 = await addUser(session.id, "new question")
const a2 = await addAssistant(session.id, u2)
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: a2,
@@ -685,7 +706,7 @@ describe("MessageV2.filterCompacted", () => {
expect(result[0].info.id).toBe(u1)
expect(result.length).toBe(4)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -699,7 +720,7 @@ describe("MessageV2.filterCompacted", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const u1 = await addUser(session.id, "hello")
await addCompactionPart(session.id, u1)
@@ -708,7 +729,7 @@ describe("MessageV2.filterCompacted", () => {
const result = MessageV2.filterCompacted(MessageV2.stream(session.id))
expect(result).toHaveLength(2)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -717,7 +738,7 @@ describe("MessageV2.filterCompacted", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const u1 = await addUser(session.id, "hello")
await addCompactionPart(session.id, u1)
@@ -733,7 +754,7 @@ describe("MessageV2.filterCompacted", () => {
// Error assistant doesn't add to completed, so compaction boundary never triggers
expect(result).toHaveLength(3)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -742,7 +763,7 @@ describe("MessageV2.filterCompacted", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const u1 = await addUser(session.id, "hello")
await addCompactionPart(session.id, u1)
@@ -754,7 +775,7 @@ describe("MessageV2.filterCompacted", () => {
const result = MessageV2.filterCompacted(MessageV2.stream(session.id))
expect(result).toHaveLength(3)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -808,7 +829,7 @@ describe("MessageV2 consistency", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
await fill(session.id, 3)
const paged = MessageV2.page({ sessionID: session.id, limit: 10 })
@@ -818,7 +839,7 @@ describe("MessageV2 consistency", () => {
expect(got.parts).toEqual(item.parts)
}
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -827,14 +848,14 @@ describe("MessageV2 consistency", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const [id] = await fill(session.id, 1)
const got = MessageV2.get({ sessionID: session.id, messageID: id })
const standalone = MessageV2.parts(id)
expect(got.parts).toEqual(standalone)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -843,7 +864,7 @@ describe("MessageV2 consistency", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
await fill(session.id, 7)
const streamed = Array.from(MessageV2.stream(session.id))
@@ -861,7 +882,7 @@ describe("MessageV2 consistency", () => {
expect(streamed.map((m) => m.info.id)).toEqual(paged.map((m) => m.info.id))
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -870,7 +891,7 @@ describe("MessageV2 consistency", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 4)
const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id))
@@ -878,7 +899,7 @@ describe("MessageV2 consistency", () => {
expect(filtered.map((m) => m.info.id)).toEqual(all.map((m) => m.info.id))
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -14,6 +14,7 @@ import { MCP } from "../../src/mcp"
import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin"
import { Provider as ProviderSvc } from "../../src/provider/provider"
import { Env } from "../../src/env"
import type { Provider } from "../../src/provider/provider"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Question } from "../../src/question"
@@ -167,6 +168,7 @@ function makeHttp() {
Session.defaultLayer,
Snapshot.defaultLayer,
LLM.defaultLayer,
Env.defaultLayer,
AgentSvc.defaultLayer,
Command.defaultLayer,
Permission.defaultLayer,
@@ -208,7 +210,7 @@ function makeHttp() {
Layer.provide(SystemPrompt.defaultLayer),
Layer.provideMerge(deps),
),
)
).pipe(Layer.provide(summary))
}
const it = testEffect(makeHttp())
@@ -382,25 +384,23 @@ it.live("loop calls LLM and returns assistant message", () =>
it.live("static loop returns assistant text through local provider", () =>
provideTmpdirServer(
Effect.fnUntraced(function* ({ llm }) {
const session = yield* Effect.promise(() =>
Session.create({
title: "Prompt provider",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
}),
)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({
title: "Prompt provider",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
})
yield* Effect.promise(() =>
SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello" }],
}),
)
yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello" }],
})
yield* llm.text("world")
const result = yield* Effect.promise(() => SessionPrompt.loop({ sessionID: session.id }))
const result = yield* prompt.loop({ sessionID: session.id })
expect(result.info.role).toBe("assistant")
expect(result.parts.some((part) => part.type === "text" && part.text === "world")).toBe(true)
expect(yield* llm.hits).toHaveLength(1)
@@ -413,40 +413,36 @@ it.live("static loop returns assistant text through local provider", () =>
it.live("static loop consumes queued replies across turns", () =>
provideTmpdirServer(
Effect.fnUntraced(function* ({ llm }) {
const session = yield* Effect.promise(() =>
Session.create({
title: "Prompt provider turns",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
}),
)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({
title: "Prompt provider turns",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
})
yield* Effect.promise(() =>
SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello one" }],
}),
)
yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello one" }],
})
yield* llm.text("world one")
const first = yield* Effect.promise(() => SessionPrompt.loop({ sessionID: session.id }))
const first = yield* prompt.loop({ sessionID: session.id })
expect(first.info.role).toBe("assistant")
expect(first.parts.some((part) => part.type === "text" && part.text === "world one")).toBe(true)
yield* Effect.promise(() =>
SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello two" }],
}),
)
yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello two" }],
})
yield* llm.text("world two")
const second = yield* Effect.promise(() => SessionPrompt.loop({ sessionID: session.id }))
const second = yield* prompt.loop({ sessionID: session.id })
expect(second.info.role).toBe("assistant")
expect(second.parts.some((part) => part.type === "text" && part.text === "world two")).toBe(true)
+267 -199
View File
@@ -2,6 +2,7 @@ import path from "path"
import { describe, expect, test } from "bun:test"
import { NamedError } from "@opencode-ai/util/error"
import { fileURLToPath } from "url"
import { Effect, Layer } from "effect"
import { Instance } from "../../src/project/instance"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Session } from "../../src/session"
@@ -12,6 +13,12 @@ import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
function run<A, E>(fx: Effect.Effect<A, E, SessionPrompt.Service | Session.Service>) {
return Effect.runPromise(
fx.pipe(Effect.scoped, Effect.provide(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))),
)
}
function defer<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
const promise = new Promise<T>((done) => {
@@ -104,34 +111,39 @@ describe("session.prompt missing file", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
fn: () =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({})
const missing = path.join(tmp.path, "does-not-exist.ts")
const msg = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [
{ type: "text", text: "please review @does-not-exist.ts" },
{
type: "file",
mime: "text/plain",
url: `file://${missing}`,
filename: "does-not-exist.ts",
},
],
})
const missing = path.join(tmp.path, "does-not-exist.ts")
const msg = yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [
{ type: "text", text: "please review @does-not-exist.ts" },
{
type: "file",
mime: "text/plain",
url: `file://${missing}`,
filename: "does-not-exist.ts",
},
],
})
if (msg.info.role !== "user") throw new Error("expected user message")
if (msg.info.role !== "user") throw new Error("expected user message")
const hasFailure = msg.parts.some(
(part) => part.type === "text" && part.synthetic && part.text.includes("Read tool failed to read"),
)
expect(hasFailure).toBe(true)
const hasFailure = msg.parts.some(
(part) => part.type === "text" && part.synthetic && part.text.includes("Read tool failed to read"),
)
expect(hasFailure).toBe(true)
await Session.remove(session.id)
},
yield* sessions.remove(session.id)
}),
),
})
})
@@ -149,39 +161,44 @@ describe("session.prompt missing file", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
fn: () =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({})
const missing = path.join(tmp.path, "still-missing.ts")
const msg = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [
{
type: "file",
mime: "text/plain",
url: `file://${missing}`,
filename: "still-missing.ts",
},
{ type: "text", text: "after-file" },
],
})
const missing = path.join(tmp.path, "still-missing.ts")
const msg = yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [
{
type: "file",
mime: "text/plain",
url: `file://${missing}`,
filename: "still-missing.ts",
},
{ type: "text", text: "after-file" },
],
})
if (msg.info.role !== "user") throw new Error("expected user message")
if (msg.info.role !== "user") throw new Error("expected user message")
const stored = await MessageV2.get({
sessionID: session.id,
messageID: msg.info.id,
})
const text = stored.parts.filter((part) => part.type === "text").map((part) => part.text)
const stored = MessageV2.get({
sessionID: session.id,
messageID: msg.info.id,
})
const text = stored.parts.filter((part) => part.type === "text").map((part) => part.text)
expect(text[0]?.startsWith("Called the Read tool with the following input:")).toBe(true)
expect(text[1]?.includes("Read tool failed to read")).toBe(true)
expect(text[2]).toBe("after-file")
expect(text[0]?.startsWith("Called the Read tool with the following input:")).toBe(true)
expect(text[1]?.includes("Read tool failed to read")).toBe(true)
expect(text[2]).toBe("after-file")
await Session.remove(session.id)
},
yield* sessions.remove(session.id)
}),
),
})
})
})
@@ -197,31 +214,36 @@ describe("session.prompt special characters", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const template = "Read @file#name.txt"
const parts = await SessionPrompt.resolvePromptParts(template)
const fileParts = parts.filter((part) => part.type === "file")
fn: () =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({})
const template = "Read @file#name.txt"
const parts = yield* prompt.resolvePromptParts(template)
const fileParts = parts.filter((part) => part.type === "file")
expect(fileParts.length).toBe(1)
expect(fileParts[0].filename).toBe("file#name.txt")
expect(fileParts[0].url).toContain("%23")
expect(fileParts.length).toBe(1)
expect(fileParts[0].filename).toBe("file#name.txt")
expect(fileParts[0].url).toContain("%23")
const decodedPath = fileURLToPath(fileParts[0].url)
expect(decodedPath).toBe(path.join(tmp.path, "file#name.txt"))
const decodedPath = fileURLToPath(fileParts[0].url)
expect(decodedPath).toBe(path.join(tmp.path, "file#name.txt"))
const message = await SessionPrompt.prompt({
sessionID: session.id,
parts,
noReply: true,
})
const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id })
const textParts = stored.parts.filter((part) => part.type === "text")
const hasContent = textParts.some((part) => part.text.includes("special content"))
expect(hasContent).toBe(true)
const message = yield* prompt.prompt({
sessionID: session.id,
parts,
noReply: true,
})
const stored = MessageV2.get({ sessionID: session.id, messageID: message.info.id })
const textParts = stored.parts.filter((part) => part.type === "text")
const hasContent = textParts.some((part) => part.text.includes("special content"))
expect(hasContent).toBe(true)
await Session.remove(session.id)
},
yield* sessions.remove(session.id)
}),
),
})
})
})
@@ -273,21 +295,26 @@ describe("session.prompt regression", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({ title: "Prompt regression" })
const result = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
parts: [{ type: "text", text: "Where is SessionProcessor?" }],
})
fn: () =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Prompt regression" })
const result = yield* prompt.prompt({
sessionID: session.id,
agent: "build",
parts: [{ type: "text", text: "Where is SessionProcessor?" }],
})
expect(result.info.role).toBe("assistant")
expect(result.parts.some((part) => part.type === "text" && part.text.includes("processor.ts"))).toBe(true)
expect(result.info.role).toBe("assistant")
expect(result.parts.some((part) => part.type === "text" && part.text.includes("processor.ts"))).toBe(true)
const msgs = await Session.messages({ sessionID: session.id })
expect(msgs.filter((msg) => msg.info.role === "assistant")).toHaveLength(1)
expect(calls).toBe(1)
},
const msgs = yield* sessions.messages({ sessionID: session.id })
expect(msgs.filter((msg) => msg.info.role === "assistant")).toHaveLength(1)
expect(calls).toBe(1)
}),
),
})
} finally {
server.stop(true)
@@ -342,36 +369,45 @@ describe("session.prompt regression", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({ title: "Prompt cancel regression" })
const run = SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
parts: [{ type: "text", text: "Cancel me" }],
})
fn: () =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Prompt cancel regression" })
const task = Effect.runPromise(
prompt.prompt({
sessionID: session.id,
agent: "build",
parts: [{ type: "text", text: "Cancel me" }],
}),
)
await ready.promise
await SessionPrompt.cancel(session.id)
yield* Effect.promise(() => ready.promise)
yield* prompt.cancel(session.id)
const result = await Promise.race([
run,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("timed out waiting for cancel")), 1000),
),
])
const result = yield* Effect.promise(() =>
Promise.race([
task,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("timed out waiting for cancel")), 1000),
),
]),
)
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.error?.name).toBe("MessageAbortedError")
}
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.error?.name).toBe("MessageAbortedError")
}
const msgs = await Session.messages({ sessionID: session.id })
const last = msgs.findLast((msg) => msg.info.role === "assistant")
expect(last?.info.role).toBe("assistant")
if (last?.info.role === "assistant") {
expect(last.info.error?.name).toBe("MessageAbortedError")
}
},
const msgs = yield* sessions.messages({ sessionID: session.id })
const last = msgs.findLast((msg) => msg.info.role === "assistant")
expect(last?.info.role).toBe("assistant")
if (last?.info.role === "assistant") {
expect(last.info.error?.name).toBe("MessageAbortedError")
}
}),
),
})
} finally {
server.stop(true)
@@ -399,45 +435,50 @@ describe("session.prompt agent variant", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
fn: () =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({})
const other = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
model: { providerID: ProviderID.make("opencode"), modelID: ModelID.make("kimi-k2.5-free") },
noReply: true,
parts: [{ type: "text", text: "hello" }],
})
if (other.info.role !== "user") throw new Error("expected user message")
expect(other.info.model.variant).toBeUndefined()
const other = yield* prompt.prompt({
sessionID: session.id,
agent: "build",
model: { providerID: ProviderID.make("opencode"), modelID: ModelID.make("kimi-k2.5-free") },
noReply: true,
parts: [{ type: "text", text: "hello" }],
})
if (other.info.role !== "user") throw new Error("expected user message")
expect(other.info.model.variant).toBeUndefined()
const match = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello again" }],
})
if (match.info.role !== "user") throw new Error("expected user message")
expect(match.info.model).toEqual({
providerID: ProviderID.make("openai"),
modelID: ModelID.make("gpt-5.2"),
variant: "xhigh",
})
expect(match.info.model.variant).toBe("xhigh")
const match = yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello again" }],
})
if (match.info.role !== "user") throw new Error("expected user message")
expect(match.info.model).toEqual({
providerID: ProviderID.make("openai"),
modelID: ModelID.make("gpt-5.2"),
variant: "xhigh",
})
expect(match.info.model.variant).toBe("xhigh")
const override = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
variant: "high",
parts: [{ type: "text", text: "hello third" }],
})
if (override.info.role !== "user") throw new Error("expected user message")
expect(override.info.model.variant).toBe("high")
const override = yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
variant: "high",
parts: [{ type: "text", text: "hello third" }],
})
if (override.info.role !== "user") throw new Error("expected user message")
expect(override.info.model.variant).toBe("high")
await Session.remove(session.id)
},
yield* sessions.remove(session.id)
}),
),
})
} finally {
if (prev === undefined) delete process.env.OPENAI_API_KEY
@@ -451,24 +492,33 @@ describe("session.agent-resolution", () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const err = await SessionPrompt.prompt({
sessionID: session.id,
agent: "nonexistent-agent-xyz",
noReply: true,
parts: [{ type: "text", text: "hello" }],
}).then(
() => undefined,
(e) => e,
)
expect(err).toBeDefined()
expect(err).not.toBeInstanceOf(TypeError)
expect(NamedError.Unknown.isInstance(err)).toBe(true)
if (NamedError.Unknown.isInstance(err)) {
expect(err.data.message).toContain('Agent not found: "nonexistent-agent-xyz"')
}
},
fn: () =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({})
const err = yield* Effect.promise(() =>
Effect.runPromise(
prompt.prompt({
sessionID: session.id,
agent: "nonexistent-agent-xyz",
noReply: true,
parts: [{ type: "text", text: "hello" }],
}),
).then(
() => undefined,
(e) => e,
),
)
expect(err).toBeDefined()
expect(err).not.toBeInstanceOf(TypeError)
expect(NamedError.Unknown.isInstance(err)).toBe(true)
if (NamedError.Unknown.isInstance(err)) {
expect(err.data.message).toContain('Agent not found: "nonexistent-agent-xyz"')
}
}),
),
})
}, 30000)
@@ -476,22 +526,31 @@ describe("session.agent-resolution", () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const err = await SessionPrompt.prompt({
sessionID: session.id,
agent: "nonexistent-agent-xyz",
noReply: true,
parts: [{ type: "text", text: "hello" }],
}).then(
() => undefined,
(e) => e,
)
expect(NamedError.Unknown.isInstance(err)).toBe(true)
if (NamedError.Unknown.isInstance(err)) {
expect(err.data.message).toContain("build")
}
},
fn: () =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({})
const err = yield* Effect.promise(() =>
Effect.runPromise(
prompt.prompt({
sessionID: session.id,
agent: "nonexistent-agent-xyz",
noReply: true,
parts: [{ type: "text", text: "hello" }],
}),
).then(
() => undefined,
(e) => e,
),
)
expect(NamedError.Unknown.isInstance(err)).toBe(true)
if (NamedError.Unknown.isInstance(err)) {
expect(err.data.message).toContain("build")
}
}),
),
})
}, 30000)
@@ -499,24 +558,33 @@ describe("session.agent-resolution", () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const err = await SessionPrompt.command({
sessionID: session.id,
command: "nonexistent-command-xyz",
arguments: "",
}).then(
() => undefined,
(e) => e,
)
expect(err).toBeDefined()
expect(err).not.toBeInstanceOf(TypeError)
expect(NamedError.Unknown.isInstance(err)).toBe(true)
if (NamedError.Unknown.isInstance(err)) {
expect(err.data.message).toContain('Command not found: "nonexistent-command-xyz"')
expect(err.data.message).toContain("init")
}
},
fn: () =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({})
const err = yield* Effect.promise(() =>
Effect.runPromise(
prompt.command({
sessionID: session.id,
command: "nonexistent-command-xyz",
arguments: "",
}),
).then(
() => undefined,
(e) => e,
),
)
expect(err).toBeDefined()
expect(err).not.toBeInstanceOf(TypeError)
expect(NamedError.Unknown.isInstance(err)).toBe(true)
if (NamedError.Unknown.isInstance(err)) {
expect(err.data.message).toContain('Command not found: "nonexistent-command-xyz"')
expect(err.data.message).toContain("init")
}
}),
),
})
}, 30000)
})
+46 -30
View File
@@ -1,43 +1,62 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Session } from "../../src/session"
import { Session as SessionNs } from "../../src/session"
import { Bus } from "../../src/bus"
import { Log } from "../../src/util/log"
import { Instance } from "../../src/project/instance"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID } from "../../src/session/schema"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { AppRuntime } from "../../src/effect/app-runtime"
import { tmpdir } from "../fixture/fixture"
const projectRoot = path.join(__dirname, "../..")
Log.init({ print: false })
function create(input?: SessionNs.CreateInput) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create(input)))
}
function get(id: SessionID) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.get(id)))
}
function remove(id: SessionID) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.remove(id)))
}
function updateMessage<T extends MessageV2.Info>(msg: T) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updateMessage(msg)))
}
function updatePart<T extends MessageV2.Part>(part: T) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updatePart(part)))
}
describe("session.created event", () => {
test("should emit session.created event when session is created", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
let eventReceived = false
let receivedInfo: Session.Info | undefined
let receivedInfo: SessionNs.Info | undefined
const unsub = Bus.subscribe(Session.Event.Created, (event) => {
const unsub = Bus.subscribe(SessionNs.Event.Created, (event) => {
eventReceived = true
receivedInfo = event.properties.info as Session.Info
receivedInfo = event.properties.info as SessionNs.Info
})
const session = await Session.create({})
const info = await create({})
await new Promise((resolve) => setTimeout(resolve, 100))
unsub()
expect(eventReceived).toBe(true)
expect(receivedInfo).toBeDefined()
expect(receivedInfo?.id).toBe(session.id)
expect(receivedInfo?.projectID).toBe(session.projectID)
expect(receivedInfo?.directory).toBe(session.directory)
expect(receivedInfo?.title).toBe(session.title)
expect(receivedInfo?.id).toBe(info.id)
expect(receivedInfo?.projectID).toBe(info.projectID)
expect(receivedInfo?.directory).toBe(info.directory)
expect(receivedInfo?.title).toBe(info.title)
await Session.remove(session.id)
await remove(info.id)
},
})
})
@@ -48,18 +67,16 @@ describe("session.created event", () => {
fn: async () => {
const events: string[] = []
const unsubCreated = Bus.subscribe(Session.Event.Created, () => {
const unsubCreated = Bus.subscribe(SessionNs.Event.Created, () => {
events.push("created")
})
const unsubUpdated = Bus.subscribe(Session.Event.Updated, () => {
const unsubUpdated = Bus.subscribe(SessionNs.Event.Updated, () => {
events.push("updated")
})
const session = await Session.create({})
const info = await create({})
await new Promise((resolve) => setTimeout(resolve, 100))
unsubCreated()
unsubUpdated()
@@ -67,7 +84,7 @@ describe("session.created event", () => {
expect(events).toContain("updated")
expect(events.indexOf("created")).toBeLessThan(events.indexOf("updated"))
await Session.remove(session.id)
await remove(info.id)
},
})
})
@@ -80,12 +97,12 @@ describe("step-finish token propagation via Bus event", () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const session = await Session.create({})
const info = await create({})
const messageID = MessageID.ascending()
await Session.updateMessage({
await updateMessage({
id: messageID,
sessionID: session.id,
sessionID: info.id,
role: "user",
time: { created: Date.now() },
agent: "user",
@@ -110,15 +127,14 @@ describe("step-finish token propagation via Bus event", () => {
const partInput = {
id: PartID.ascending(),
messageID,
sessionID: session.id,
sessionID: info.id,
type: "step-finish" as const,
reason: "stop",
cost: 0.005,
tokens,
}
await Session.updatePart(partInput)
await updatePart(partInput)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(received).toBeDefined()
@@ -134,7 +150,7 @@ describe("step-finish token propagation via Bus event", () => {
expect(received).not.toBe(partInput)
unsub()
await Session.remove(session.id)
await remove(info.id)
},
})
},
@@ -146,17 +162,17 @@ describe("Session", () => {
test("remove works without an instance", async () => {
await using tmp = await tmpdir({ git: true })
const session = await Instance.provide({
const info = await Instance.provide({
directory: tmp.path,
fn: async () => Session.create({ title: "remove-without-instance" }),
fn: () => create({ title: "remove-without-instance" }),
})
await expect(async () => {
await Session.remove(session.id)
await remove(info.id)
}).not.toThrow()
let missing = false
await Session.get(session.id).catch(() => {
await get(info.id).catch(() => {
missing = true
})
@@ -39,6 +39,7 @@ import { MCP } from "../../src/mcp"
import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin"
import { Provider as ProviderSvc } from "../../src/provider/provider"
import { Env } from "../../src/env"
import { Question } from "../../src/question"
import { Skill } from "../../src/skill"
import { SystemPrompt } from "../../src/session/system"
@@ -121,6 +122,7 @@ function makeHttp() {
Session.defaultLayer,
Snapshot.defaultLayer,
LLM.defaultLayer,
Env.defaultLayer,
AgentSvc.defaultLayer,
Command.defaultLayer,
Permission.defaultLayer,
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Effect, Layer } from "effect"
import { Session } from "../../src/session"
import { SessionPrompt } from "../../src/session/prompt"
import { Log } from "../../src/util/log"
@@ -20,51 +21,63 @@ async function withInstance<T>(fn: () => Promise<T>): Promise<T> {
})
}
function run<A, E>(fx: Effect.Effect<A, E, SessionPrompt.Service | Session.Service>) {
return Effect.runPromise(
fx.pipe(Effect.scoped, Effect.provide(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))),
)
}
describe("StructuredOutput Integration", () => {
test.skipIf(!hasApiKey)(
"produces structured output with simple schema",
async () => {
await withInstance(async () => {
const session = await Session.create({ title: "Structured Output Test" })
await withInstance(() =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Structured Output Test" })
const result = await SessionPrompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "What is 2 + 2? Provide a simple answer.",
},
],
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
answer: { type: "number", description: "The numerical answer" },
explanation: { type: "string", description: "Brief explanation" },
const result = yield* prompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "What is 2 + 2? Provide a simple answer.",
},
],
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
answer: { type: "number", description: "The numerical answer" },
explanation: { type: "string", description: "Brief explanation" },
},
required: ["answer"],
},
retryCount: 0,
},
required: ["answer"],
},
retryCount: 0,
},
})
})
// Verify structured output was captured (only on assistant messages)
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.structured).toBeDefined()
expect(typeof result.info.structured).toBe("object")
// Verify structured output was captured (only on assistant messages)
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.structured).toBeDefined()
expect(typeof result.info.structured).toBe("object")
const output = result.info.structured as any
expect(output.answer).toBe(4)
const output = result.info.structured as any
expect(output.answer).toBe(4)
// Verify no error was set
expect(result.info.error).toBeUndefined()
}
// Verify no error was set
expect(result.info.error).toBeUndefined()
}
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
})
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
}),
),
)
},
60000,
)
@@ -72,62 +85,68 @@ describe("StructuredOutput Integration", () => {
test.skipIf(!hasApiKey)(
"produces structured output with nested objects",
async () => {
await withInstance(async () => {
const session = await Session.create({ title: "Nested Schema Test" })
await withInstance(() =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Nested Schema Test" })
const result = await SessionPrompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "Tell me about Anthropic company in a structured format.",
},
],
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
company: {
const result = yield* prompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "Tell me about Anthropic company in a structured format.",
},
],
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
name: { type: "string" },
founded: { type: "number" },
company: {
type: "object",
properties: {
name: { type: "string" },
founded: { type: "number" },
},
required: ["name", "founded"],
},
products: {
type: "array",
items: { type: "string" },
},
},
required: ["name", "founded"],
},
products: {
type: "array",
items: { type: "string" },
required: ["company"],
},
retryCount: 0,
},
required: ["company"],
},
retryCount: 0,
},
})
})
// Verify structured output was captured (only on assistant messages)
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.structured).toBeDefined()
const output = result.info.structured as any
// Verify structured output was captured (only on assistant messages)
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.structured).toBeDefined()
const output = result.info.structured as any
expect(output.company).toBeDefined()
expect(output.company.name).toBe("Anthropic")
expect(typeof output.company.founded).toBe("number")
expect(output.company).toBeDefined()
expect(output.company.name).toBe("Anthropic")
expect(typeof output.company.founded).toBe("number")
if (output.products) {
expect(Array.isArray(output.products)).toBe(true)
}
if (output.products) {
expect(Array.isArray(output.products)).toBe(true)
}
// Verify no error was set
expect(result.info.error).toBeUndefined()
}
// Verify no error was set
expect(result.info.error).toBeUndefined()
}
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
})
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
}),
),
)
},
60000,
)
@@ -135,35 +154,41 @@ describe("StructuredOutput Integration", () => {
test.skipIf(!hasApiKey)(
"works with text outputFormat (default)",
async () => {
await withInstance(async () => {
const session = await Session.create({ title: "Text Output Test" })
await withInstance(() =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Text Output Test" })
const result = await SessionPrompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "Say hello.",
},
],
format: {
type: "text",
},
})
const result = yield* prompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "Say hello.",
},
],
format: {
type: "text",
},
})
// Verify no structured output (text mode) and no error
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.structured).toBeUndefined()
expect(result.info.error).toBeUndefined()
}
// Verify no structured output (text mode) and no error
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.structured).toBeUndefined()
expect(result.info.error).toBeUndefined()
}
// Verify we got a response with parts
expect(result.parts.length).toBeGreaterThan(0)
// Verify we got a response with parts
expect(result.parts.length).toBeGreaterThan(0)
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
})
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
}),
),
)
},
60000,
)
@@ -171,47 +196,53 @@ describe("StructuredOutput Integration", () => {
test.skipIf(!hasApiKey)(
"stores outputFormat on user message",
async () => {
await withInstance(async () => {
const session = await Session.create({ title: "OutputFormat Storage Test" })
await withInstance(() =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "OutputFormat Storage Test" })
await SessionPrompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "What is 1 + 1?",
},
],
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
result: { type: "number" },
yield* prompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "What is 1 + 1?",
},
],
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
result: { type: "number" },
},
required: ["result"],
},
retryCount: 3,
},
required: ["result"],
},
retryCount: 3,
},
})
})
// Get all messages from session
const messages = await Session.messages({ sessionID: session.id })
const userMessage = messages.find((m) => m.info.role === "user")
// Get all messages from session
const messages = yield* sessions.messages({ sessionID: session.id })
const userMessage = messages.find((m) => m.info.role === "user")
// Verify outputFormat was stored on user message
expect(userMessage).toBeDefined()
if (userMessage?.info.role === "user") {
expect(userMessage.info.format).toBeDefined()
expect(userMessage.info.format?.type).toBe("json_schema")
if (userMessage.info.format?.type === "json_schema") {
expect(userMessage.info.format.retryCount).toBe(3)
}
}
// Verify outputFormat was stored on user message
expect(userMessage).toBeDefined()
if (userMessage?.info.role === "user") {
expect(userMessage.info.format).toBeDefined()
expect(userMessage.info.format?.type).toBe("json_schema")
if (userMessage.info.format?.type === "json_schema") {
expect(userMessage.info.format.retryCount).toBe(3)
}
}
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
})
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
}),
),
)
},
60000,
)
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -76,7 +76,7 @@ function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void;
}
}
function reply(input: Parameters<typeof SessionPrompt.prompt>[0], text: string): MessageV2.WithParts {
function reply(input: SessionPrompt.PromptInput, text: string): MessageV2.WithParts {
const id = MessageID.ascending()
return {
info: {
+18
View File
@@ -304,6 +304,24 @@ export interface Hooks {
input: { sessionID: string },
output: { context: string[]; prompt?: string },
) => Promise<void>
/**
* Called after compaction succeeds and before a synthetic user
* auto-continue message is added.
*
* - `enabled`: Defaults to `true`. Set to `false` to skip the synthetic
* user "continue" turn.
*/
"experimental.compaction.autocontinue"?: (
input: {
sessionID: string
agent: string
model: Model
provider: ProviderContext
message: UserMessage
overflow: boolean
},
output: { enabled: boolean },
) => Promise<void>
"experimental.text.complete"?: (
input: { sessionID: string; messageID: string; partID: string },
output: { text: string },