mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-04-20 21:00:29 +08:00
Merge branch 'dev' into snapshot-node-shim-stuff
This commit is contained in:
1
.github/VOUCHED.td
vendored
1
.github/VOUCHED.td
vendored
@@ -26,6 +26,7 @@ kommander
|
||||
r44vc0rp
|
||||
rekram1-node
|
||||
-robinmordasiewicz
|
||||
simonklee
|
||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||
thdxr
|
||||
-toastythebot
|
||||
|
||||
8
.github/workflows/publish.yml
vendored
8
.github/workflows/publish.yml
vendored
@@ -114,7 +114,7 @@ jobs:
|
||||
- build-cli
|
||||
- version
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -213,7 +213,6 @@ jobs:
|
||||
needs:
|
||||
- build-cli
|
||||
- version
|
||||
if: github.ref_name != 'beta'
|
||||
continue-on-error: false
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
@@ -390,7 +389,7 @@ jobs:
|
||||
needs:
|
||||
- build-cli
|
||||
- version
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
continue-on-error: false
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
@@ -591,13 +590,12 @@ jobs:
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
if: github.ref_name != 'beta'
|
||||
with:
|
||||
name: opencode-cli-signed-windows
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
if: needs.version.outputs.release && github.ref_name != 'beta'
|
||||
if: needs.version.outputs.release
|
||||
with:
|
||||
pattern: latest-yml-*
|
||||
path: /tmp/latest-yml
|
||||
|
||||
@@ -14,18 +14,11 @@
|
||||
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
||||
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
||||
"dev": "bun run --conditions=browser ./src/index.ts",
|
||||
"random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'",
|
||||
"clean": "echo 'Cleaning up...' && rm -rf node_modules dist",
|
||||
"lint": "echo 'Running lint checks...' && bun test --coverage",
|
||||
"format": "echo 'Formatting code...' && bun run --prettier --write src/**/*.ts",
|
||||
"docs": "echo 'Generating documentation...' && find src -name '*.ts' -exec echo 'Processing: {}' \\;",
|
||||
"deploy": "echo 'Deploying application...' && bun run build && echo 'Deployment completed successfully'",
|
||||
"db": "bun drizzle-kit"
|
||||
},
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode"
|
||||
},
|
||||
"randomField": "this-is-a-random-value-12345",
|
||||
"exports": {
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { iife } from "@/util/iife"
|
||||
import { Log } from "../../util/log"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { CopilotModels } from "./models"
|
||||
import { MessageV2 } from "@/session/message-v2"
|
||||
|
||||
const log = Log.create({ service: "plugin.copilot" })
|
||||
|
||||
@@ -27,11 +28,27 @@ function base(enterpriseUrl?: string) {
|
||||
return enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : "https://api.githubcopilot.com"
|
||||
}
|
||||
|
||||
function fix(model: Model): Model {
|
||||
// Check if a message is a synthetic user msg used to attach an image from a tool call
|
||||
function imgMsg(msg: any): boolean {
|
||||
if (msg?.role !== "user") return false
|
||||
|
||||
// Handle the 3 api formats
|
||||
|
||||
const content = msg.content
|
||||
if (typeof content === "string") return content === MessageV2.SYNTHETIC_ATTACHMENT_PROMPT
|
||||
if (!Array.isArray(content)) return false
|
||||
return content.some(
|
||||
(part: any) =>
|
||||
(part?.type === "text" || part?.type === "input_text") && part.text === MessageV2.SYNTHETIC_ATTACHMENT_PROMPT,
|
||||
)
|
||||
}
|
||||
|
||||
function fix(model: Model, url: string): Model {
|
||||
return {
|
||||
...model,
|
||||
api: {
|
||||
...model.api,
|
||||
url,
|
||||
npm: "@ai-sdk/github-copilot",
|
||||
},
|
||||
}
|
||||
@@ -44,19 +61,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 +87,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()
|
||||
@@ -88,7 +106,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
(msg: any) =>
|
||||
Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"),
|
||||
),
|
||||
isAgent: last?.role !== "user",
|
||||
isAgent: last?.role !== "user" || imgMsg(last),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +118,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
(item: any) =>
|
||||
Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"),
|
||||
),
|
||||
isAgent: last?.role !== "user",
|
||||
isAgent: last?.role !== "user" || imgMsg(last),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +140,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
part.content.some((nested: any) => nested?.type === "image")),
|
||||
),
|
||||
),
|
||||
isAgent: !(last?.role === "user" && hasNonToolCalls),
|
||||
isAgent: !(last?.role === "user" && hasNonToolCalls) || imgMsg(last),
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated by build.ts - do not edit
|
||||
export declare const snapshot: Record<string, unknown>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -708,9 +708,8 @@ export namespace Session {
|
||||
runPromise((svc) => svc.setArchived(input)),
|
||||
)
|
||||
|
||||
export const setPermission = fn(
|
||||
z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset }),
|
||||
(input) => runPromise((svc) => svc.setPermission(input)),
|
||||
export const setPermission = fn(z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset }), (input) =>
|
||||
runPromise((svc) => svc.setPermission(input)),
|
||||
)
|
||||
|
||||
export const setRevert = fn(
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ interface FetchDecompressionError extends Error {
|
||||
}
|
||||
|
||||
export namespace MessageV2 {
|
||||
export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:"
|
||||
|
||||
export function isMedia(mime: string) {
|
||||
return mime.startsWith("image/") || mime === "application/pdf"
|
||||
}
|
||||
@@ -808,7 +810,7 @@ export namespace MessageV2 {
|
||||
parts: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: "Attached image(s) from tool result:",
|
||||
text: SYNTHETIC_ATTACHMENT_PROMPT,
|
||||
},
|
||||
...media.map((attachment) => ({
|
||||
type: "file" as const,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,8 +177,39 @@ export namespace Snapshot {
|
||||
const all = Array.from(new Set([...tracked, ...untracked]))
|
||||
if (!all.length) return
|
||||
|
||||
// Filter out files that are now gitignored even if previously tracked
|
||||
// Files may have been tracked before being gitignored, so we need to check
|
||||
// against the source project's current gitignore rules
|
||||
// Use --no-index to check purely against patterns (ignoring whether file is tracked)
|
||||
const checkArgs = [
|
||||
...quote,
|
||||
"--git-dir",
|
||||
path.join(state.worktree, ".git"),
|
||||
"--work-tree",
|
||||
state.worktree,
|
||||
"check-ignore",
|
||||
"--no-index",
|
||||
"--",
|
||||
...all,
|
||||
]
|
||||
const check = yield* git(checkArgs, { cwd: state.directory })
|
||||
const ignored =
|
||||
check.code === 0 ? new Set(check.text.trim().split("\n").filter(Boolean)) : new Set<string>()
|
||||
const filtered = all.filter((item) => !ignored.has(item))
|
||||
|
||||
// Remove newly-ignored files from snapshot index to prevent re-adding
|
||||
if (ignored.size > 0) {
|
||||
const ignoredFiles = Array.from(ignored)
|
||||
log.info("removing gitignored files from snapshot", { count: ignoredFiles.length })
|
||||
yield* git([...cfg, ...args(["rm", "--cached", "-f", "--", ...ignoredFiles])], {
|
||||
cwd: state.directory,
|
||||
})
|
||||
}
|
||||
|
||||
if (!filtered.length) return
|
||||
|
||||
const large = (yield* Effect.all(
|
||||
all.map((item) =>
|
||||
filtered.map((item) =>
|
||||
fs
|
||||
.stat(path.join(state.directory, item))
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
@@ -259,14 +290,39 @@ export namespace Snapshot {
|
||||
log.warn("failed to get diff", { hash, exitCode: result.code })
|
||||
return { hash, files: [] }
|
||||
}
|
||||
const files = result.text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
// Filter out files that are now gitignored
|
||||
if (files.length > 0) {
|
||||
const checkArgs = [
|
||||
...quote,
|
||||
"--git-dir",
|
||||
path.join(state.worktree, ".git"),
|
||||
"--work-tree",
|
||||
state.worktree,
|
||||
"check-ignore",
|
||||
"--no-index",
|
||||
"--",
|
||||
...files,
|
||||
]
|
||||
const check = yield* git(checkArgs, { cwd: state.directory })
|
||||
if (check.code === 0) {
|
||||
const ignored = new Set(check.text.trim().split("\n").filter(Boolean))
|
||||
const filtered = files.filter((item) => !ignored.has(item))
|
||||
return {
|
||||
hash,
|
||||
files: filtered.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hash,
|
||||
files: result.text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
|
||||
files: files.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -616,6 +672,30 @@ export namespace Snapshot {
|
||||
} satisfies Row,
|
||||
]
|
||||
})
|
||||
|
||||
// Filter out files that are now gitignored
|
||||
if (rows.length > 0) {
|
||||
const files = rows.map((r) => r.file)
|
||||
const checkArgs = [
|
||||
...quote,
|
||||
"--git-dir",
|
||||
path.join(state.worktree, ".git"),
|
||||
"--work-tree",
|
||||
state.worktree,
|
||||
"check-ignore",
|
||||
"--no-index",
|
||||
"--",
|
||||
...files,
|
||||
]
|
||||
const check = yield* git(checkArgs, { cwd: state.directory })
|
||||
if (check.code === 0) {
|
||||
const ignored = new Set(check.text.trim().split("\n").filter(Boolean))
|
||||
const filtered = rows.filter((r) => !ignored.has(r.file))
|
||||
rows.length = 0
|
||||
rows.push(...filtered)
|
||||
}
|
||||
}
|
||||
|
||||
const step = 100
|
||||
const patch = (file: string, before: string, after: string) =>
|
||||
formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -10,59 +10,106 @@ export namespace Message {
|
||||
})),
|
||||
)
|
||||
|
||||
export class File extends Schema.Class<File>("Message.File")({
|
||||
url: Schema.String,
|
||||
export class Source extends Schema.Class<Source>("Message.Source")({
|
||||
start: Schema.Number,
|
||||
end: Schema.Number,
|
||||
text: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class FileAttachment extends Schema.Class<FileAttachment>("Message.File.Attachment")({
|
||||
uri: Schema.String,
|
||||
mime: Schema.String,
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
source: Source.pipe(Schema.optional),
|
||||
}) {
|
||||
static create(url: string) {
|
||||
return new File({
|
||||
url,
|
||||
return new FileAttachment({
|
||||
uri: url,
|
||||
mime: "text/plain",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class UserContent extends Schema.Class<UserContent>("Message.User.Content")({
|
||||
text: Schema.String,
|
||||
synthetic: Schema.Boolean.pipe(Schema.optional),
|
||||
agent: Schema.String.pipe(Schema.optional),
|
||||
files: Schema.Array(File).pipe(Schema.optional),
|
||||
export class AgentAttachment extends Schema.Class<AgentAttachment>("Message.Agent.Attachment")({
|
||||
name: Schema.String,
|
||||
source: Source.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class User extends Schema.Class<User>("Message.User")({
|
||||
id: ID,
|
||||
type: Schema.Literal("user"),
|
||||
text: Schema.String,
|
||||
files: Schema.Array(FileAttachment).pipe(Schema.optional),
|
||||
agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
|
||||
time: Schema.Struct({
|
||||
created: Schema.DateTimeUtc,
|
||||
}),
|
||||
content: UserContent,
|
||||
}) {
|
||||
static create(content: Schema.Schema.Type<typeof UserContent>) {
|
||||
static create(input: { text: User["text"]; files?: User["files"]; agents?: User["agents"] }) {
|
||||
const msg = new User({
|
||||
id: ID.create(),
|
||||
type: "user",
|
||||
...input,
|
||||
time: {
|
||||
created: Effect.runSync(DateTime.now),
|
||||
},
|
||||
content,
|
||||
})
|
||||
return msg
|
||||
}
|
||||
|
||||
static file(url: string) {
|
||||
return new File({
|
||||
url,
|
||||
mime: "text/plain",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export namespace User {}
|
||||
export class Synthetic extends Schema.Class<Synthetic>("Message.Synthetic")({
|
||||
id: ID,
|
||||
type: Schema.Literal("synthetic"),
|
||||
text: Schema.String,
|
||||
time: Schema.Struct({
|
||||
created: Schema.DateTimeUtc,
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class Request extends Schema.Class<Request>("Message.Request")({
|
||||
id: ID,
|
||||
type: Schema.Literal("start"),
|
||||
model: Schema.Struct({
|
||||
id: Schema.String,
|
||||
providerID: Schema.String,
|
||||
variant: Schema.String.pipe(Schema.optional),
|
||||
}),
|
||||
time: Schema.Struct({
|
||||
created: Schema.DateTimeUtc,
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class Text extends Schema.Class<Text>("Message.Text")({
|
||||
id: ID,
|
||||
type: Schema.Literal("text"),
|
||||
text: Schema.String,
|
||||
time: Schema.Struct({
|
||||
created: Schema.DateTimeUtc,
|
||||
completed: Schema.DateTimeUtc.pipe(Schema.optional),
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class Complete extends Schema.Class<Complete>("Message.Complete")({
|
||||
id: ID,
|
||||
type: Schema.Literal("complete"),
|
||||
time: Schema.Struct({
|
||||
created: Schema.DateTimeUtc,
|
||||
}),
|
||||
cost: Schema.Number,
|
||||
tokens: Schema.Struct({
|
||||
total: Schema.Number,
|
||||
input: Schema.Number,
|
||||
output: Schema.Number,
|
||||
reasoning: Schema.Number,
|
||||
cache: Schema.Struct({
|
||||
read: Schema.Number,
|
||||
write: Schema.Number,
|
||||
}),
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export const Info = Schema.Union([User, Text])
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
}
|
||||
|
||||
const msg = Message.User.create({
|
||||
text: "Hello world",
|
||||
files: [Message.File.create("file://example.com/file.txt")],
|
||||
})
|
||||
|
||||
console.log(JSON.stringify(msg, null, 2))
|
||||
|
||||
71
packages/opencode/src/v2/session.ts
Normal file
71
packages/opencode/src/v2/session.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Context, Layer, Schema, Effect } from "effect"
|
||||
import { Message } from "./message"
|
||||
import { Struct } from "effect"
|
||||
import { Identifier } from "@/id/id"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { Session } from "@/session"
|
||||
import { SessionID } from "@/session/schema"
|
||||
|
||||
export namespace SessionV2 {
|
||||
export const ID = SessionID
|
||||
|
||||
export type ID = Schema.Schema.Type<typeof ID>
|
||||
|
||||
export class PromptInput extends Schema.Class<PromptInput>("Session.PromptInput")({
|
||||
...Struct.omit(Message.User.fields, ["time", "type"]),
|
||||
id: Schema.optionalKey(Message.ID),
|
||||
sessionID: SessionV2.ID,
|
||||
}) {}
|
||||
|
||||
export class CreateInput extends Schema.Class<CreateInput>("Session.CreateInput")({
|
||||
id: Schema.optionalKey(SessionV2.ID),
|
||||
}) {}
|
||||
|
||||
export class Info extends Schema.Class<Info>("Session.Info")({
|
||||
id: SessionV2.ID,
|
||||
model: Schema.Struct({
|
||||
id: Schema.String,
|
||||
providerID: Schema.String,
|
||||
modelID: Schema.String,
|
||||
}).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
fromID: (id: SessionV2.ID) => Effect.Effect<Info>
|
||||
create: (input: CreateInput) => Effect.Effect<Info>
|
||||
prompt: (input: PromptInput) => Effect.Effect<Message.User>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("Session.Service") {}
|
||||
|
||||
export const layer = Layer.effect(Service)(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
|
||||
const create: Interface["create"] = Effect.fn("Session.create")(function* (input) {
|
||||
throw new Error("Not implemented")
|
||||
})
|
||||
|
||||
const prompt: Interface["prompt"] = Effect.fn("Session.prompt")(function* (input) {
|
||||
throw new Error("Not implemented")
|
||||
})
|
||||
|
||||
const fromID: Interface["fromID"] = Effect.fn("Session.fromID")(function* (id) {
|
||||
const match = yield* session.get(id)
|
||||
return fromV1(match)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
create,
|
||||
prompt,
|
||||
fromID,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
function fromV1(input: Session.Info): Info {
|
||||
return new Info({
|
||||
id: SessionV2.ID.make(input.id),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -511,6 +511,49 @@ test("circular symlinks", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("source project gitignore is respected - ignored files are not snapshotted", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
// Create gitignore BEFORE any tracking
|
||||
await Filesystem.write(`${dir}/.gitignore`, "*.ignored\nbuild/\nnode_modules/\n")
|
||||
await Filesystem.write(`${dir}/tracked.txt`, "tracked content")
|
||||
await Filesystem.write(`${dir}/ignored.ignored`, "ignored content")
|
||||
await $`mkdir -p ${dir}/build`.quiet()
|
||||
await Filesystem.write(`${dir}/build/output.js`, "build output")
|
||||
await Filesystem.write(`${dir}/normal.js`, "normal js")
|
||||
await $`git add .`.cwd(dir).quiet()
|
||||
await $`git commit -m init`.cwd(dir).quiet()
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
// Modify tracked files and create new ones - some ignored, some not
|
||||
await Filesystem.write(`${tmp.path}/tracked.txt`, "modified tracked")
|
||||
await Filesystem.write(`${tmp.path}/new.ignored`, "new ignored")
|
||||
await Filesystem.write(`${tmp.path}/new-tracked.txt`, "new tracked")
|
||||
await Filesystem.write(`${tmp.path}/build/new-build.js`, "new build file")
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
|
||||
// Modified and new tracked files should be in snapshot
|
||||
expect(patch.files).toContain(fwd(tmp.path, "new-tracked.txt"))
|
||||
expect(patch.files).toContain(fwd(tmp.path, "tracked.txt"))
|
||||
|
||||
// Ignored files should NOT be in snapshot
|
||||
expect(patch.files).not.toContain(fwd(tmp.path, "new.ignored"))
|
||||
expect(patch.files).not.toContain(fwd(tmp.path, "ignored.ignored"))
|
||||
expect(patch.files).not.toContain(fwd(tmp.path, "build/output.js"))
|
||||
expect(patch.files).not.toContain(fwd(tmp.path, "build/new-build.js"))
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("gitignore changes", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
@@ -535,6 +578,75 @@ test("gitignore changes", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("files tracked in snapshot but now gitignored are filtered out", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// First, create a file and snapshot it
|
||||
await Filesystem.write(`${tmp.path}/later-ignored.txt`, "initial content")
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
// Modify the file (so it appears in diff-files)
|
||||
await Filesystem.write(`${tmp.path}/later-ignored.txt`, "modified content")
|
||||
|
||||
// Now add gitignore that would exclude this file
|
||||
await Filesystem.write(`${tmp.path}/.gitignore`, "later-ignored.txt\n")
|
||||
|
||||
// Also create another tracked file
|
||||
await Filesystem.write(`${tmp.path}/still-tracked.txt`, "new tracked file")
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
|
||||
// The file that is now gitignored should NOT appear, even though it was
|
||||
// previously tracked and modified
|
||||
expect(patch.files).not.toContain(fwd(tmp.path, "later-ignored.txt"))
|
||||
|
||||
// The gitignore file itself should appear
|
||||
expect(patch.files).toContain(fwd(tmp.path, ".gitignore"))
|
||||
|
||||
// Other tracked files should appear
|
||||
expect(patch.files).toContain(fwd(tmp.path, "still-tracked.txt"))
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("gitignore updated between track calls filters from diff", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// a.txt is already committed from bootstrap - track it in snapshot
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
// Modify a.txt (so it appears in diff-files)
|
||||
await Filesystem.write(`${tmp.path}/a.txt`, "modified content")
|
||||
|
||||
// Now add gitignore that would exclude a.txt
|
||||
await Filesystem.write(`${tmp.path}/.gitignore`, "a.txt\n")
|
||||
|
||||
// Also modify b.txt which is not gitignored
|
||||
await Filesystem.write(`${tmp.path}/b.txt`, "also modified")
|
||||
|
||||
// Second track - should not include a.txt even though it changed
|
||||
const after = await Snapshot.track()
|
||||
expect(after).toBeTruthy()
|
||||
|
||||
// Verify a.txt is NOT in the diff between snapshots
|
||||
const diffs = await Snapshot.diffFull(before!, after!)
|
||||
expect(diffs.some((x) => x.file === "a.txt")).toBe(false)
|
||||
|
||||
// But .gitignore should be in the diff
|
||||
expect(diffs.some((x) => x.file === ".gitignore")).toBe(true)
|
||||
|
||||
// b.txt should be in the diff (not gitignored)
|
||||
expect(diffs.some((x) => x.file === "b.txt")).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("git info exclude changes", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
|
||||
@@ -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,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() {
|
||||
|
||||
@@ -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 = {
|
||||
@@ -34,7 +36,15 @@ async function touch(file: string, time: number) {
|
||||
}
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -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())))
|
||||
|
||||
@@ -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 (;;) {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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">> = []
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
packages/opencode/test/util/log.test.ts
Normal file
44
packages/opencode/test/util/log.test.ts
Normal 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)!)
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -2550,6 +2550,9 @@
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"permission": {
|
||||
"$ref": "#/components/schemas/PermissionRuleset"
|
||||
},
|
||||
"time": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -8136,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": [
|
||||
{
|
||||
@@ -8242,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": {
|
||||
@@ -9946,6 +9949,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.question.rejected"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.todo.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.session.status"
|
||||
},
|
||||
@@ -9955,9 +9961,6 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.session.compacted"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.todo.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.worktree.ready"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user