Compare commits

..
Author SHA1 Message Date
Simon Klee b89ec4b11b opencode: lazily compose default layers
defaultLayer is currently composed at import time. This makes module evaluation
sensitive to transitive import order, even though the layer graph itself is
unchanged.

Wrap these compositions in Layer.suspend() so the same graph is built on
demand instead of during module load. This reduces init-order coupling and
makes import reordering for startup work safer.
2026-04-12 11:40:35 +02:00
Simon KleeandGitHub 3729fd5706 chore(github): vouch simonklee (#22127) 2026-04-12 11:33:38 +02:00
Aiden ClineandGitHub 74b14a2d4e chore: refactor log.ts, go back to glob but add sort (#22107) 2026-04-11 23:09:19 -05:00
Aiden ClineandGitHub cdb951ec2f feat: make gh copilot use msgs api when available (#22106) 2026-04-11 23:06:35 -05:00
Aiden ClineandGitHub fc01cad2b8 fix: ensure logger cleanup properly orders list before deleting files (#22101) 2026-04-11 22:07:34 -05:00
opencode-agent[bot] c1ddc0ea2d chore: generate 2026-04-12 01:21:17 +00:00
Kit LangtonandGitHub 319b7655b7 refactor(tool): destroy Truncate facade, effectify Tool.define (#22093) 2026-04-11 21:20:12 -04:00
Kit LangtonandGitHub 824c12c01a refactor(file): destroy FileWatcher facade (#22091) 2026-04-11 21:19:12 -04:00
opencode-agent[bot] 17b2900884 chore: generate 2026-04-12 00:58:05 +00:00
Kit LangtonandGitHub 003010bdb6 refactor(question): destroy Question facade (#22092) 2026-04-11 20:57:01 -04:00
Kit LangtonandGitHub 82a4292934 refactor(file): destroy FileTime facade (#22090) 2026-04-11 20:08:55 -04:00
Kit LangtonandGitHub eea4253d67 refactor(session): destroy Instruction facade (#22089) 2026-04-11 20:04:09 -04:00
opencode-agent[bot] 1eacc3c339 chore: generate 2026-04-12 00:03:01 +00:00
Kit LangtonandGitHub 1a509d62a0 refactor(session): destroy SessionRunState facade (#22064) 2026-04-11 20:01:52 -04:00
43 changed files with 1072 additions and 872 deletions
+1
View File
@@ -26,6 +26,7 @@ kommander
r44vc0rp
rekram1-node
-robinmordasiewicz
simonklee
-spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr
-toastythebot
@@ -217,6 +217,7 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
- [x] `SessionSummary``session/summary.ts`
- [x] `SessionRevert``session/revert.ts`
- [x] `Instruction``session/instruction.ts`
- [x] `SystemPrompt``session/system.ts`
- [x] `Provider``provider/provider.ts`
- [x] `Storage``storage/storage.ts`
- [x] `ShareNext``share/share-next.ts`
@@ -340,3 +341,47 @@ For each service, the migration is roughly:
- `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime.
- `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration.
- `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/routes/session.ts` converted; facade removed.
- `Account` — migrated 2026-04-11. Callers in `server/routes/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
- `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed.
- `FileTime` — migrated 2026-04-11. Test-only callers converted; facade removed.
- `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed.
- `Question` — migrated 2026-04-11. Callers in `server/routes/question.ts` and test converted; facade removed.
- `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed.
## Route handler effectification
Route handlers should wrap their entire body in a single `AppRuntime.runPromise(Effect.gen(...))` call, yielding services from context rather than calling facades one-by-one. This eliminates multiple `runPromise` round-trips and lets handlers compose naturally.
```ts
// Before — one facade call per service
;async (c) => {
await SessionRunState.assertNotBusy(id)
await Session.removeMessage({ sessionID: id, messageID })
return c.json(true)
}
// After — one Effect.gen, yield services from context
;async (c) => {
await AppRuntime.runPromise(
Effect.gen(function* () {
const state = yield* SessionRunState.Service
const session = yield* Session.Service
yield* state.assertNotBusy(id)
yield* session.removeMessage({ sessionID: id, messageID })
}),
)
return c.json(true)
}
```
When migrating, always use `{ concurrency: "unbounded" }` with `Effect.all` — route handlers should run independent service calls in parallel, not sequentially.
Route files to convert (each handler that calls facades should be wrapped):
- [ ] `server/routes/session.ts` — heaviest; uses Session, SessionPrompt, SessionRevert, SessionCompaction, SessionShare, SessionSummary, SessionRunState, Agent, Permission, Bus
- [ ] `server/routes/global.ts` — uses Config, Project, Provider, Vcs, Snapshot, Agent
- [ ] `server/routes/provider.ts` — uses Provider, Auth, Config
- [ ] `server/routes/question.ts` — uses Question
- [ ] `server/routes/pty.ts` — uses Pty
- [ ] `server/routes/experimental.ts` — uses Account, ToolRegistry, Agent, MCP, Config
+7 -5
View File
@@ -398,11 +398,13 @@ export namespace Agent {
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Provider.defaultLayer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Skill.defaultLayer),
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(Provider.defaultLayer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Skill.defaultLayer),
),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
+2 -4
View File
@@ -183,9 +183,7 @@ export namespace Command {
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(MCP.defaultLayer),
Layer.provide(Skill.defaultLayer),
export const defaultLayer = Layer.suspend(() =>
layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(MCP.defaultLayer), Layer.provide(Skill.defaultLayer)),
)
}
@@ -1,9 +1,10 @@
import { Layer, ManagedRuntime } from "effect"
import { memoMap } from "./run-service"
import { FileWatcher } from "@/file/watcher"
import { Format } from "@/format"
import { ShareNext } from "@/share/share-next"
export const BootstrapLayer = Layer.mergeAll(Format.defaultLayer, ShareNext.defaultLayer)
export const BootstrapLayer = Layer.mergeAll(Format.defaultLayer, ShareNext.defaultLayer, FileWatcher.defaultLayer)
export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap })
-19
View File
@@ -1,6 +1,5 @@
import { DateTime, Effect, Layer, Option, Semaphore, Context } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
import { Flag } from "@/flag/flag"
import type { SessionID } from "@/session/schema"
@@ -112,22 +111,4 @@ export namespace FileTime {
).pipe(Layer.orDie)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export function read(sessionID: SessionID, file: string) {
return runPromise((s) => s.read(sessionID, file))
}
export function get(sessionID: SessionID, file: string) {
return runPromise((s) => s.get(sessionID, file))
}
export async function assert(sessionID: SessionID, filepath: string) {
return runPromise((s) => s.assert(sessionID, filepath))
}
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
return runPromise((s) => s.withLock(filepath, () => Effect.promise(fn)))
}
}
-7
View File
@@ -8,7 +8,6 @@ import z from "zod"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Flag } from "@/flag/flag"
import { Git } from "@/git"
import { Instance } from "@/project/instance"
@@ -161,10 +160,4 @@ export namespace FileWatcher {
)
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export function init() {
return runPromise((svc) => svc.init())
}
}
@@ -27,11 +27,12 @@ function base(enterpriseUrl?: string) {
return enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : "https://api.githubcopilot.com"
}
function fix(model: Model): Model {
function fix(model: Model, url: string): Model {
return {
...model,
api: {
...model.api,
url,
npm: "@ai-sdk/github-copilot",
},
}
@@ -44,19 +45,23 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
id: "github-copilot",
async models(provider, ctx) {
if (ctx.auth?.type !== "oauth") {
return Object.fromEntries(Object.entries(provider.models).map(([id, model]) => [id, fix(model)]))
return Object.fromEntries(Object.entries(provider.models).map(([id, model]) => [id, fix(model, base())]))
}
const auth = ctx.auth
return CopilotModels.get(
base(ctx.auth.enterpriseUrl),
base(auth.enterpriseUrl),
{
Authorization: `Bearer ${ctx.auth.refresh}`,
Authorization: `Bearer ${auth.refresh}`,
"User-Agent": `opencode/${Installation.VERSION}`,
},
provider.models,
).catch((error) => {
log.error("failed to fetch copilot models", { error })
return Object.fromEntries(Object.entries(provider.models).map(([id, model]) => [id, fix(model)]))
return Object.fromEntries(
Object.entries(provider.models).map(([id, model]) => [id, fix(model, base(auth.enterpriseUrl))]),
)
})
},
},
@@ -66,10 +71,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
const info = await getAuth()
if (!info || info.type !== "oauth") return {}
const baseURL = base(info.enterpriseUrl)
return {
baseURL,
apiKey: "",
async fetch(request: RequestInfo | URL, init?: RequestInit) {
const info = await getAuth()
@@ -52,13 +52,15 @@ export namespace CopilotModels {
(remote.capabilities.supports.vision ?? false) ||
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
const isMsgApi = remote.supported_endpoints?.includes("/v1/messages")
return {
id: key,
providerID: "github-copilot",
api: {
id: remote.id,
url,
npm: "@ai-sdk/github-copilot",
url: isMsgApi ? `${url}/v1` : url,
npm: isMsgApi ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot",
},
// API response wins
status: "active",
+2 -2
View File
@@ -2,7 +2,6 @@ import { Plugin } from "../plugin"
import { Format } from "../format"
import { LSP } from "../lsp"
import { File } from "../file"
import { FileWatcher } from "../file/watcher"
import { Snapshot } from "../snapshot"
import { Project } from "./project"
import { Vcs } from "./vcs"
@@ -11,6 +10,7 @@ import { Command } from "../command"
import { Instance } from "./instance"
import { Log } from "@/util/log"
import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
import { FileWatcher } from "@/file/watcher"
import { ShareNext } from "@/share/share-next"
export async function InstanceBootstrap() {
@@ -20,7 +20,7 @@ export async function InstanceBootstrap() {
void BootstrapRuntime.runPromise(Format.Service.use((svc) => svc.init()))
await LSP.init()
File.init()
FileWatcher.init()
void BootstrapRuntime.runPromise(FileWatcher.Service.use((svc) => svc.init()))
Vcs.init()
Snapshot.init()
-23
View File
@@ -2,7 +2,6 @@ import { Deferred, Effect, Layer, Schema, Context } from "effect"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { SessionID, MessageID } from "@/session/schema"
import { Log } from "@/util/log"
import z from "zod"
@@ -199,26 +198,4 @@ export namespace Question {
)
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function ask(input: {
sessionID: SessionID
questions: Info[]
tool?: { messageID: MessageID; callID: string }
}): Promise<Answer[]> {
return runPromise((s) => s.ask(input))
}
export async function reply(input: { requestID: QuestionID; answers: Answer[] }) {
return runPromise((s) => s.reply(input))
}
export async function reject(requestID: QuestionID) {
return runPromise((s) => s.reject(requestID))
}
export async function list() {
return runPromise((s) => s.list())
}
}
@@ -3,6 +3,7 @@ import { describeRoute, validator } from "hono-openapi"
import { resolver } from "hono-openapi"
import { QuestionID } from "@/question/schema"
import { Question } from "../../question"
import { AppRuntime } from "@/effect/app-runtime"
import z from "zod"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
@@ -27,7 +28,7 @@ export const QuestionRoutes = lazy(() =>
},
}),
async (c) => {
const questions = await Question.list()
const questions = await AppRuntime.runPromise(Question.Service.use((svc) => svc.list()))
return c.json(questions)
},
)
@@ -59,10 +60,14 @@ export const QuestionRoutes = lazy(() =>
async (c) => {
const params = c.req.valid("param")
const json = c.req.valid("json")
await Question.reply({
requestID: params.requestID,
answers: json.answers,
})
await AppRuntime.runPromise(
Question.Service.use((svc) =>
svc.reply({
requestID: params.requestID,
answers: json.answers,
}),
),
)
return c.json(true)
},
)
@@ -92,7 +97,7 @@ export const QuestionRoutes = lazy(() =>
),
async (c) => {
const params = c.req.valid("param")
await Question.reject(params.requestID)
await AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(params.requestID)))
return c.json(true)
},
),
@@ -13,6 +13,7 @@ import { SessionShare } from "@/share/session"
import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
import { Todo } from "../../session/todo"
import { Effect } from "effect"
import { AppRuntime } from "../../effect/app-runtime"
import { Agent } from "../../agent/agent"
import { Snapshot } from "@/snapshot"
@@ -724,11 +725,17 @@ export const SessionRoutes = lazy(() =>
),
async (c) => {
const params = c.req.valid("param")
await SessionRunState.assertNotBusy(params.sessionID)
await Session.removeMessage({
sessionID: params.sessionID,
messageID: params.messageID,
})
await AppRuntime.runPromise(
Effect.gen(function* () {
const state = yield* SessionRunState.Service
const session = yield* Session.Service
yield* state.assertNotBusy(params.sessionID)
yield* session.removeMessage({
sessionID: params.sessionID,
messageID: params.messageID,
})
}),
)
return c.json(true)
},
)
+3 -1
View File
@@ -678,7 +678,9 @@ export namespace Session {
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Storage.defaultLayer))
export const defaultLayer = Layer.suspend(() =>
layer.pipe(Layer.provide(Bus.layer), Layer.provide(Storage.defaultLayer)),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
@@ -4,7 +4,6 @@ import { Effect, Layer, Context } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Flag } from "@/flag/flag"
import { AppFileSystem } from "@/filesystem"
import { withTransientReadRetry } from "@/util/effect-http-client"
@@ -238,21 +237,7 @@ export namespace Instruction {
Layer.provide(FetchHttpClient.layer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export function clear(messageID: MessageID) {
return runPromise((svc) => svc.clear(messageID))
}
export async function systemPaths() {
return runPromise((svc) => svc.systemPaths())
}
export function loaded(messages: MessageV2.WithParts[]) {
return extract(messages)
}
export async function resolve(messages: MessageV2.WithParts[], filepath: string, messageID: MessageID) {
return runPromise((svc) => svc.resolve(messages, filepath, messageID))
}
}
@@ -1,6 +1,5 @@
import { InstanceState } from "@/effect/instance-state"
import { Runner } from "@/effect/runner"
import { makeRuntime } from "@/effect/run-service"
import { Effect, Layer, Scope, Context } from "effect"
import { Session } from "."
import { MessageV2 } from "./message-v2"
@@ -106,9 +105,4 @@ export namespace SessionRunState {
)
export const defaultLayer = layer.pipe(Layer.provide(SessionStatus.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function assertNotBusy(sessionID: SessionID) {
return runPromise((svc) => svc.assertNotBusy(sessionID))
}
}
+9 -7
View File
@@ -339,12 +339,14 @@ export namespace ShareNext {
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(Account.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Session.defaultLayer),
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(Account.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Session.defaultLayer),
),
)
}
+7 -5
View File
@@ -230,11 +230,13 @@ export namespace Skill {
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Discovery.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(AppFileSystem.defaultLayer),
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(Discovery.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(AppFileSystem.defaultLayer),
),
)
export function fmt(list: Info[], opts: { verbose: boolean }) {
+15 -5
View File
@@ -70,7 +70,12 @@ export namespace Tool {
? Def<P, M>
: never
function wrap<Parameters extends z.ZodType, Result extends Metadata>(id: string, init: Init<Parameters, Result>) {
function wrap<Parameters extends z.ZodType, Result extends Metadata>(
id: string,
init: Init<Parameters, Result>,
truncate: Truncate.Interface,
agents: Agent.Interface,
) {
return () =>
Effect.gen(function* () {
const toolInfo = init instanceof Function ? { ...(yield* init()) } : { ...init }
@@ -93,8 +98,8 @@ export namespace Tool {
if (result.metadata.truncated !== undefined) {
return result
}
const agent = yield* Effect.promise(() => Agent.get(ctx.agent))
const truncated = yield* Effect.promise(() => Truncate.output(result.output, {}, agent))
const agent = yield* agents.get(ctx.agent)
const truncated = yield* truncate.output(result.output, {}, agent)
return {
...result,
output: truncated.content,
@@ -112,9 +117,14 @@ export namespace Tool {
export function define<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
id: ID,
init: Effect.Effect<Init<Parameters, Result>, never, R>,
): Effect.Effect<Info<Parameters, Result>, never, R> & { id: ID } {
): Effect.Effect<Info<Parameters, Result>, never, R | Truncate.Service | Agent.Service> & { id: ID } {
return Object.assign(
Effect.map(init, (init) => ({ id, init: wrap(id, init) })),
Effect.gen(function* () {
const resolved = yield* init
const truncate = yield* Truncate.Service
const agents = yield* Agent.Service
return { id, init: wrap(id, resolved, truncate, agents) }
}),
{ id },
)
}
-7
View File
@@ -2,7 +2,6 @@ import { NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, Layer, Schedule, Context } from "effect"
import path from "path"
import type { Agent } from "../agent/agent"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
import { evaluate } from "@/permission/evaluate"
import { Identifier } from "../id/id"
@@ -135,10 +134,4 @@ export namespace Truncate {
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
return runPromise((s) => s.output(text, options, agent))
}
}
+13 -8
View File
@@ -15,6 +15,7 @@ export namespace Log {
WARN: 2,
ERROR: 3,
}
const keep = 10
let level: Level = "INFO"
@@ -78,15 +79,19 @@ export namespace Log {
}
async function cleanup(dir: string) {
const files = await Glob.scan("????-??-??T??????.log", {
cwd: dir,
absolute: true,
include: "file",
})
if (files.length <= 5) return
const files = (
await Glob.scan("????-??-??T??????.log", {
cwd: dir,
absolute: false,
include: "file",
}).catch(() => [])
)
.filter((file) => path.basename(file) === file)
.sort()
if (files.length <= keep) return
const filesToDelete = files.slice(0, -10)
await Promise.all(filesToDelete.map((file) => fs.unlink(file).catch(() => {})))
const doomed = files.slice(0, -keep)
await Promise.all(doomed.map((file) => fs.unlink(path.join(dir, file)).catch(() => {})))
}
function formatError(error: Error, depth = 0): string {
+52
View File
@@ -79,3 +79,55 @@ await using tmp = await tmpdir({
- Directories are created in the system temp folder with prefix `opencode-test-`
- Use `await using` for automatic cleanup when the variable goes out of scope
- Paths are sanitized to strip null bytes (defensive fix for CI environments)
## Testing With Effects
Use `testEffect(...)` from `test/lib/effect.ts` for tests that exercise Effect services or Effect-based workflows.
### Core Pattern
```typescript
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(MyService.defaultLayer))
describe("my service", () => {
it.live("does the thing", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const svc = yield* MyService.Service
const out = yield* svc.run()
expect(out).toEqual("ok")
}),
),
)
})
```
### `it.effect` vs `it.live`
- Use `it.effect(...)` when the test should run with `TestClock` and `TestConsole`.
- Use `it.live(...)` when the test depends on real time, filesystem mtimes, child processes, git, locks, or other live OS behavior.
- Most integration-style tests in this package use `it.live(...)`.
### Effect Fixtures
Prefer the Effect-aware helpers from `fixture/fixture.ts` instead of building a manual runtime in each test.
- `tmpdirScoped(options?)` creates a scoped temp directory and cleans it up when the Effect scope closes.
- `provideInstance(dir)(effect)` is the low-level helper. It does not create a directory; it just runs an Effect with `Instance.current` bound to `dir`.
- `provideTmpdirInstance((dir) => effect, options?)` is the convenience helper. It creates a temp directory, binds it as the active instance, and disposes the instance on cleanup.
- `provideTmpdirServer((input) => effect, options?)` does the same, but also provides the test LLM server.
Use `provideTmpdirInstance(...)` by default when a test only needs one temp instance. Use `tmpdirScoped()` plus `provideInstance(...)` when a test needs multiple directories, custom setup before binding, or needs to switch instance context within one test.
### Style
- Define `const it = testEffect(...)` near the top of the file.
- Keep the test body inside `Effect.gen(function* () { ... })`.
- Yield services directly with `yield* MyService.Service` or `yield* MyTool`.
- Avoid custom `ManagedRuntime`, `attach(...)`, or ad hoc `run(...)` wrappers when `testEffect(...)` already provides the runtime.
- When a test needs instance-local state, prefer `provideTmpdirInstance(...)` or `provideInstance(...)` over manual `Instance.provide(...)` inside Promise-style tests.
+341 -364
View File
@@ -1,445 +1,422 @@
import { describe, test, expect, afterEach } from "bun:test"
import path from "path"
import { afterEach, describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { FileTime } from "../../src/file/time"
import { Instance } from "../../src/project/instance"
import { SessionID } from "../../src/session/schema"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
afterEach(async () => {
await Instance.disposeAll()
})
async function touch(file: string, time: number) {
const date = new Date(time)
await fs.utimes(file, date, date)
}
const it = testEffect(Layer.mergeAll(FileTime.defaultLayer, CrossSpawnSpawner.defaultLayer))
function gate() {
let open!: () => void
const wait = new Promise<void>((resolve) => {
open = resolve
const id = SessionID.make("ses_00000000000000000000000001")
const put = (file: string, text: string) => Effect.promise(() => fs.writeFile(file, text, "utf-8"))
const touch = (file: string, time: number) =>
Effect.promise(() => {
const date = new Date(time)
return fs.utimes(file, date, date)
})
return { open, wait }
}
const read = (id: SessionID, file: string) => FileTime.Service.use((svc) => svc.read(id, file))
const get = (id: SessionID, file: string) => FileTime.Service.use((svc) => svc.get(id, file))
const check = (id: SessionID, file: string) => FileTime.Service.use((svc) => svc.assert(id, file))
const lock = <A>(file: string, fn: () => Effect.Effect<A>) => FileTime.Service.use((svc) => svc.withLock(file, fn))
const fail = Effect.fn("FileTimeTest.fail")(function* <A, E, R>(self: Effect.Effect<A, E, R>) {
const exit = yield* self.pipe(Effect.exit)
if (Exit.isFailure(exit)) {
const err = Cause.squash(exit.cause)
return err instanceof Error ? err : new Error(String(err))
}
throw new Error("expected file time effect to fail")
})
describe("file/time", () => {
const sessionID = SessionID.make("ses_00000000000000000000000001")
describe("read() and get()", () => {
test("stores read timestamp", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
it.live("stores read timestamp", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await FileTime.get(sessionID, filepath)
const before = yield* get(id, file)
expect(before).toBeUndefined()
await FileTime.read(sessionID, filepath)
yield* read(id, file)
const after = await FileTime.get(sessionID, filepath)
const after = yield* get(id, file)
expect(after).toBeInstanceOf(Date)
expect(after!.getTime()).toBeGreaterThan(0)
},
})
})
}),
),
)
test("tracks separate timestamps per session", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
it.live("tracks separate timestamps per session", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
await Instance.provide({
directory: tmp.path,
fn: async () => {
await FileTime.read(SessionID.make("ses_00000000000000000000000002"), filepath)
await FileTime.read(SessionID.make("ses_00000000000000000000000003"), filepath)
const one = SessionID.make("ses_00000000000000000000000002")
const two = SessionID.make("ses_00000000000000000000000003")
yield* read(one, file)
yield* read(two, file)
const time1 = await FileTime.get(SessionID.make("ses_00000000000000000000000002"), filepath)
const time2 = await FileTime.get(SessionID.make("ses_00000000000000000000000003"), filepath)
const first = yield* get(one, file)
const second = yield* get(two, file)
expect(time1).toBeDefined()
expect(time2).toBeDefined()
},
})
})
expect(first).toBeDefined()
expect(second).toBeDefined()
}),
),
)
test("updates timestamp on subsequent reads", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
it.live("updates timestamp on subsequent reads", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
await Instance.provide({
directory: tmp.path,
fn: async () => {
await FileTime.read(sessionID, filepath)
const first = await FileTime.get(sessionID, filepath)
yield* read(id, file)
const first = yield* get(id, file)
await FileTime.read(sessionID, filepath)
const second = await FileTime.get(sessionID, filepath)
yield* read(id, file)
const second = yield* get(id, file)
expect(second!.getTime()).toBeGreaterThanOrEqual(first!.getTime())
},
})
})
}),
),
)
test("isolates reads by directory", async () => {
await using one = await tmpdir()
await using two = await tmpdir()
await using shared = await tmpdir()
const filepath = path.join(shared.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
it.live("isolates reads by directory", () =>
Effect.gen(function* () {
const one = yield* tmpdirScoped()
const two = yield* tmpdirScoped()
const shared = yield* tmpdirScoped()
const file = path.join(shared, "file.txt")
yield* put(file, "content")
await Instance.provide({
directory: one.path,
fn: async () => {
await FileTime.read(sessionID, filepath)
},
})
await Instance.provide({
directory: two.path,
fn: async () => {
expect(await FileTime.get(sessionID, filepath)).toBeUndefined()
},
})
})
yield* provideInstance(one)(read(id, file))
const result = yield* provideInstance(two)(get(id, file))
expect(result).toBeUndefined()
}),
)
})
describe("assert()", () => {
test("passes when file has not been modified", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await touch(filepath, 1_000)
it.live("passes when file has not been modified", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
yield* touch(file, 1_000)
await Instance.provide({
directory: tmp.path,
fn: async () => {
await FileTime.read(sessionID, filepath)
await FileTime.assert(sessionID, filepath)
},
})
})
yield* read(id, file)
yield* check(id, file)
}),
),
)
test("throws when file was not read first", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
it.live("throws when file was not read first", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("You must read file")
},
})
})
const err = yield* fail(check(id, file))
expect(err.message).toContain("You must read file")
}),
),
)
test("throws when file was modified after read", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await touch(filepath, 1_000)
it.live("throws when file was modified after read", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
yield* touch(file, 1_000)
await Instance.provide({
directory: tmp.path,
fn: async () => {
await FileTime.read(sessionID, filepath)
await fs.writeFile(filepath, "modified content", "utf-8")
await touch(filepath, 2_000)
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("modified since it was last read")
},
})
})
yield* read(id, file)
yield* put(file, "modified content")
yield* touch(file, 2_000)
test("includes timestamps in error message", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await touch(filepath, 1_000)
const err = yield* fail(check(id, file))
expect(err.message).toContain("modified since it was last read")
}),
),
)
await Instance.provide({
directory: tmp.path,
fn: async () => {
await FileTime.read(sessionID, filepath)
await fs.writeFile(filepath, "modified", "utf-8")
await touch(filepath, 2_000)
it.live("includes timestamps in error message", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
yield* touch(file, 1_000)
let error: Error | undefined
try {
await FileTime.assert(sessionID, filepath)
} catch (e) {
error = e as Error
}
expect(error).toBeDefined()
expect(error!.message).toContain("Last modification:")
expect(error!.message).toContain("Last read:")
},
})
})
yield* read(id, file)
yield* put(file, "modified")
yield* touch(file, 2_000)
const err = yield* fail(check(id, file))
expect(err.message).toContain("Last modification:")
expect(err.message).toContain("Last read:")
}),
),
)
})
describe("withLock()", () => {
test("executes function within lock", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
it.live("executes function within lock", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
let hit = false
await Instance.provide({
directory: tmp.path,
fn: async () => {
let executed = false
await FileTime.withLock(filepath, async () => {
executed = true
return "result"
})
expect(executed).toBe(true)
},
})
})
test("returns function result", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await FileTime.withLock(filepath, async () => {
return "success"
})
expect(result).toBe("success")
},
})
})
test("serializes concurrent operations on same file", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const order: number[] = []
const hold = gate()
const ready = gate()
const op1 = FileTime.withLock(filepath, async () => {
order.push(1)
ready.open()
await hold.wait
order.push(2)
})
await ready.wait
const op2 = FileTime.withLock(filepath, async () => {
order.push(3)
order.push(4)
})
hold.open()
await Promise.all([op1, op2])
expect(order).toEqual([1, 2, 3, 4])
},
})
})
test("allows concurrent operations on different files", async () => {
await using tmp = await tmpdir()
const filepath1 = path.join(tmp.path, "file1.txt")
const filepath2 = path.join(tmp.path, "file2.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
let started1 = false
let started2 = false
const hold = gate()
const ready = gate()
const op1 = FileTime.withLock(filepath1, async () => {
started1 = true
ready.open()
await hold.wait
expect(started2).toBe(true)
})
await ready.wait
const op2 = FileTime.withLock(filepath2, async () => {
started2 = true
hold.open()
})
await Promise.all([op1, op2])
expect(started1).toBe(true)
expect(started2).toBe(true)
},
})
})
test("releases lock even if function throws", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(
FileTime.withLock(filepath, async () => {
throw new Error("Test error")
yield* lock(file, () =>
Effect.sync(() => {
hit = true
return "result"
}),
).rejects.toThrow("Test error")
)
let executed = false
await FileTime.withLock(filepath, async () => {
executed = true
})
expect(executed).toBe(true)
},
})
})
expect(hit).toBe(true)
}),
),
)
it.live("returns function result", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
const result = yield* lock(file, () => Effect.succeed("success"))
expect(result).toBe("success")
}),
),
)
it.live("serializes concurrent operations on same file", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
const order: number[] = []
const hold = yield* Deferred.make<void>()
const ready = yield* Deferred.make<void>()
const one = yield* lock(file, () =>
Effect.gen(function* () {
order.push(1)
yield* Deferred.succeed(ready, void 0)
yield* Deferred.await(hold)
order.push(2)
}),
).pipe(Effect.forkScoped)
yield* Deferred.await(ready)
const two = yield* lock(file, () =>
Effect.sync(() => {
order.push(3)
order.push(4)
}),
).pipe(Effect.forkScoped)
yield* Deferred.succeed(hold, void 0)
yield* Fiber.join(one)
yield* Fiber.join(two)
expect(order).toEqual([1, 2, 3, 4])
}),
),
)
it.live("allows concurrent operations on different files", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const onefile = path.join(dir, "file1.txt")
const twofile = path.join(dir, "file2.txt")
let one = false
let two = false
const hold = yield* Deferred.make<void>()
const ready = yield* Deferred.make<void>()
const a = yield* lock(onefile, () =>
Effect.gen(function* () {
one = true
yield* Deferred.succeed(ready, void 0)
yield* Deferred.await(hold)
expect(two).toBe(true)
}),
).pipe(Effect.forkScoped)
yield* Deferred.await(ready)
const b = yield* lock(twofile, () =>
Effect.sync(() => {
two = true
}),
).pipe(Effect.forkScoped)
yield* Fiber.join(b)
yield* Deferred.succeed(hold, void 0)
yield* Fiber.join(a)
expect(one).toBe(true)
expect(two).toBe(true)
}),
),
)
it.live("releases lock even if function throws", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
const err = yield* fail(lock(file, () => Effect.die(new Error("Test error"))))
expect(err.message).toContain("Test error")
let hit = false
yield* lock(file, () =>
Effect.sync(() => {
hit = true
}),
)
expect(hit).toBe(true)
}),
),
)
})
describe("path normalization", () => {
test("read with forward slashes, assert with backslashes", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await touch(filepath, 1_000)
it.live("read with forward slashes, assert with backslashes", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
yield* touch(file, 1_000)
const forwardSlash = filepath.replaceAll("\\", "/")
const forward = file.replaceAll("\\", "/")
yield* read(id, forward)
yield* check(id, file)
}),
),
)
await Instance.provide({
directory: tmp.path,
fn: async () => {
await FileTime.read(sessionID, forwardSlash)
// assert with the native backslash path should still work
await FileTime.assert(sessionID, filepath)
},
})
})
it.live("read with backslashes, assert with forward slashes", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
yield* touch(file, 1_000)
test("read with backslashes, assert with forward slashes", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await touch(filepath, 1_000)
const forward = file.replaceAll("\\", "/")
yield* read(id, file)
yield* check(id, forward)
}),
),
)
const forwardSlash = filepath.replaceAll("\\", "/")
it.live("get returns timestamp regardless of slash direction", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
await Instance.provide({
directory: tmp.path,
fn: async () => {
await FileTime.read(sessionID, filepath)
// assert with forward slashes should still work
await FileTime.assert(sessionID, forwardSlash)
},
})
})
const forward = file.replaceAll("\\", "/")
yield* read(id, forward)
test("get returns timestamp regardless of slash direction", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
const forwardSlash = filepath.replaceAll("\\", "/")
await Instance.provide({
directory: tmp.path,
fn: async () => {
await FileTime.read(sessionID, forwardSlash)
const result = await FileTime.get(sessionID, filepath)
const result = yield* get(id, file)
expect(result).toBeInstanceOf(Date)
},
})
})
}),
),
)
test("withLock serializes regardless of slash direction", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
const forwardSlash = filepath.replaceAll("\\", "/")
await Instance.provide({
directory: tmp.path,
fn: async () => {
it.live("withLock serializes regardless of slash direction", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
const forward = file.replaceAll("\\", "/")
const order: number[] = []
const hold = gate()
const ready = gate()
const hold = yield* Deferred.make<void>()
const ready = yield* Deferred.make<void>()
const op1 = FileTime.withLock(filepath, async () => {
order.push(1)
ready.open()
await hold.wait
order.push(2)
})
const one = yield* lock(file, () =>
Effect.gen(function* () {
order.push(1)
yield* Deferred.succeed(ready, void 0)
yield* Deferred.await(hold)
order.push(2)
}),
).pipe(Effect.forkScoped)
await ready.wait
yield* Deferred.await(ready)
// Use forward-slash variant -- should still serialize against op1
const op2 = FileTime.withLock(forwardSlash, async () => {
order.push(3)
order.push(4)
})
const two = yield* lock(forward, () =>
Effect.sync(() => {
order.push(3)
order.push(4)
}),
).pipe(Effect.forkScoped)
hold.open()
yield* Deferred.succeed(hold, void 0)
yield* Fiber.join(one)
yield* Fiber.join(two)
await Promise.all([op1, op2])
expect(order).toEqual([1, 2, 3, 4])
},
})
})
}),
),
)
})
describe("stat() Filesystem.stat pattern", () => {
test("reads file modification time via Filesystem.stat()", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await touch(filepath, 1_000)
it.live("reads file modification time via Filesystem.stat()", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
yield* touch(file, 1_000)
await Instance.provide({
directory: tmp.path,
fn: async () => {
await FileTime.read(sessionID, filepath)
yield* read(id, file)
const stats = Filesystem.stat(filepath)
expect(stats?.mtime).toBeInstanceOf(Date)
expect(stats!.mtime.getTime()).toBeGreaterThan(0)
const stat = Filesystem.stat(file)
expect(stat?.mtime).toBeInstanceOf(Date)
expect(stat!.mtime.getTime()).toBeGreaterThan(0)
await FileTime.assert(sessionID, filepath)
},
})
})
yield* check(id, file)
}),
),
)
test("detects modification via stat mtime", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "original", "utf-8")
await touch(filepath, 1_000)
it.live("detects modification via stat mtime", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "original")
yield* touch(file, 1_000)
await Instance.provide({
directory: tmp.path,
fn: async () => {
await FileTime.read(sessionID, filepath)
yield* read(id, file)
const originalStat = Filesystem.stat(filepath)
const first = Filesystem.stat(file)
await fs.writeFile(filepath, "modified", "utf-8")
await touch(filepath, 2_000)
yield* put(file, "modified")
yield* touch(file, 2_000)
const newStat = Filesystem.stat(filepath)
expect(newStat!.mtime.getTime()).toBeGreaterThan(originalStat!.mtime.getTime())
const second = Filesystem.stat(file)
expect(second!.mtime.getTime()).toBeGreaterThan(first!.mtime.getTime())
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow()
},
})
})
yield* fail(check(id, file))
}),
),
)
})
})
@@ -1,5 +1,6 @@
import { afterEach, expect, mock, test } from "bun:test"
import { CopilotModels } from "@/plugin/github-copilot/models"
import { CopilotAuthPlugin } from "@/plugin/github-copilot/copilot"
const originalFetch = globalThis.fetch
@@ -115,3 +116,45 @@ test("preserves temperature support from existing provider models", async () =>
expect(models["gpt-4o"].capabilities.temperature).toBe(true)
expect(models["brand-new"].capabilities.temperature).toBe(true)
})
test("remaps fallback oauth model urls to the enterprise host", async () => {
globalThis.fetch = mock(() => Promise.reject(new Error("timeout"))) as unknown as typeof fetch
const hooks = await CopilotAuthPlugin({
client: {} as never,
project: {} as never,
directory: "",
worktree: "",
serverUrl: new URL("https://example.com"),
$: {} as never,
})
const models = await hooks.provider!.models!(
{
id: "github-copilot",
models: {
claude: {
id: "claude",
providerID: "github-copilot",
api: {
id: "claude-sonnet-4.5",
url: "https://api.githubcopilot.com/v1",
npm: "@ai-sdk/anthropic",
},
},
},
} as never,
{
auth: {
type: "oauth",
refresh: "token",
access: "token",
expires: Date.now() + 60_000,
enterpriseUrl: "ghe.example.com",
} as never,
},
)
expect(models.claude.api.url).toBe("https://copilot-api.ghe.example.com")
expect(models.claude.api.npm).toBe("@ai-sdk/github-copilot")
})
+2 -1
View File
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { tmpdir } from "../fixture/fixture"
import { AppRuntime } from "../../src/effect/app-runtime"
import { FileWatcher } from "../../src/file/watcher"
import { Instance } from "../../src/project/instance"
import { GlobalBus } from "../../src/bus/global"
@@ -19,7 +20,7 @@ async function withVcs(directory: string, body: () => Promise<void>) {
return Instance.provide({
directory,
fn: async () => {
FileWatcher.init()
void AppRuntime.runPromise(FileWatcher.Service.use((svc) => svc.init()))
Vcs.init()
await Bun.sleep(500)
await body()
@@ -4,6 +4,17 @@ import { Instance } from "../../src/project/instance"
import { QuestionID } from "../../src/question/schema"
import { tmpdir } from "../fixture/fixture"
import { SessionID } from "../../src/session/schema"
import { AppRuntime } from "../../src/effect/app-runtime"
const ask = (input: { sessionID: SessionID; questions: Question.Info[]; tool?: { messageID: any; callID: string } }) =>
AppRuntime.runPromise(Question.Service.use((svc) => svc.ask(input)))
const list = () => AppRuntime.runPromise(Question.Service.use((svc) => svc.list()))
const reply = (input: { requestID: QuestionID; answers: Question.Answer[] }) =>
AppRuntime.runPromise(Question.Service.use((svc) => svc.reply(input)))
const reject = (id: QuestionID) => AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(id)))
afterEach(async () => {
await Instance.disposeAll()
@@ -11,9 +22,9 @@ afterEach(async () => {
/** Reject all pending questions so dangling Deferred fibers don't hang the test. */
async function rejectAll() {
const pending = await Question.list()
const pending = await list()
for (const req of pending) {
await Question.reject(req.id)
await reject(req.id)
}
}
@@ -22,7 +33,7 @@ test("ask - returns pending promise", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const promise = Question.ask({
const promise = ask({
sessionID: SessionID.make("ses_test"),
questions: [
{
@@ -58,16 +69,16 @@ test("ask - adds to pending list", async () => {
},
]
const askPromise = Question.ask({
const promise = ask({
sessionID: SessionID.make("ses_test"),
questions,
})
const pending = await Question.list()
const pending = await list()
expect(pending.length).toBe(1)
expect(pending[0].questions).toEqual(questions)
await rejectAll()
await askPromise.catch(() => {})
await promise.catch(() => {})
},
})
})
@@ -90,20 +101,20 @@ test("reply - resolves the pending ask with answers", async () => {
},
]
const askPromise = Question.ask({
const promise = ask({
sessionID: SessionID.make("ses_test"),
questions,
})
const pending = await Question.list()
const pending = await list()
const requestID = pending[0].id
await Question.reply({
await reply({
requestID,
answers: [["Option 1"]],
})
const answers = await askPromise
const answers = await promise
expect(answers).toEqual([["Option 1"]])
},
})
@@ -114,7 +125,7 @@ test("reply - removes from pending list", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = Question.ask({
const promise = ask({
sessionID: SessionID.make("ses_test"),
questions: [
{
@@ -128,17 +139,17 @@ test("reply - removes from pending list", async () => {
],
})
const pending = await Question.list()
const pending = await list()
expect(pending.length).toBe(1)
await Question.reply({
await reply({
requestID: pending[0].id,
answers: [["Option 1"]],
})
await askPromise
await promise
const pendingAfter = await Question.list()
expect(pendingAfter.length).toBe(0)
const after = await list()
expect(after.length).toBe(0)
},
})
})
@@ -148,7 +159,7 @@ test("reply - does nothing for unknown requestID", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await Question.reply({
await reply({
requestID: QuestionID.make("que_unknown"),
answers: [["Option 1"]],
})
@@ -164,7 +175,7 @@ test("reject - throws RejectedError", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = Question.ask({
const promise = ask({
sessionID: SessionID.make("ses_test"),
questions: [
{
@@ -178,10 +189,10 @@ test("reject - throws RejectedError", async () => {
],
})
const pending = await Question.list()
await Question.reject(pending[0].id)
const pending = await list()
await reject(pending[0].id)
await expect(askPromise).rejects.toBeInstanceOf(Question.RejectedError)
await expect(promise).rejects.toBeInstanceOf(Question.RejectedError)
},
})
})
@@ -191,7 +202,7 @@ test("reject - removes from pending list", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = Question.ask({
const promise = ask({
sessionID: SessionID.make("ses_test"),
questions: [
{
@@ -205,14 +216,14 @@ test("reject - removes from pending list", async () => {
],
})
const pending = await Question.list()
const pending = await list()
expect(pending.length).toBe(1)
await Question.reject(pending[0].id)
askPromise.catch(() => {}) // Ignore rejection
await reject(pending[0].id)
promise.catch(() => {}) // Ignore rejection
const pendingAfter = await Question.list()
expect(pendingAfter.length).toBe(0)
const after = await list()
expect(after.length).toBe(0)
},
})
})
@@ -222,7 +233,7 @@ test("reject - does nothing for unknown requestID", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await Question.reject(QuestionID.make("que_unknown"))
await reject(QuestionID.make("que_unknown"))
// Should not throw
},
})
@@ -254,19 +265,19 @@ test("ask - handles multiple questions", async () => {
},
]
const askPromise = Question.ask({
const promise = ask({
sessionID: SessionID.make("ses_test"),
questions,
})
const pending = await Question.list()
const pending = await list()
await Question.reply({
await reply({
requestID: pending[0].id,
answers: [["Build"], ["Dev"]],
})
const answers = await askPromise
const answers = await promise
expect(answers).toEqual([["Build"], ["Dev"]])
},
})
@@ -279,7 +290,7 @@ test("list - returns all pending requests", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const p1 = Question.ask({
const p1 = ask({
sessionID: SessionID.make("ses_test1"),
questions: [
{
@@ -290,7 +301,7 @@ test("list - returns all pending requests", async () => {
],
})
const p2 = Question.ask({
const p2 = ask({
sessionID: SessionID.make("ses_test2"),
questions: [
{
@@ -301,7 +312,7 @@ test("list - returns all pending requests", async () => {
],
})
const pending = await Question.list()
const pending = await list()
expect(pending.length).toBe(2)
await rejectAll()
p1.catch(() => {})
@@ -315,7 +326,7 @@ test("list - returns empty when no pending", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const pending = await Question.list()
const pending = await list()
expect(pending.length).toBe(0)
},
})
@@ -328,7 +339,7 @@ test("questions stay isolated by directory", async () => {
const p1 = Instance.provide({
directory: one.path,
fn: () =>
Question.ask({
ask({
sessionID: SessionID.make("ses_one"),
questions: [
{
@@ -343,7 +354,7 @@ test("questions stay isolated by directory", async () => {
const p2 = Instance.provide({
directory: two.path,
fn: () =>
Question.ask({
ask({
sessionID: SessionID.make("ses_two"),
questions: [
{
@@ -357,11 +368,11 @@ test("questions stay isolated by directory", async () => {
const onePending = await Instance.provide({
directory: one.path,
fn: () => Question.list(),
fn: () => list(),
})
const twoPending = await Instance.provide({
directory: two.path,
fn: () => Question.list(),
fn: () => list(),
})
expect(onePending.length).toBe(1)
@@ -371,11 +382,11 @@ test("questions stay isolated by directory", async () => {
await Instance.provide({
directory: one.path,
fn: () => Question.reject(onePending[0].id),
fn: () => reject(onePending[0].id),
})
await Instance.provide({
directory: two.path,
fn: () => Question.reject(twoPending[0].id),
fn: () => reject(twoPending[0].id),
})
await p1.catch(() => {})
@@ -385,10 +396,10 @@ test("questions stay isolated by directory", async () => {
test("pending question rejects on instance dispose", async () => {
await using tmp = await tmpdir({ git: true })
const ask = Instance.provide({
const pending = Instance.provide({
directory: tmp.path,
fn: () => {
return Question.ask({
return ask({
sessionID: SessionID.make("ses_dispose"),
questions: [
{
@@ -400,7 +411,7 @@ test("pending question rejects on instance dispose", async () => {
})
},
})
const result = ask.then(
const result = pending.then(
() => "resolved" as const,
(err) => err,
)
@@ -408,8 +419,8 @@ test("pending question rejects on instance dispose", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const pending = await Question.list()
expect(pending).toHaveLength(1)
const items = await list()
expect(items).toHaveLength(1)
await Instance.dispose()
},
})
@@ -420,10 +431,10 @@ test("pending question rejects on instance dispose", async () => {
test("pending question rejects on instance reload", async () => {
await using tmp = await tmpdir({ git: true })
const ask = Instance.provide({
const pending = Instance.provide({
directory: tmp.path,
fn: () => {
return Question.ask({
return ask({
sessionID: SessionID.make("ses_reload"),
questions: [
{
@@ -435,7 +446,7 @@ test("pending question rejects on instance reload", async () => {
})
},
})
const result = ask.then(
const result = pending.then(
() => "resolved" as const,
(err) => err,
)
@@ -443,8 +454,8 @@ test("pending question rejects on instance reload", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const pending = await Question.list()
expect(pending).toHaveLength(1)
const items = await list()
expect(items).toHaveLength(1)
await Instance.reload({ directory: tmp.path })
},
})
@@ -1,11 +1,9 @@
import { afterEach, describe, expect, mock, spyOn, 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 { ModelID, ProviderID } from "../../src/provider/schema"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { SessionPrompt } from "../../src/session/prompt"
import { SessionRunState } from "../../src/session/run-state"
import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture"
@@ -16,25 +14,6 @@ afterEach(async () => {
await Instance.disposeAll()
})
async function user(sessionID: SessionID, text: string) {
const msg = await Session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID,
agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
time: { created: Date.now() },
})
await Session.updatePart({
id: PartID.ascending(),
sessionID,
messageID: msg.id,
type: "text",
text,
})
return msg
}
describe("session action routes", () => {
test("abort route calls SessionPrompt.cancel", async () => {
await using tmp = await tmpdir({ git: true })
@@ -45,9 +24,7 @@ describe("session action routes", () => {
const cancel = spyOn(SessionPrompt, "cancel").mockResolvedValue()
const app = Server.Default().app
const res = await app.request(`/session/${session.id}/abort`, {
method: "POST",
})
const res = await app.request(`/session/${session.id}/abort`, { method: "POST" })
expect(res.status).toBe(200)
expect(await res.json()).toBe(true)
@@ -57,28 +34,4 @@ describe("session action routes", () => {
},
})
})
test("delete message route returns 400 when session is busy", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const msg = await user(session.id, "hello")
const busy = spyOn(SessionRunState, "assertNotBusy").mockRejectedValue(new Session.BusyError(session.id))
const remove = spyOn(Session, "removeMessage").mockResolvedValue(msg.id)
const app = Server.Default().app
const res = await app.request(`/session/${session.id}/message/${msg.id}`, {
method: "DELETE",
})
expect(res.status).toBe(400)
expect(busy).toHaveBeenCalledWith(session.id)
expect(remove).not.toHaveBeenCalled()
await Session.remove(session.id)
},
})
})
})
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import path from "path"
import { Effect } from "effect"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instruction } from "../../src/session/instruction"
import type { MessageV2 } from "../../src/session/message-v2"
@@ -8,6 +9,9 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { Global } from "../../src/global"
import { tmpdir } from "../fixture/fixture"
const run = <A>(effect: Effect.Effect<A, any, Instruction.Service>) =>
Effect.runPromise(effect.pipe(Effect.provide(Instruction.defaultLayer)))
function loaded(filepath: string): MessageV2.WithParts[] {
const sessionID = SessionID.make("session-loaded-1")
const messageID = MessageID.make("message-loaded-1")
@@ -57,17 +61,22 @@ describe("Instruction.resolve", () => {
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const system = await Instruction.systemPaths()
expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true)
fn: () =>
run(
Instruction.Service.use((svc) =>
Effect.gen(function* () {
const system = yield* svc.systemPaths()
expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true)
const results = await Instruction.resolve(
[],
path.join(tmp.path, "src", "file.ts"),
MessageID.make("message-test-1"),
)
expect(results).toEqual([])
},
const results = yield* svc.resolve(
[],
path.join(tmp.path, "src", "file.ts"),
MessageID.make("message-test-1"),
)
expect(results).toEqual([])
}),
),
),
})
})
@@ -80,18 +89,23 @@ describe("Instruction.resolve", () => {
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const system = await Instruction.systemPaths()
expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false)
fn: () =>
run(
Instruction.Service.use((svc) =>
Effect.gen(function* () {
const system = yield* svc.systemPaths()
expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false)
const results = await Instruction.resolve(
[],
path.join(tmp.path, "subdir", "nested", "file.ts"),
MessageID.make("message-test-2"),
)
expect(results.length).toBe(1)
expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
},
const results = yield* svc.resolve(
[],
path.join(tmp.path, "subdir", "nested", "file.ts"),
MessageID.make("message-test-2"),
)
expect(results.length).toBe(1)
expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
}),
),
),
})
})
@@ -104,14 +118,19 @@ describe("Instruction.resolve", () => {
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const filepath = path.join(tmp.path, "subdir", "AGENTS.md")
const system = await Instruction.systemPaths()
expect(system.has(filepath)).toBe(false)
fn: () =>
run(
Instruction.Service.use((svc) =>
Effect.gen(function* () {
const filepath = path.join(tmp.path, "subdir", "AGENTS.md")
const system = yield* svc.systemPaths()
expect(system.has(filepath)).toBe(false)
const results = await Instruction.resolve([], filepath, MessageID.make("message-test-3"))
expect(results).toEqual([])
},
const results = yield* svc.resolve([], filepath, MessageID.make("message-test-3"))
expect(results).toEqual([])
}),
),
),
})
})
@@ -124,17 +143,22 @@ describe("Instruction.resolve", () => {
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
const id = MessageID.make("message-claim-1")
fn: () =>
run(
Instruction.Service.use((svc) =>
Effect.gen(function* () {
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
const id = MessageID.make("message-claim-1")
const first = await Instruction.resolve([], filepath, id)
const second = await Instruction.resolve([], filepath, id)
const first = yield* svc.resolve([], filepath, id)
const second = yield* svc.resolve([], filepath, id)
expect(first).toHaveLength(1)
expect(first[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
expect(second).toEqual([])
},
expect(first).toHaveLength(1)
expect(first[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
expect(second).toEqual([])
}),
),
),
})
})
@@ -147,18 +171,23 @@ describe("Instruction.resolve", () => {
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
const id = MessageID.make("message-claim-2")
fn: () =>
run(
Instruction.Service.use((svc) =>
Effect.gen(function* () {
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
const id = MessageID.make("message-claim-2")
const first = await Instruction.resolve([], filepath, id)
await Instruction.clear(id)
const second = await Instruction.resolve([], filepath, id)
const first = yield* svc.resolve([], filepath, id)
yield* svc.clear(id)
const second = yield* svc.resolve([], filepath, id)
expect(first).toHaveLength(1)
expect(second).toHaveLength(1)
expect(second[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
},
expect(first).toHaveLength(1)
expect(second).toHaveLength(1)
expect(second[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
}),
),
),
})
})
@@ -171,15 +200,19 @@ describe("Instruction.resolve", () => {
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const agents = path.join(tmp.path, "subdir", "AGENTS.md")
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
const id = MessageID.make("message-claim-3")
fn: () =>
run(
Instruction.Service.use((svc) =>
Effect.gen(function* () {
const agents = path.join(tmp.path, "subdir", "AGENTS.md")
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
const id = MessageID.make("message-claim-3")
const results = await Instruction.resolve(loaded(agents), filepath, id)
expect(results).toEqual([])
},
const results = yield* svc.resolve(loaded(agents), filepath, id)
expect(results).toEqual([])
}),
),
),
})
})
@@ -221,11 +254,16 @@ describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => {
try {
await Instance.provide({
directory: projectTmp.path,
fn: async () => {
const paths = await Instruction.systemPaths()
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(true)
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(false)
},
fn: () =>
run(
Instruction.Service.use((svc) =>
Effect.gen(function* () {
const paths = yield* svc.systemPaths()
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(true)
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(false)
}),
),
),
})
} finally {
;(Global.Path as { config: string }).config = originalGlobalConfig
@@ -248,11 +286,16 @@ describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => {
try {
await Instance.provide({
directory: projectTmp.path,
fn: async () => {
const paths = await Instruction.systemPaths()
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(false)
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
},
fn: () =>
run(
Instruction.Service.use((svc) =>
Effect.gen(function* () {
const paths = yield* svc.systemPaths()
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(false)
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
}),
),
),
})
} finally {
;(Global.Path as { config: string }).config = originalGlobalConfig
@@ -274,10 +317,15 @@ describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => {
try {
await Instance.provide({
directory: projectTmp.path,
fn: async () => {
const paths = await Instruction.systemPaths()
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
},
fn: () =>
run(
Instruction.Service.use((svc) =>
Effect.gen(function* () {
const paths = yield* svc.systemPaths()
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
}),
),
),
})
} finally {
;(Global.Path as { config: string }).config = originalGlobalConfig
@@ -7,12 +7,21 @@ import { Instance } from "../../src/project/instance"
import { LSP } from "../../src/lsp"
import { AppFileSystem } from "../../src/filesystem"
import { Format } from "../../src/format"
import { Agent } from "../../src/agent/agent"
import { Bus } from "../../src/bus"
import { Truncate } from "../../src/tool/truncate"
import { tmpdir } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema"
const runtime = ManagedRuntime.make(
Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer),
Layer.mergeAll(
LSP.defaultLayer,
AppFileSystem.defaultLayer,
Format.defaultLayer,
Bus.layer,
Truncate.defaultLayer,
Agent.defaultLayer,
),
)
const baseCtx = {
+8 -1
View File
@@ -8,6 +8,7 @@ import { Instance } from "../../src/project/instance"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
import type { Permission } from "../../src/permission"
import { Agent } from "../../src/agent/agent"
import { Truncate } from "../../src/tool/truncate"
import { SessionID, MessageID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
@@ -15,7 +16,13 @@ import { AppFileSystem } from "../../src/filesystem"
import { Plugin } from "../../src/plugin"
const runtime = ManagedRuntime.make(
Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer, Plugin.defaultLayer),
Layer.mergeAll(
CrossSpawnSpawner.defaultLayer,
AppFileSystem.defaultLayer,
Plugin.defaultLayer,
Truncate.defaultLayer,
Agent.defaultLayer,
),
)
function initBash() {
+13 -18
View File
@@ -9,8 +9,10 @@ import { FileTime } from "../../src/file/time"
import { LSP } from "../../src/lsp"
import { AppFileSystem } from "../../src/filesystem"
import { Format } from "../../src/format"
import { Agent } from "../../src/agent/agent"
import { Bus } from "../../src/bus"
import { BusEvent } from "../../src/bus/bus-event"
import { Truncate } from "../../src/tool/truncate"
import { SessionID, MessageID } from "../../src/session/schema"
const ctx = {
@@ -33,23 +35,16 @@ async function touch(file: string, time: number) {
await fs.utimes(file, date, date)
}
async function wait(check: () => void, timeout = 1_000) {
const end = Date.now() + timeout
let err: unknown
while (Date.now() < end) {
try {
check()
return
} catch (cause) {
err = cause
await Bun.sleep(10)
}
}
throw err
}
const runtime = ManagedRuntime.make(
Layer.mergeAll(LSP.defaultLayer, FileTime.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer),
Layer.mergeAll(
LSP.defaultLayer,
FileTime.defaultLayer,
AppFileSystem.defaultLayer,
Format.defaultLayer,
Bus.layer,
Truncate.defaultLayer,
Agent.defaultLayer,
),
)
afterAll(async () => {
@@ -148,7 +143,7 @@ describe("tool.edit", () => {
),
)
await wait(() => expect(events).toContain("updated"))
expect(events).toContain("updated")
unsubUpdated()
},
})
@@ -379,7 +374,7 @@ describe("tool.edit", () => {
),
)
await wait(() => expect(events).toContain("updated"))
expect(events).toContain("updated")
unsubUpdated()
},
})
+5 -1
View File
@@ -6,8 +6,12 @@ import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Truncate } from "../../src/tool/truncate"
import { Agent } from "../../src/agent/agent"
const runtime = ManagedRuntime.make(Layer.mergeAll(CrossSpawnSpawner.defaultLayer))
const runtime = ManagedRuntime.make(
Layer.mergeAll(CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer),
)
function initGrep() {
return runtime.runPromise(GrepTool.pipe(Effect.flatMap((info) => info.init())))
+5 -1
View File
@@ -4,7 +4,9 @@ import { Tool } from "../../src/tool/tool"
import { QuestionTool } from "../../src/tool/question"
import { Question } from "../../src/question"
import { SessionID, MessageID } from "../../src/session/schema"
import { Agent } from "../../src/agent/agent"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Truncate } from "../../src/tool/truncate"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
@@ -19,7 +21,9 @@ const ctx = {
ask: () => Effect.void,
}
const it = testEffect(Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer))
const it = testEffect(
Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer),
)
const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Question.Interface) {
for (;;) {
+2
View File
@@ -11,6 +11,7 @@ import { Instance } from "../../src/project/instance"
import { SessionID, MessageID } from "../../src/session/schema"
import { Instruction } from "../../src/session/instruction"
import { ReadTool } from "../../src/tool/read"
import { Truncate } from "../../src/tool/truncate"
import { Tool } from "../../src/tool/tool"
import { Filesystem } from "../../src/util/filesystem"
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
@@ -41,6 +42,7 @@ const it = testEffect(
FileTime.defaultLayer,
Instruction.defaultLayer,
LSP.defaultLayer,
Truncate.defaultLayer,
),
)
+5 -1
View File
@@ -1,6 +1,8 @@
import { Effect, Layer, ManagedRuntime } from "effect"
import { Agent } from "../../src/agent/agent"
import { Skill } from "../../src/skill"
import { Ripgrep } from "../../src/file/ripgrep"
import { Truncate } from "../../src/tool/truncate"
import { afterEach, describe, expect, test } from "bun:test"
import path from "path"
import { pathToFileURL } from "url"
@@ -150,7 +152,9 @@ Use this skill.
await Instance.provide({
directory: tmp.path,
fn: async () => {
const runtime = ManagedRuntime.make(Layer.mergeAll(Skill.defaultLayer, Ripgrep.defaultLayer))
const runtime = ManagedRuntime.make(
Layer.mergeAll(Skill.defaultLayer, Ripgrep.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer),
)
const info = await runtime.runPromise(SkillTool)
const tool = await runtime.runPromise(info.init())
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+2
View File
@@ -10,6 +10,7 @@ import type { SessionPrompt } from "../../src/session/prompt"
import { MessageID, PartID } from "../../src/session/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { TaskTool, type TaskPromptOps } from "../../src/tool/task"
import { Truncate } from "../../src/tool/truncate"
import { ToolRegistry } from "../../src/tool/registry"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
@@ -29,6 +30,7 @@ const it = testEffect(
Config.defaultLayer,
CrossSpawnSpawner.defaultLayer,
Session.defaultLayer,
Truncate.defaultLayer,
ToolRegistry.defaultLayer,
),
)
@@ -1,7 +1,11 @@
import { describe, test, expect } from "bun:test"
import { Effect } from "effect"
import { Effect, Layer, ManagedRuntime } from "effect"
import z from "zod"
import { Agent } from "../../src/agent/agent"
import { Tool } from "../../src/tool/tool"
import { Truncate } from "../../src/tool/truncate"
const runtime = ManagedRuntime.make(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer))
const params = z.object({ input: z.string() })
@@ -21,7 +25,7 @@ describe("Tool.define", () => {
const original = makeTool("test")
const originalExecute = original.execute
const info = await Effect.runPromise(Tool.define("test-tool", Effect.succeed(original)))
const info = await runtime.runPromise(Tool.define("test-tool", Effect.succeed(original)))
await Effect.runPromise(info.init())
await Effect.runPromise(info.init())
@@ -31,7 +35,7 @@ describe("Tool.define", () => {
})
test("effect-defined tool returns fresh objects and is unaffected", async () => {
const info = await Effect.runPromise(
const info = await runtime.runPromise(
Tool.define(
"test-fn-tool",
Effect.succeed(() => Effect.succeed(makeTool("test"))),
@@ -45,7 +49,7 @@ describe("Tool.define", () => {
})
test("object-defined tool returns distinct objects per init() call", async () => {
const info = await Effect.runPromise(Tool.define("test-copy", Effect.succeed(makeTool("test"))))
const info = await runtime.runPromise(Tool.define("test-copy", Effect.succeed(makeTool("test"))))
const first = await Effect.runPromise(info.init())
const second = await Effect.runPromise(info.init())
+123 -88
View File
@@ -1,7 +1,7 @@
import { describe, test, expect } from "bun:test"
import { NodeFileSystem } from "@effect/platform-node"
import { Effect, FileSystem, Layer } from "effect"
import { Truncate, Truncate as TruncateSvc } from "../../src/tool/truncate"
import { Truncate } from "../../src/tool/truncate"
import { Identifier } from "../../src/id/id"
import { Process } from "../../src/util/process"
import { Filesystem } from "../../src/util/filesystem"
@@ -12,120 +12,155 @@ import { writeFileStringScoped } from "../lib/filesystem"
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
const ROOT = path.resolve(import.meta.dir, "..", "..")
const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, NodeFileSystem.layer))
describe("Truncate", () => {
describe("output", () => {
test("truncates large json file by bytes", async () => {
const content = await Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json"))
const result = await Truncate.output(content)
it.live("truncates large json file by bytes", () =>
Effect.gen(function* () {
const svc = yield* Truncate.Service
const content = yield* Effect.promise(() => Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json")))
const result = yield* svc.output(content)
expect(result.truncated).toBe(true)
expect(result.content).toContain("truncated...")
if (result.truncated) expect(result.outputPath).toBeDefined()
})
expect(result.truncated).toBe(true)
expect(result.content).toContain("truncated...")
if (result.truncated) expect(result.outputPath).toBeDefined()
}),
)
test("returns content unchanged when under limits", async () => {
const content = "line1\nline2\nline3"
const result = await Truncate.output(content)
it.live("returns content unchanged when under limits", () =>
Effect.gen(function* () {
const svc = yield* Truncate.Service
const content = "line1\nline2\nline3"
const result = yield* svc.output(content)
expect(result.truncated).toBe(false)
expect(result.content).toBe(content)
})
expect(result.truncated).toBe(false)
expect(result.content).toBe(content)
}),
)
test("truncates by line count", async () => {
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
const result = await Truncate.output(lines, { maxLines: 10 })
it.live("truncates by line count", () =>
Effect.gen(function* () {
const svc = yield* Truncate.Service
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
const result = yield* svc.output(lines, { maxLines: 10 })
expect(result.truncated).toBe(true)
expect(result.content).toContain("...90 lines truncated...")
})
expect(result.truncated).toBe(true)
expect(result.content).toContain("...90 lines truncated...")
}),
)
test("truncates by byte count", async () => {
const content = "a".repeat(1000)
const result = await Truncate.output(content, { maxBytes: 100 })
it.live("truncates by byte count", () =>
Effect.gen(function* () {
const svc = yield* Truncate.Service
const content = "a".repeat(1000)
const result = yield* svc.output(content, { maxBytes: 100 })
expect(result.truncated).toBe(true)
expect(result.content).toContain("truncated...")
})
expect(result.truncated).toBe(true)
expect(result.content).toContain("truncated...")
}),
)
test("truncates from head by default", async () => {
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
const result = await Truncate.output(lines, { maxLines: 3 })
it.live("truncates from head by default", () =>
Effect.gen(function* () {
const svc = yield* Truncate.Service
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
const result = yield* svc.output(lines, { maxLines: 3 })
expect(result.truncated).toBe(true)
expect(result.content).toContain("line0")
expect(result.content).toContain("line1")
expect(result.content).toContain("line2")
expect(result.content).not.toContain("line9")
})
expect(result.truncated).toBe(true)
expect(result.content).toContain("line0")
expect(result.content).toContain("line1")
expect(result.content).toContain("line2")
expect(result.content).not.toContain("line9")
}),
)
test("truncates from tail when direction is tail", async () => {
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
const result = await Truncate.output(lines, { maxLines: 3, direction: "tail" })
it.live("truncates from tail when direction is tail", () =>
Effect.gen(function* () {
const svc = yield* Truncate.Service
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
const result = yield* svc.output(lines, { maxLines: 3, direction: "tail" })
expect(result.truncated).toBe(true)
expect(result.content).toContain("line7")
expect(result.content).toContain("line8")
expect(result.content).toContain("line9")
expect(result.content).not.toContain("line0")
})
expect(result.truncated).toBe(true)
expect(result.content).toContain("line7")
expect(result.content).toContain("line8")
expect(result.content).toContain("line9")
expect(result.content).not.toContain("line0")
}),
)
test("uses default MAX_LINES and MAX_BYTES", () => {
expect(Truncate.MAX_LINES).toBe(2000)
expect(Truncate.MAX_BYTES).toBe(50 * 1024)
})
test("large single-line file truncates with byte message", async () => {
const content = await Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json"))
const result = await Truncate.output(content)
it.live("large single-line file truncates with byte message", () =>
Effect.gen(function* () {
const svc = yield* Truncate.Service
const content = yield* Effect.promise(() => Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json")))
const result = yield* svc.output(content)
expect(result.truncated).toBe(true)
expect(result.content).toContain("bytes truncated...")
expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES)
})
expect(result.truncated).toBe(true)
expect(result.content).toContain("bytes truncated...")
expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES)
}),
)
test("writes full output to file when truncated", async () => {
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
const result = await Truncate.output(lines, { maxLines: 10 })
it.live("writes full output to file when truncated", () =>
Effect.gen(function* () {
const svc = yield* Truncate.Service
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
const result = yield* svc.output(lines, { maxLines: 10 })
expect(result.truncated).toBe(true)
expect(result.content).toContain("The tool call succeeded but the output was truncated")
expect(result.content).toContain("Grep")
if (!result.truncated) throw new Error("expected truncated")
expect(result.outputPath).toBeDefined()
expect(result.outputPath).toContain("tool_")
expect(result.truncated).toBe(true)
expect(result.content).toContain("The tool call succeeded but the output was truncated")
expect(result.content).toContain("Grep")
if (!result.truncated) throw new Error("expected truncated")
expect(result.outputPath).toBeDefined()
expect(result.outputPath).toContain("tool_")
const written = await Filesystem.readText(result.outputPath!)
expect(written).toBe(lines)
})
const written = yield* Effect.promise(() => Filesystem.readText(result.outputPath!))
expect(written).toBe(lines)
}),
)
test("suggests Task tool when agent has task permission", async () => {
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
const agent = { permission: [{ permission: "task", pattern: "*", action: "allow" as const }] }
const result = await Truncate.output(lines, { maxLines: 10 }, agent as any)
it.live("suggests Task tool when agent has task permission", () =>
Effect.gen(function* () {
const svc = yield* Truncate.Service
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
const agent = { permission: [{ permission: "task", pattern: "*", action: "allow" as const }] }
const result = yield* svc.output(lines, { maxLines: 10 }, agent as any)
expect(result.truncated).toBe(true)
expect(result.content).toContain("Grep")
expect(result.content).toContain("Task tool")
})
expect(result.truncated).toBe(true)
expect(result.content).toContain("Grep")
expect(result.content).toContain("Task tool")
}),
)
test("omits Task tool hint when agent lacks task permission", async () => {
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
const agent = { permission: [{ permission: "task", pattern: "*", action: "deny" as const }] }
const result = await Truncate.output(lines, { maxLines: 10 }, agent as any)
it.live("omits Task tool hint when agent lacks task permission", () =>
Effect.gen(function* () {
const svc = yield* Truncate.Service
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
const agent = { permission: [{ permission: "task", pattern: "*", action: "deny" as const }] }
const result = yield* svc.output(lines, { maxLines: 10 }, agent as any)
expect(result.truncated).toBe(true)
expect(result.content).toContain("Grep")
expect(result.content).not.toContain("Task tool")
})
expect(result.truncated).toBe(true)
expect(result.content).toContain("Grep")
expect(result.content).not.toContain("Task tool")
}),
)
test("does not write file when not truncated", async () => {
const content = "short content"
const result = await Truncate.output(content)
it.live("does not write file when not truncated", () =>
Effect.gen(function* () {
const svc = yield* Truncate.Service
const content = "short content"
const result = yield* svc.output(content)
expect(result.truncated).toBe(false)
if (result.truncated) throw new Error("expected not truncated")
expect("outputPath" in result).toBe(false)
})
expect(result.truncated).toBe(false)
if (result.truncated) throw new Error("expected not truncated")
expect("outputPath" in result).toBe(false)
}),
)
test("loads truncate effect in a fresh process", async () => {
const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate.ts")], {
@@ -138,10 +173,10 @@ describe("Truncate", () => {
describe("cleanup", () => {
const DAY_MS = 24 * 60 * 60 * 1000
const it = testEffect(Layer.mergeAll(TruncateSvc.defaultLayer, NodeFileSystem.layer))
it.live("deletes files older than 7 days and preserves recent files", () =>
Effect.gen(function* () {
const svc = yield* Truncate.Service
const fs = yield* FileSystem.FileSystem
yield* fs.makeDirectory(Truncate.DIR, { recursive: true })
@@ -151,7 +186,7 @@ describe("Truncate", () => {
yield* writeFileStringScoped(old, "old content")
yield* writeFileStringScoped(recent, "recent content")
yield* TruncateSvc.Service.use((s) => s.cleanup())
yield* svc.cleanup()
expect(yield* fs.exists(old)).toBe(false)
expect(yield* fs.exists(recent)).toBe(true)
+9 -15
View File
@@ -1,7 +1,9 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Effect } from "effect"
import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { Agent } from "../../src/agent/agent"
import { Truncate } from "../../src/tool/truncate"
import { Instance } from "../../src/project/instance"
import { WebFetchTool } from "../../src/tool/webfetch"
import { SessionID, MessageID } from "../../src/session/schema"
@@ -24,10 +26,11 @@ async function withFetch(fetch: (req: Request) => Response | Promise<Response>,
await fn(server.url)
}
function initTool() {
function exec(args: { url: string; format: "text" | "markdown" | "html" }) {
return WebFetchTool.pipe(
Effect.flatMap((info) => info.init()),
Effect.provide(FetchHttpClient.layer),
Effect.flatMap((tool) => tool.execute(args, ctx)),
Effect.provide(Layer.mergeAll(FetchHttpClient.layer, Truncate.defaultLayer, Agent.defaultLayer)),
Effect.runPromise,
)
}
@@ -41,10 +44,7 @@ describe("tool.webfetch", () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const webfetch = await initTool()
const result = await Effect.runPromise(
webfetch.execute({ url: new URL("/image.png", url).toString(), format: "markdown" }, ctx),
)
const result = await exec({ url: new URL("/image.png", url).toString(), format: "markdown" })
expect(result.output).toBe("Image fetched successfully")
expect(result.attachments).toBeDefined()
expect(result.attachments?.length).toBe(1)
@@ -72,10 +72,7 @@ describe("tool.webfetch", () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const webfetch = await initTool()
const result = await Effect.runPromise(
webfetch.execute({ url: new URL("/image.svg", url).toString(), format: "html" }, ctx),
)
const result = await exec({ url: new URL("/image.svg", url).toString(), format: "html" })
expect(result.output).toContain("<svg")
expect(result.attachments).toBeUndefined()
},
@@ -95,10 +92,7 @@ describe("tool.webfetch", () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const webfetch = await initTool()
const result = await Effect.runPromise(
webfetch.execute({ url: new URL("/file.txt", url).toString(), format: "text" }, ctx),
)
const result = await exec({ url: new URL("/file.txt", url).toString(), format: "text" })
expect(result.output).toBe("hello from webfetch")
expect(result.attachments).toBeUndefined()
},
@@ -9,7 +9,9 @@ import { AppFileSystem } from "../../src/filesystem"
import { FileTime } from "../../src/file/time"
import { Bus } from "../../src/bus"
import { Format } from "../../src/format"
import { Truncate } from "../../src/tool/truncate"
import { Tool } from "../../src/tool/tool"
import { Agent } from "../../src/agent/agent"
import { SessionID, MessageID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { provideTmpdirInstance } from "../fixture/fixture"
@@ -38,6 +40,8 @@ const it = testEffect(
Bus.layer,
Format.defaultLayer,
CrossSpawnSpawner.defaultLayer,
Truncate.defaultLayer,
Agent.defaultLayer,
),
)
+44
View File
@@ -0,0 +1,44 @@
import { afterEach, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Global } from "../../src/global"
import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture"
const log = Global.Path.log
afterEach(() => {
Global.Path.log = log
})
async function files(dir: string) {
let last = ""
let same = 0
for (let i = 0; i < 50; i++) {
const list = (await fs.readdir(dir)).sort()
const next = JSON.stringify(list)
same = next === last ? same + 1 : 0
if (same >= 2 && list.length === 11) return list
last = next
await Bun.sleep(10)
}
return (await fs.readdir(dir)).sort()
}
test("init cleanup keeps the newest timestamped logs", async () => {
await using tmp = await tmpdir()
Global.Path.log = tmp.path
const list = Array.from({ length: 12 }, (_, i) => `2000-01-${String(i + 1).padStart(2, "0")}T000000.log`)
await Promise.all(list.map((file) => fs.writeFile(path.join(tmp.path, file), file)))
await Log.init({ print: false, dev: false })
const next = await files(tmp.path)
expect(next).not.toContain(list[0]!)
expect(next).toContain(list.at(-1)!)
})
+24 -24
View File
@@ -387,6 +387,29 @@ export type EventQuestionRejected = {
}
}
export type Todo = {
/**
* Brief description of the task
*/
content: string
/**
* Current status of the task: pending, in_progress, completed, cancelled
*/
status: string
/**
* Priority level of the task: high, medium, low
*/
priority: string
}
export type EventTodoUpdated = {
type: "todo.updated"
properties: {
sessionID: string
todos: Array<Todo>
}
}
export type SessionStatus =
| {
type: "idle"
@@ -423,29 +446,6 @@ export type EventSessionCompacted = {
}
}
export type Todo = {
/**
* Brief description of the task
*/
content: string
/**
* Current status of the task: pending, in_progress, completed, cancelled
*/
status: string
/**
* Priority level of the task: high, medium, low
*/
priority: string
}
export type EventTodoUpdated = {
type: "todo.updated"
properties: {
sessionID: string
todos: Array<Todo>
}
}
export type EventWorktreeReady = {
type: "worktree.ready"
properties: {
@@ -998,10 +998,10 @@ export type Event =
| EventQuestionAsked
| EventQuestionReplied
| EventQuestionRejected
| EventTodoUpdated
| EventSessionStatus
| EventSessionIdle
| EventSessionCompacted
| EventTodoUpdated
| EventWorktreeReady
| EventWorktreeFailed
| EventPtyCreated
+47 -47
View File
@@ -8139,6 +8139,50 @@
},
"required": ["type", "properties"]
},
"Todo": {
"type": "object",
"properties": {
"content": {
"description": "Brief description of the task",
"type": "string"
},
"status": {
"description": "Current status of the task: pending, in_progress, completed, cancelled",
"type": "string"
},
"priority": {
"description": "Priority level of the task: high, medium, low",
"type": "string"
}
},
"required": ["content", "status", "priority"]
},
"Event.todo.updated": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "todo.updated"
},
"properties": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"pattern": "^ses.*"
},
"todos": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Todo"
}
}
},
"required": ["sessionID", "todos"]
}
},
"required": ["type", "properties"]
},
"SessionStatus": {
"anyOf": [
{
@@ -8245,50 +8289,6 @@
},
"required": ["type", "properties"]
},
"Todo": {
"type": "object",
"properties": {
"content": {
"description": "Brief description of the task",
"type": "string"
},
"status": {
"description": "Current status of the task: pending, in_progress, completed, cancelled",
"type": "string"
},
"priority": {
"description": "Priority level of the task: high, medium, low",
"type": "string"
}
},
"required": ["content", "status", "priority"]
},
"Event.todo.updated": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "todo.updated"
},
"properties": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"pattern": "^ses.*"
},
"todos": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Todo"
}
}
},
"required": ["sessionID", "todos"]
}
},
"required": ["type", "properties"]
},
"Event.worktree.ready": {
"type": "object",
"properties": {
@@ -9949,6 +9949,9 @@
{
"$ref": "#/components/schemas/Event.question.rejected"
},
{
"$ref": "#/components/schemas/Event.todo.updated"
},
{
"$ref": "#/components/schemas/Event.session.status"
},
@@ -9958,9 +9961,6 @@
{
"$ref": "#/components/schemas/Event.session.compacted"
},
{
"$ref": "#/components/schemas/Event.todo.updated"
},
{
"$ref": "#/components/schemas/Event.worktree.ready"
},