mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-10 19:49:48 -04:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4637ea7599 | |||
| 2fc5b00537 | |||
| 2728f6413e | |||
| fe3303163c | |||
| f954854232 | |||
| 6a99079012 | |||
| 0a8b6298cd | |||
| f40209bdfb | |||
| a2cb4909da | |||
| 7a05ba47d1 | |||
| 36745caa2a | |||
| c2403d0f15 | |||
| 34e2429c49 | |||
| 10ba68c772 |
+4
-4
@@ -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="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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),
|
||||
|
||||
@@ -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))))
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Vendored
+41
-13
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user