Compare commits

...
Author SHA1 Message Date
Kit Langton e48341bad9 test(project): migrate project tests to Effect runner 2026-05-12 15:47:06 -04:00
Kit LangtonandGitHub e540daabc4 test(agent): migrate plan bypass tests to Effect runner (#27119) 2026-05-12 14:56:01 -04:00
opencode-agent[bot] f3c91c5f96 chore: generate 2026-05-12 18:52:25 +00:00
Kit LangtonandGitHub 549b146ea6 Stabilize session event tests (#27117) 2026-05-12 18:51:18 +00:00
Kit LangtonandGitHub 3c7569d852 test(tool): migrate external directory tests to Effect runner (#27122) 2026-05-12 18:50:56 +00:00
Kit LangtonandGitHub 8f1ded9e08 test(file): migrate ripgrep tests to Effect runner (#27120) 2026-05-12 18:50:26 +00:00
Kit LangtonandGitHub b668af29dd test(git): migrate git tests to Effect runner (#27121) 2026-05-12 18:50:07 +00:00
Kit LangtonandGitHub ec30ff9120 test(agent): migrate agent tests to Effect runner (#27118) 2026-05-12 18:49:30 +00:00
opencode-agent[bot] a3714d4399 chore: update nix node_modules hashes 2026-05-12 18:27:42 +00:00
Kit LangtonandGitHub 822eec0d62 Fix runner cancel completion (#27115) 2026-05-12 14:22:56 -04:00
Kit LangtonandGitHub 3974520742 Migrate UI cancel error to tagged error (#27112) 2026-05-12 18:09:00 +00:00
Kit LangtonandGitHub fda37b3609 Remove Zod from app global SDK (#27111) 2026-05-12 14:02:12 -04:00
Kit LangtonandGitHub 6b950b666a Remove Zod from core dependencies (#27107) 2026-05-12 13:51:08 -04:00
Kit LangtonandGitHub bc4fdb8370 Remove unused app ID schema (#27105) 2026-05-12 13:09:23 -04:00
Kit LangtonandGitHub 2b9af91568 Remove Zod from core log (#27102) 2026-05-12 13:08:57 -04:00
Kit LangtonandGitHub 53a3f95088 Make core fn Zod import type-only (#27103) 2026-05-12 12:58:50 -04:00
Dax Raad 5a4596c879 core: Wait 3 days before installing new package versions to reduce supply chain risk 2026-05-12 12:50:32 -04:00
opencode-agent[bot] 0ce614a280 chore: generate 2026-05-12 16:46:35 +00:00
Kit LangtonandGitHub e8125e9b42 test(server): migrate session list tests to Effect runner (#27101) 2026-05-12 16:45:05 +00:00
Kit LangtonandGitHub a7b5041674 test(file): migrate fsmonitor tests to Effect runner (#27099) 2026-05-12 16:44:04 +00:00
Kit LangtonandGitHub a16789dfdd test(tool): migrate apply patch tests to Effect runner (#27100) 2026-05-12 16:43:33 +00:00
Kit LangtonandGitHub 8115004c73 test(file): migrate path traversal tests to Effect runner (#27098) 2026-05-12 16:42:49 +00:00
Kit LangtonandGitHub ec4fdaf8e9 test(tool): migrate tool define tests to Effect runner (#27097) 2026-05-12 16:42:19 +00:00
Shoubhit DashandGitHub 3dc2c1d81c fix(session): preserve usage update timestamps (#27094) 2026-05-12 22:10:28 +05:30
Kit LangtonandGitHub d658e1e350 Remove local MCP Zod schema (#27095) 2026-05-12 12:38:27 -04:00
opencode-agent[bot] 30e3fa1de9 chore: generate 2026-05-12 16:33:06 +00:00
Aiden ClineandGitHub 23f8b3eb3e fix: annotate Effect log metadata (#27093) 2026-05-12 11:31:18 -05:00
Kit LangtonandGitHub c7d8b0d565 Delete named schema error wrapper (#27066) 2026-05-12 12:04:28 -04:00
Kit LangtonandGitHub 257fcafc83 test(tool): migrate edit concurrency test (#26983) 2026-05-12 11:52:31 -04:00
Kit LangtonandGitHub 04aafe2bfc test(provider): migrate more config-backed cases (#27067) 2026-05-12 14:19:49 +00:00
opencode-agent[bot] 0fd0facc44 chore: generate 2026-05-12 13:31:03 +00:00
Kit LangtonandGitHub 0de3b67cc0 test(tool): migrate shell tests to Effect runner (#26968) 2026-05-12 13:28:46 +00:00
47 changed files with 3295 additions and 3591 deletions
-2
View File
@@ -65,7 +65,6 @@
"solid-list": "catalog:",
"tailwindcss": "catalog:",
"virtua": "catalog:",
"zod": "catalog:",
},
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
@@ -214,7 +213,6 @@
"npm-package-arg": "13.0.2",
"semver": "^7.6.3",
"xdg-basedir": "5.1.0",
"zod": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
+2 -1
View File
@@ -1,6 +1,7 @@
[install]
exact = true
# Only install newly resolved package versions published at least 3 days ago.
minimumReleaseAge = 259200
[test]
root = "./do-not-run-tests-from-root"
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-Q9r1S15YL9LQK7DRhuOpw3Fxi24BPovEM995GZJayKw=",
"aarch64-linux": "sha256-C0rRTLnxxuuEkCBc3JZbkR66TUVwpcPFif3BU9GRAuA=",
"aarch64-darwin": "sha256-1HvalOO/pOkRlYH8CZ93psapt90C+pYzui1JCadBE1Q=",
"x86_64-darwin": "sha256-RrndyLWfhWm4mZ88XytFF2NI+ly8la550Z5LBN/g5u4="
"x86_64-linux": "sha256-MUHog06sZEi6bXR1m8exdkjSNW9bHEv9bPQXACJ7SFw=",
"aarch64-linux": "sha256-3dwdZ3It++OsdGT8xMOQ10Arz8eeODp/LXOrI4DLEhY=",
"aarch64-darwin": "sha256-TmUPGDCewjsrT13npVH6B55J43NKKut67p/HgPJpQNM=",
"x86_64-darwin": "sha256-j8I7t3MZoUQUMFRWyaFO75TRbAw5TauSZAa4yKOHFMA="
}
}
+1 -2
View File
@@ -73,7 +73,6 @@
"solid-js": "catalog:",
"solid-list": "catalog:",
"tailwindcss": "catalog:",
"virtua": "catalog:",
"zod": "catalog:"
"virtua": "catalog:"
}
}
+3 -5
View File
@@ -3,15 +3,13 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener"
import { batch, onCleanup, onMount } from "solid-js"
import z from "zod"
import { createSdkForServer } from "@/utils/server"
import { useLanguage } from "./language"
import { usePlatform } from "./platform"
import { useServer } from "./server"
const abortError = z.object({
name: z.literal("AbortError"),
})
const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
name: "GlobalSDK",
@@ -103,7 +101,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
let streamErrorLogged = false
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
const aborted = (error: unknown) => abortError.safeParse(error).success
const aborted = isAbortError
let attempt: AbortController | undefined
let run: Promise<void> | undefined
-6
View File
@@ -1,5 +1,3 @@
import z from "zod"
const prefixes = {
session: "ses",
message: "msg",
@@ -15,10 +13,6 @@ let counter = 0
type Prefix = keyof typeof prefixes
export namespace Identifier {
export function schema(prefix: Prefix) {
return z.string().startsWith(prefixes[prefix])
}
export function ascending(prefix: Prefix, given?: string) {
return generateID(prefix, false, given)
}
+1 -2
View File
@@ -41,8 +41,7 @@
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"semver": "^7.6.3",
"xdg-basedir": "5.1.0",
"zod": "catalog:"
"xdg-basedir": "5.1.0"
},
"overrides": {
"drizzle-orm": "catalog:"
-11
View File
@@ -1,11 +0,0 @@
import { z } from "zod"
export function fn<T extends z.ZodType, Result>(schema: T, cb: (input: z.infer<T>) => Result) {
const result = (input: z.infer<T>) => {
const parsed = schema.parse(input)
return cb(parsed)
}
result.force = (input: z.infer<T>) => cb(input)
result.schema = schema
return result
}
+6 -3
View File
@@ -4,11 +4,14 @@ import path from "path"
import fs from "fs/promises"
import { createWriteStream } from "fs"
import * as Global from "../global"
import z from "zod"
import { Schema } from "effect"
import { Glob } from "./glob"
export const Level = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).meta({ ref: "LogLevel", description: "Log level" })
export type Level = z.infer<typeof Level>
export const Level = Schema.Literals(["DEBUG", "INFO", "WARN", "ERROR"]).annotate({
identifier: "LogLevel",
description: "Log level",
})
export type Level = Schema.Schema.Type<typeof Level>
const levelPriority: Record<Level, number> = {
DEBUG: 0,
+4 -1
View File
@@ -1,9 +1,12 @@
import { Message, Model, Part, Session, SnapshotFileDiff } from "@opencode-ai/sdk/v2"
import { fn } from "@opencode-ai/core/util/fn"
import { iife } from "@opencode-ai/core/util/iife"
import z from "zod"
import { Storage } from "./storage"
function fn<T extends z.ZodType, Result>(schema: T, cb: (input: z.infer<T>) => Result) {
return (input: z.infer<T>) => cb(schema.parse(input))
}
export namespace Share {
export const Info = z.object({
id: z.string(),
+2 -2
View File
@@ -78,8 +78,8 @@ export function FormatError(input: unknown) {
].join("\n")
}
// UICancelledError: void (no data)
if (NamedError.hasName(input, "UICancelledError")) {
// UICancelledError: user cancelled an interactive CLI prompt
if (isTaggedError(input, "UICancelledError") || NamedError.hasName(input, "UICancelledError")) {
return ""
}
}
+1 -2
View File
@@ -1,5 +1,4 @@
import { EOL } from "os"
import { NamedError } from "@opencode-ai/core/util/error"
import { Schema } from "effect"
import { logo as glyphs } from "./logo"
@@ -10,7 +9,7 @@ const wordmark = [
`▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`,
]
export const CancelledError = NamedError.create("UICancelledError", Schema.optional(Schema.Void))
export class CancelledError extends Schema.TaggedErrorClass<CancelledError>()("UICancelledError", {}) {}
export const Style = {
TEXT_HIGHLIGHT: "\x1b[96m",
+4 -1
View File
@@ -94,6 +94,7 @@ export const layer = Layer.effect(
tokens_reasoning: value.tokens.reasoning,
tokens_cache_read: value.tokens.cache.read,
tokens_cache_write: value.tokens.cache.write,
time_updated: sql`${SessionTable.time_updated}`,
})
.where(eq(SessionTable.id, sessionID))
.run()
@@ -145,7 +146,9 @@ export const layer = Layer.effect(
)
}
}).pipe(
Effect.tapCause((cause) => Effect.logError("failed to run data migrations", { cause })),
Effect.tapCause((cause) =>
Effect.logError("failed to run data migrations").pipe(Effect.annotateLogs("cause", cause)),
),
Effect.ignore,
Effect.forkScoped,
)
+1 -1
View File
@@ -181,7 +181,7 @@ export const make = <A, E = never>(
return [
Effect.gen(function* () {
yield* Fiber.interrupt(st.run.fiber)
yield* Deferred.await(st.run.done).pipe(Effect.exit, Effect.asVoid)
yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid)
yield* idleIfCurrent()
}),
{ _tag: "Idle" } as const,
+7 -9
View File
@@ -6,6 +6,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
import {
CallToolResultSchema,
ListToolsResultSchema,
ToolSchema,
type Tool as MCPToolDef,
ToolListChangedNotificationSchema,
@@ -14,7 +15,6 @@ import { Config } from "@/config/config"
import { ConfigMCP } from "../config/mcp"
import * as Log from "@opencode-ai/core/util/log"
import { NamedError } from "@opencode-ai/core/util/error"
import z from "zod/v4"
import { Installation } from "../installation"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { withTimeout } from "@/util/timeout"
@@ -35,13 +35,8 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
const log = Log.create({ service: "mcp" })
const DEFAULT_TIMEOUT = 30_000
const TolerantToolSchema = ToolSchema.extend({
outputSchema: z.unknown().optional(),
})
const TolerantListToolsResultSchema = z.looseObject({
tools: z.array(TolerantToolSchema),
nextCursor: z.string().optional(),
const TolerantListToolsResultSchema = ListToolsResultSchema.extend({
tools: ToolSchema.omit({ outputSchema: true }).array(),
})
export const Resource = Schema.Struct({
@@ -137,7 +132,10 @@ function listTools(key: string, client: MCPClient, timeout: number) {
log.warn("failed to validate MCP tool output schemas, retrying without output schema validation", { key, error })
return Effect.tryPromise({
try: () => client.request({ method: "tools/list" }, TolerantListToolsResultSchema, { timeout }),
try: () =>
client.request({ method: "tools/list" }, TolerantListToolsResultSchema, {
timeout,
}),
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
}).pipe(
Effect.map((result) =>
+1 -1
View File
@@ -37,7 +37,7 @@ export const layer = Layer.effect(
const run = Effect.gen(function* () {
const ctx = yield* InstanceState.context
yield* Effect.logInfo("bootstrapping", { directory: ctx.directory })
yield* Effect.logInfo("bootstrapping").pipe(Effect.annotateLogs("directory", ctx.directory))
// everything depends on config so eager load it for nice traces
yield* config.get()
// Plugin can mutate config so it has to be initialized before anything else.
@@ -156,7 +156,9 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
Effect.gen(function* () {
const exit = yield* Deferred.await(item[1].deferred).pipe(Effect.exit)
if (Exit.isFailure(exit)) {
yield* Effect.logWarning("instance dispose failed", { key: item[0], cause: exit.cause })
yield* Effect.logWarning("instance dispose failed").pipe(
Effect.annotateLogs({ key: item[0], cause: exit.cause }),
)
yield* removeEntry(item[0], item[1])
return
}
+5 -5
View File
@@ -1,7 +1,7 @@
import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin"
import { Auth } from "@/auth"
import { InstanceState } from "@/effect/instance-state"
import { namedSchemaError } from "@/util/named-schema-error"
import { NamedError } from "@opencode-ai/core/util/error"
import { optionalOmitUndefined } from "@opencode-ai/core/schema"
import { Plugin } from "../plugin"
import { ProviderID } from "./schema"
@@ -64,13 +64,13 @@ export const CallbackInput = Schema.Struct({
})
export type CallbackInput = Schema.Schema.Type<typeof CallbackInput>
export const OauthMissing = namedSchemaError("ProviderAuthOauthMissing", { providerID: ProviderID })
export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", { providerID: ProviderID })
export const OauthCodeMissing = namedSchemaError("ProviderAuthOauthCodeMissing", { providerID: ProviderID })
export const OauthCodeMissing = NamedError.create("ProviderAuthOauthCodeMissing", { providerID: ProviderID })
export const OauthCallbackFailed = namedSchemaError("ProviderAuthOauthCallbackFailed", {})
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", {})
export const ValidationFailed = namedSchemaError("ProviderAuthValidationFailed", {
export const ValidationFailed = NamedError.create("ProviderAuthValidationFailed", {
field: Schema.String,
message: Schema.String,
})
+3 -1
View File
@@ -177,7 +177,9 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | HttpClie
yield* invalidate
}),
).pipe(
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause })),
Effect.tapCause((cause) =>
Effect.logError("Failed to fetch models.dev").pipe(Effect.annotateLogs("cause", cause)),
),
Effect.ignore,
)
})
+3 -3
View File
@@ -13,7 +13,7 @@ import { Auth } from "../auth"
import { Env } from "../env"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Flag } from "@opencode-ai/core/flag/flag"
import { namedSchemaError } from "@/util/named-schema-error"
import { NamedError } from "@opencode-ai/core/util/error"
import { iife } from "@/util/iife"
import { Global } from "@opencode-ai/core/global"
import path from "path"
@@ -1749,13 +1749,13 @@ export function parseModel(model: string) {
}
}
export const ModelNotFoundError = namedSchemaError("ProviderModelNotFoundError", {
export const ModelNotFoundError = NamedError.create("ProviderModelNotFoundError", {
providerID: ProviderID,
modelID: ModelID,
suggestions: Schema.optional(Schema.Array(Schema.String)),
})
export const InitError = namedSchemaError("ProviderInitError", {
export const InitError = NamedError.create("ProviderInitError", {
providerID: ProviderID,
})
+3 -1
View File
@@ -169,7 +169,9 @@ export const layer = Layer.effect(
).pipe(
Effect.asVoid,
Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize reference repository", { name: reference.name, cause }),
Effect.logWarning("failed to materialize reference repository").pipe(
Effect.annotateLogs({ name: reference.name, cause }),
),
),
),
)
@@ -299,7 +299,9 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
yield* promptSvc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID }).pipe(
Effect.catchCause((cause) =>
Effect.gen(function* () {
yield* Effect.logError("prompt_async failed", { sessionID: ctx.params.sessionID, cause })
yield* Effect.logError("prompt_async failed").pipe(
Effect.annotateLogs({ sessionID: ctx.params.sessionID, cause }),
)
yield* bus.publish(Session.Event.Error, {
sessionID: ctx.params.sessionID,
error: new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(),
@@ -0,0 +1,14 @@
import { Schema } from "effect"
import { NamedError } from "@opencode-ai/core/util/error"
export const OutputLengthError = NamedError.create("MessageOutputLengthError", {})
export const AuthError = NamedError.create("ProviderAuthError", {
providerID: Schema.String,
message: Schema.String,
})
export const Shared = [AuthError.EffectSchema, NamedError.Unknown.EffectSchema, OutputLengthError.EffectSchema] as const
export const SharedSchema = Schema.Union(Shared)
export * as MessageError from "./message-error"
+8 -13
View File
@@ -23,8 +23,10 @@ import type { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"
import { Effect, Schema, Types } from "effect"
import { NonNegativeInt } from "@opencode-ai/core/schema"
import { namedSchemaError } from "@/util/named-schema-error"
import * as EffectLogger from "@opencode-ai/core/effect/logger"
import { MessageError } from "./message-error"
import { AuthError, OutputLengthError } from "./message-error"
export { AuthError, OutputLengthError } from "./message-error"
/** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */
interface FetchDecompressionError extends Error {
@@ -36,17 +38,12 @@ interface FetchDecompressionError extends Error {
export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached media from tool result:"
export { isMedia }
export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {})
export const AbortedError = namedSchemaError("MessageAbortedError", { message: Schema.String })
export const StructuredOutputError = namedSchemaError("StructuredOutputError", {
export const AbortedError = NamedError.create("MessageAbortedError", { message: Schema.String })
export const StructuredOutputError = NamedError.create("StructuredOutputError", {
message: Schema.String,
retries: NonNegativeInt,
})
export const AuthError = namedSchemaError("ProviderAuthError", {
providerID: Schema.String,
message: Schema.String,
})
export const APIError = namedSchemaError("APIError", {
export const APIError = NamedError.create("APIError", {
message: Schema.String,
statusCode: Schema.optional(NonNegativeInt),
isRetryable: Schema.Boolean,
@@ -55,7 +52,7 @@ export const APIError = namedSchemaError("APIError", {
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
})
export type APIError = Schema.Schema.Type<typeof APIError.Schema>
export const ContextOverflowError = namedSchemaError("ContextOverflowError", {
export const ContextOverflowError = NamedError.create("ContextOverflowError", {
message: Schema.String,
responseBody: Schema.optional(Schema.String),
})
@@ -381,9 +378,7 @@ export type Part =
| CompactionPart
const AssistantErrorSchema = Schema.Union([
AuthError.EffectSchema,
NamedError.Unknown.EffectSchema,
OutputLengthError.EffectSchema,
...MessageError.Shared,
AbortedError.EffectSchema,
StructuredOutputError.EffectSchema,
ContextOverflowError.EffectSchema,
+4 -11
View File
@@ -2,14 +2,9 @@ import { Schema } from "effect"
import { SessionID } from "./schema"
import { ModelID, ProviderID } from "../provider/schema"
import { NonNegativeInt } from "@opencode-ai/core/schema"
import { namedSchemaError } from "@/util/named-schema-error"
import { NamedError } from "@opencode-ai/core/util/error"
export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {})
export const AuthError = namedSchemaError("ProviderAuthError", {
providerID: Schema.String,
message: Schema.String,
})
import { MessageError } from "./message-error"
import { AuthError, OutputLengthError } from "./message-error"
export { AuthError, OutputLengthError } from "./message-error"
export const ToolCall = Schema.Struct({
state: Schema.Literal("call"),
@@ -105,9 +100,7 @@ export const Info = Schema.Struct({
created: NonNegativeInt,
completed: Schema.optional(NonNegativeInt),
}),
error: Schema.optional(
Schema.Union([AuthError.EffectSchema, NamedError.Unknown.EffectSchema, OutputLengthError.EffectSchema]),
),
error: Schema.optional(MessageError.SharedSchema),
sessionID: SessionID,
tool: Schema.Record(
Schema.String,
+2 -1
View File
@@ -38,6 +38,7 @@ function applyUsage(db: TxOrDb, sessionID: Session.Info["id"], value: Usage, sig
tokens_reasoning: sql`${SessionTable.tokens_reasoning} + ${value.tokens.reasoning * sign}`,
tokens_cache_read: sql`${SessionTable.tokens_cache_read} + ${value.tokens.cache.read * sign}`,
tokens_cache_write: sql`${SessionTable.tokens_cache_write} + ${value.tokens.cache.write * sign}`,
time_updated: sql`${SessionTable.time_updated}`,
})
.where(eq(SessionTable.id, sessionID))
.run()
@@ -110,7 +111,7 @@ export default [
const info = data.info
const row = db
.update(SessionTable)
.set(toPartialRow(info as Session.Patch))
.set({ time_updated: sql`${SessionTable.time_updated}`, ...toPartialRow(info as Session.Patch) })
.where(eq(SessionTable.id, data.sessionID))
.returning()
.get()
@@ -1,9 +0,0 @@
import { Schema } from "effect"
import { NamedError } from "@opencode-ai/core/util/error"
/**
* Create a Schema-backed NamedError-shaped class.
*/
export function namedSchemaError<Tag extends string, Fields extends Schema.Struct.Fields>(tag: Tag, fields: Fields) {
return NamedError.create(tag, fields)
}
File diff suppressed because it is too large Load Diff
@@ -18,110 +18,85 @@
* permissions are passed through, and Plan Mode's restrictions live on the
* agent, not the session.
*/
import { test, expect, afterEach } from "bun:test"
import { expect } from "bun:test"
import { Effect } from "effect"
import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture"
import { WithInstance } from "../../src/project/with-instance"
import { Agent } from "../../src/agent/agent"
import { deriveSubagentSessionPermission } from "../../src/agent/subagent-permissions"
import { Permission } from "../../src/permission"
import { testEffect } from "../lib/effect"
afterEach(async () => {
await disposeAllInstances()
})
function load<A>(dir: string, fn: (svc: Agent.Interface) => Effect.Effect<A>) {
return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer)))
}
const it = testEffect(Agent.defaultLayer)
// `deriveSubagentSessionPermission` is imported from production. The test
// exercises the actual helper that task.ts uses to build the subagent's
// session permission, so any regression in that helper trips this test.
test("[#26514] subagent spawned from plan mode inherits read-only restriction (edit denied)", async () => {
await using tmp = await tmpdir()
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const planAgent = await load(tmp.path, (svc) => svc.get("plan"))
const generalAgent = await load(tmp.path, (svc) => svc.get("general"))
it.instance("[#26514] subagent spawned from plan mode inherits read-only restriction (edit denied)", () =>
Effect.gen(function* () {
const planAgent = yield* Agent.Service.use((svc) => svc.get("plan"))
const generalAgent = yield* Agent.Service.use((svc) => svc.get("general"))
expect(planAgent).toBeDefined()
expect(generalAgent).toBeDefined()
// Sanity: the plan agent itself blocks edit. (Note: `write` and
// `apply_patch` route through the `edit` permission at the runtime
// tool layer — see Permission.disabled / EDIT_TOOLS.)
expect(Permission.evaluate("edit", "/some/file.ts", planAgent!.permission).action).toBe("deny")
expect(planAgent).toBeDefined()
expect(generalAgent).toBeDefined()
// Sanity: the plan agent itself blocks edit. (Note: `write` and
// `apply_patch` route through the `edit` permission at the runtime
// tool layer — see Permission.disabled / EDIT_TOOLS.)
expect(Permission.evaluate("edit", "/some/file.ts", planAgent!.permission).action).toBe("deny")
// Simulate the plan-mode parent session: in real flow the plan
// session's `permission` field is empty (Plan Mode lives on the agent
// ruleset, not the session). So we pass [] through as the parent
// session permission, exactly like the actual code path.
const parentSessionPermission: Permission.Ruleset = []
// Simulate the plan-mode parent session: in real flow the plan
// session's `permission` field is empty (Plan Mode lives on the agent
// ruleset, not the session). So we pass [] through as the parent
// session permission, exactly like the actual code path.
const parentSessionPermission: Permission.Ruleset = []
const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission,
parentAgent: planAgent,
subagent: generalAgent!,
})
const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission,
parentAgent: planAgent,
subagent: generalAgent!,
})
// Mirror the runtime evaluation in session/prompt.ts (~line 410, 639):
// ruleset: Permission.merge(agent.permission, session.permission ?? [])
const effective = Permission.merge(generalAgent!.permission, subagentSessionPermission)
// Mirror the runtime evaluation in session/prompt.ts (~line 410, 639):
// ruleset: Permission.merge(agent.permission, session.permission ?? [])
const effective = Permission.merge(generalAgent!.permission, subagentSessionPermission)
expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny")
expect(Permission.evaluate("edit", "/another/path/index.tsx", effective).action).toBe("deny")
},
})
})
expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny")
expect(Permission.evaluate("edit", "/another/path/index.tsx", effective).action).toBe("deny")
}),
)
test("[#26514] explore subagent launched from plan mode also stays read-only", async () => {
it.instance("[#26514] explore subagent launched from plan mode also stays read-only", () =>
// Sibling check: even though `explore` is intrinsically read-only, the
// bug surface is the same. Including this case to document that the fix
// should propagate the parent **agent** permissions, not just deny edit
// when the subagent happens to already deny it.
await using tmp = await tmpdir()
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const planAgent = await load(tmp.path, (svc) => svc.get("plan"))
const explore = await load(tmp.path, (svc) => svc.get("explore"))
expect(planAgent).toBeDefined()
expect(explore).toBeDefined()
Effect.gen(function* () {
const planAgent = yield* Agent.Service.use((svc) => svc.get("plan"))
const explore = yield* Agent.Service.use((svc) => svc.get("explore"))
expect(planAgent).toBeDefined()
expect(explore).toBeDefined()
const parentSessionPermission: Permission.Ruleset = []
const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission,
parentAgent: planAgent,
subagent: explore!,
})
const effective = Permission.merge(explore!.permission, subagentSessionPermission)
const parentSessionPermission: Permission.Ruleset = []
const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission,
parentAgent: planAgent,
subagent: explore!,
})
const effective = Permission.merge(explore!.permission, subagentSessionPermission)
// Already deny — sanity check.
expect(Permission.evaluate("edit", "/x.ts", effective).action).toBe("deny")
},
})
})
// Already deny — sanity check.
expect(Permission.evaluate("edit", "/x.ts", effective).action).toBe("deny")
}),
)
test("[#26514] custom user subagent launched from plan mode bypasses Plan Mode read-only", async () => {
it.instance(
"[#26514] custom user subagent launched from plan mode bypasses Plan Mode read-only",
// The most damaging case: a user-defined subagent with default
// permissions (allow-by-default, like `general`). The subagent must NOT
// be able to edit when the parent agent is `plan`.
await using tmp = await tmpdir({
config: {
agent: {
my_subagent: {
description: "A user-defined subagent",
mode: "subagent",
},
},
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const planAgent = await load(tmp.path, (svc) => svc.get("plan"))
const my = await load(tmp.path, (svc) => svc.get("my_subagent"))
() =>
Effect.gen(function* () {
const planAgent = yield* Agent.Service.use((svc) => svc.get("plan"))
const my = yield* Agent.Service.use((svc) => svc.get("my_subagent"))
expect(planAgent).toBeDefined()
expect(my).toBeDefined()
@@ -136,6 +111,15 @@ test("[#26514] custom user subagent launched from plan mode bypasses Plan Mode r
// BUG: on origin/dev edit resolves to "allow" because the plan
// agent's `edit: deny *` rule never reaches the subagent.
expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny")
}),
{
config: {
agent: {
my_subagent: {
description: "A user-defined subagent",
mode: "subagent",
},
},
},
})
})
},
)
+5
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import { AccountTransportError } from "../../src/account/schema"
import { FormatError } from "../../src/cli/error"
import { UI } from "../../src/cli/ui"
describe("cli.error", () => {
test("formats account transport errors clearly", () => {
@@ -15,4 +16,8 @@ describe("cli.error", () => {
expect(formatted).toContain("This failed before the server returned an HTTP response.")
expect(formatted).toContain("Check your network, proxy, or VPN configuration and try again.")
})
test("formats cancelled UI errors as empty output", () => {
expect(FormatError(new UI.CancelledError())).toBe("")
})
})
+22 -17
View File
@@ -3,6 +3,11 @@ import { Deferred, Effect, Exit, Fiber, Latch, Ref, Scope } from "effect"
import { Runner } from "@/effect/runner"
import { it } from "../lib/effect"
const waitForState = <A, E>(runner: Runner.Runner<A, E>, tag: Runner.State<A, E>["_tag"]) =>
Effect.gen(function* () {
while (runner.state._tag !== tag) yield* Effect.yieldNow
}).pipe(Effect.timeout("1 second"))
describe("Runner", () => {
// --- ensureRunning semantics ---
@@ -152,7 +157,7 @@ describe("Runner", () => {
const s = yield* Scope.Scope
const runner = Runner.make<string>(s, { onInterrupt: Effect.succeed("fallback") })
const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("never"))).pipe(Effect.forkChild)
yield* Effect.sleep("10 millis")
yield* waitForState(runner, "Running")
yield* runner.cancel
@@ -169,9 +174,9 @@ describe("Runner", () => {
const runner = Runner.make<string>(s, { onInterrupt: Effect.succeed("fallback") })
const a = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild)
yield* Effect.sleep("10 millis")
yield* waitForState(runner, "Running")
const b = yield* runner.ensureRunning(Effect.succeed("y")).pipe(Effect.forkChild)
yield* Effect.sleep("10 millis")
yield* Effect.yieldNow
yield* runner.cancel
@@ -189,7 +194,7 @@ describe("Runner", () => {
const s = yield* Scope.Scope
const runner = Runner.make<string>(s)
const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild)
yield* Effect.sleep("10 millis")
yield* waitForState(runner, "Running")
yield* runner.cancel
yield* Fiber.await(fiber)
@@ -215,7 +220,7 @@ describe("Runner", () => {
)
const a = yield* runner.ensureRunning(first).pipe(Effect.exit, Effect.forkChild)
yield* Effect.sleep("10 millis")
yield* waitForState(runner, "Running")
const stop = yield* runner.cancel.pipe(Effect.forkChild)
yield* Deferred.await(hit).pipe(Effect.timeout("250 millis"))
@@ -293,7 +298,7 @@ describe("Runner", () => {
const gate = yield* Deferred.make<void>()
const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("first"))).pipe(Effect.forkChild)
yield* Effect.sleep("10 millis")
yield* waitForState(runner, "Shell")
const exit = yield* runner.startShell(Effect.succeed("second")).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
@@ -314,7 +319,7 @@ describe("Runner", () => {
})
const sh = yield* runner.startShell(Effect.never.pipe(Effect.as("aborted"))).pipe(Effect.forkChild)
yield* Effect.sleep("10 millis")
yield* waitForState(runner, "Shell")
const exit = yield* runner.startShell(Effect.succeed("second")).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
@@ -333,7 +338,7 @@ describe("Runner", () => {
const gate = yield* Deferred.make<void>()
const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("ignored"))).pipe(Effect.forkChild)
yield* Effect.sleep("10 millis")
yield* waitForState(runner, "Shell")
const stop = yield* runner.cancel.pipe(Effect.forkChild)
const stopExit = yield* Fiber.await(stop).pipe(Effect.timeout("250 millis"))
@@ -380,11 +385,11 @@ describe("Runner", () => {
const gate = yield* Deferred.make<void>()
const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("shell-result"))).pipe(Effect.forkChild)
yield* Effect.sleep("10 millis")
yield* waitForState(runner, "Shell")
expect(runner.state._tag).toBe("Shell")
const run = yield* runner.ensureRunning(Effect.succeed("run-result")).pipe(Effect.forkChild)
yield* Effect.sleep("10 millis")
yield* waitForState(runner, "ShellThenRun")
expect(runner.state._tag).toBe("ShellThenRun")
yield* Deferred.succeed(gate, undefined)
@@ -406,7 +411,7 @@ describe("Runner", () => {
const gate = yield* Deferred.make<void>()
const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("shell"))).pipe(Effect.forkChild)
yield* Effect.sleep("10 millis")
yield* waitForState(runner, "Shell")
const work = Effect.gen(function* () {
yield* Ref.update(calls, (n) => n + 1)
@@ -414,7 +419,7 @@ describe("Runner", () => {
})
const a = yield* runner.ensureRunning(work).pipe(Effect.forkChild)
const b = yield* runner.ensureRunning(work).pipe(Effect.forkChild)
yield* Effect.sleep("10 millis")
yield* waitForState(runner, "ShellThenRun")
yield* Deferred.succeed(gate, undefined)
yield* Fiber.await(sh)
@@ -433,10 +438,10 @@ describe("Runner", () => {
const runner = Runner.make<string>(s)
const sh = yield* runner.startShell(Effect.never.pipe(Effect.as("aborted"))).pipe(Effect.forkChild)
yield* Effect.sleep("10 millis")
yield* waitForState(runner, "Shell")
const run = yield* runner.ensureRunning(Effect.succeed("y")).pipe(Effect.forkChild)
yield* Effect.sleep("10 millis")
yield* waitForState(runner, "ShellThenRun")
expect(runner.state._tag).toBe("ShellThenRun")
yield* runner.cancel
@@ -472,7 +477,7 @@ describe("Runner", () => {
onIdle: Ref.update(count, (n) => n + 1),
})
const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild)
yield* Effect.sleep("10 millis")
yield* waitForState(runner, "Running")
yield* runner.cancel
yield* Fiber.await(fiber)
expect(yield* Ref.get(count)).toBeGreaterThanOrEqual(1)
@@ -502,7 +507,7 @@ describe("Runner", () => {
const gate = yield* Deferred.make<void>()
const fiber = yield* runner.ensureRunning(Deferred.await(gate).pipe(Effect.as("ok"))).pipe(Effect.forkChild)
yield* Effect.sleep("10 millis")
yield* waitForState(runner, "Running")
expect(runner.busy).toBe(true)
yield* Deferred.succeed(gate, undefined)
@@ -519,7 +524,7 @@ describe("Runner", () => {
const gate = yield* Deferred.make<void>()
const fiber = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("ok"))).pipe(Effect.forkChild)
yield* Effect.sleep("10 millis")
yield* waitForState(runner, "Shell")
expect(runner.busy).toBe(true)
yield* Deferred.succeed(gate, undefined)
+55 -51
View File
@@ -3,67 +3,71 @@ import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import fs from "fs/promises"
import path from "path"
import { File } from "../../src/file"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { provideInstance, tmpdir } from "../fixture/fixture"
const run = <A, E>(eff: Effect.Effect<A, E, File.Service>) =>
Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer))))
const status = () => run(File.Service.use((svc) => svc.status()))
const read = (file: string) => run(File.Service.use((svc) => svc.read(file)))
const wintest = process.platform === "win32" ? test : test.skip
const it =
process.platform === "win32"
? (await import("../lib/effect")).testEffect((await import("../../src/file")).File.defaultLayer)
: undefined
describe("file fsmonitor", () => {
wintest("status does not start fsmonitor for readonly git checks", async () => {
await using tmp = await tmpdir({ git: true })
const target = path.join(tmp.path, "tracked.txt")
if (!it) {
test.skip("status does not start fsmonitor for readonly git checks", () => {})
test.skip("read does not start fsmonitor for git diffs", () => {})
return
}
await fs.writeFile(target, "base\n")
await $`git add tracked.txt`.cwd(tmp.path).quiet()
await $`git commit -m init`.cwd(tmp.path).quiet()
await $`git config core.fsmonitor true`.cwd(tmp.path).quiet()
await $`git fsmonitor--daemon stop`.cwd(tmp.path).quiet().nothrow()
await fs.writeFile(target, "next\n")
await fs.writeFile(path.join(tmp.path, "new.txt"), "new\n")
it.instance(
"status does not start fsmonitor for readonly git checks",
() =>
Effect.gen(function* () {
const { File } = yield* Effect.promise(() => import("../../src/file"))
const { TestInstance } = yield* Effect.promise(() => import("../fixture/fixture"))
const directory = (yield* TestInstance).directory
const target = path.join(directory, "tracked.txt")
const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow()
expect(before.exitCode).not.toBe(0)
yield* Effect.promise(() => fs.writeFile(target, "base\n"))
yield* Effect.promise(() => $`git add tracked.txt`.cwd(directory).quiet())
yield* Effect.promise(() => $`git commit -m init`.cwd(directory).quiet())
yield* Effect.promise(() => $`git config core.fsmonitor true`.cwd(directory).quiet())
yield* Effect.promise(() => $`git fsmonitor--daemon stop`.cwd(directory).quiet().nothrow())
yield* Effect.promise(() => fs.writeFile(target, "next\n"))
yield* Effect.promise(() => fs.writeFile(path.join(directory, "new.txt"), "new\n"))
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await status()
},
})
const before = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(directory).quiet().nothrow())
expect(before.exitCode).not.toBe(0)
const after = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow()
expect(after.exitCode).not.toBe(0)
})
yield* File.Service.use((svc) => svc.status())
wintest("read does not start fsmonitor for git diffs", async () => {
await using tmp = await tmpdir({ git: true })
const target = path.join(tmp.path, "tracked.txt")
const after = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(directory).quiet().nothrow())
expect(after.exitCode).not.toBe(0)
}),
{ git: true },
)
await fs.writeFile(target, "base\n")
await $`git add tracked.txt`.cwd(tmp.path).quiet()
await $`git commit -m init`.cwd(tmp.path).quiet()
await $`git config core.fsmonitor true`.cwd(tmp.path).quiet()
await $`git fsmonitor--daemon stop`.cwd(tmp.path).quiet().nothrow()
await fs.writeFile(target, "next\n")
it.instance(
"read does not start fsmonitor for git diffs",
() =>
Effect.gen(function* () {
const { File } = yield* Effect.promise(() => import("../../src/file"))
const { TestInstance } = yield* Effect.promise(() => import("../fixture/fixture"))
const directory = (yield* TestInstance).directory
const target = path.join(directory, "tracked.txt")
const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow()
expect(before.exitCode).not.toBe(0)
yield* Effect.promise(() => fs.writeFile(target, "base\n"))
yield* Effect.promise(() => $`git add tracked.txt`.cwd(directory).quiet())
yield* Effect.promise(() => $`git commit -m init`.cwd(directory).quiet())
yield* Effect.promise(() => $`git config core.fsmonitor true`.cwd(directory).quiet())
yield* Effect.promise(() => $`git fsmonitor--daemon stop`.cwd(directory).quiet().nothrow())
yield* Effect.promise(() => fs.writeFile(target, "next\n"))
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await read("tracked.txt")
},
})
const before = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(directory).quiet().nothrow())
expect(before.exitCode).not.toBe(0)
const after = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow()
expect(after.exitCode).not.toBe(0)
})
yield* File.Service.use((svc) => svc.read("tracked.txt"))
const after = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(directory).quiet().nothrow())
expect(after.exitCode).not.toBe(0)
}),
{ git: true },
)
})
+141 -162
View File
@@ -1,42 +1,55 @@
import { test, expect, describe } from "bun:test"
import { Effect } from "effect"
import { expect, describe } from "bun:test"
import { Cause, Effect, Exit } from "effect"
import path from "path"
import fs from "fs/promises"
import { Filesystem } from "@/util/filesystem"
import { File } from "../../src/file"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { InstanceState } from "../../src/effect/instance-state"
import { containsPath } from "../../src/project/instance-context"
import { provideInstance, tmpdir } from "../fixture/fixture"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const run = <A, E>(eff: Effect.Effect<A, E, File.Service>) =>
Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer))))
const read = (file: string) => run(File.Service.use((svc) => svc.read(file)))
const list = (dir?: string) => run(File.Service.use((svc) => svc.list(dir)))
const it = testEffect(File.defaultLayer)
const read = (file: string) => File.Service.use((svc) => svc.read(file))
const list = (dir?: string) => File.Service.use((svc) => svc.list(dir))
const expectAccessDenied = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
const exit = yield* effect.pipe(Effect.exit)
if (Exit.isSuccess(exit)) throw new Error("expected access denied")
expect(Cause.squash(exit.cause)).toHaveProperty("message", "Access denied: path escapes project directory")
})
describe("Filesystem.contains", () => {
test("allows paths within project", () => {
expect(Filesystem.contains("/project", "/project/src")).toBe(true)
expect(Filesystem.contains("/project", "/project/src/file.ts")).toBe(true)
expect(Filesystem.contains("/project", "/project")).toBe(true)
})
it.effect("allows paths within project", () =>
Effect.sync(() => {
expect(Filesystem.contains("/project", "/project/src")).toBe(true)
expect(Filesystem.contains("/project", "/project/src/file.ts")).toBe(true)
expect(Filesystem.contains("/project", "/project")).toBe(true)
}),
)
test("blocks ../ traversal", () => {
expect(Filesystem.contains("/project", "/project/../etc")).toBe(false)
expect(Filesystem.contains("/project", "/project/src/../../etc")).toBe(false)
expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false)
})
it.effect("blocks ../ traversal", () =>
Effect.sync(() => {
expect(Filesystem.contains("/project", "/project/../etc")).toBe(false)
expect(Filesystem.contains("/project", "/project/src/../../etc")).toBe(false)
expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false)
}),
)
test("blocks absolute paths outside project", () => {
expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false)
expect(Filesystem.contains("/project", "/tmp/file")).toBe(false)
expect(Filesystem.contains("/home/user/project", "/home/user/other")).toBe(false)
})
it.effect("blocks absolute paths outside project", () =>
Effect.sync(() => {
expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false)
expect(Filesystem.contains("/project", "/tmp/file")).toBe(false)
expect(Filesystem.contains("/home/user/project", "/home/user/other")).toBe(false)
}),
)
test("handles prefix collision edge cases", () => {
expect(Filesystem.contains("/project", "/project-other/file")).toBe(false)
expect(Filesystem.contains("/project", "/projectfile")).toBe(false)
})
it.effect("handles prefix collision edge cases", () =>
Effect.sync(() => {
expect(Filesystem.contains("/project", "/project-other/file")).toBe(false)
expect(Filesystem.contains("/project", "/projectfile")).toBe(false)
}),
)
})
/*
@@ -49,158 +62,124 @@ describe("Filesystem.contains", () => {
* This is a SEPARATE code path from ReadTool, which has its own checks.
*/
describe("File.read path traversal protection", () => {
test("rejects ../ traversal attempting to read /etc/passwd", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "allowed.txt"), "allowed content")
},
})
it.instance("rejects ../ traversal attempting to read /etc/passwd", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => Bun.write(path.join(test.directory, "allowed.txt"), "allowed content"))
yield* expectAccessDenied(read("../../../etc/passwd"))
}),
)
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await expect(read("../../../etc/passwd")).rejects.toThrow("Access denied: path escapes project directory")
},
})
})
it.instance("rejects deeply nested traversal", () =>
Effect.gen(function* () {
yield* expectAccessDenied(read("src/nested/../../../../../../../etc/passwd"))
}),
)
test("rejects deeply nested traversal", async () => {
await using tmp = await tmpdir()
it.instance("allows valid paths within project", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => Bun.write(path.join(test.directory, "valid.txt"), "valid content"))
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await expect(read("src/nested/../../../../../../../etc/passwd")).rejects.toThrow(
"Access denied: path escapes project directory",
)
},
})
})
test("allows valid paths within project", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "valid.txt"), "valid content")
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const result = await read("valid.txt")
expect(result.content).toBe("valid content")
},
})
})
const result = yield* read("valid.txt")
expect(result.content).toBe("valid content")
}),
)
})
describe("File.list path traversal protection", () => {
test("rejects ../ traversal attempting to list /etc", async () => {
await using tmp = await tmpdir()
it.instance("rejects ../ traversal attempting to list /etc", () =>
Effect.gen(function* () {
yield* expectAccessDenied(list("../../../etc"))
}),
)
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await expect(list("../../../etc")).rejects.toThrow("Access denied: path escapes project directory")
},
})
})
it.instance("allows valid subdirectory listing", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => Bun.write(path.join(test.directory, "subdir", "file.txt"), "content"))
test("allows valid subdirectory listing", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "subdir", "file.txt"), "content")
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const result = await list("subdir")
expect(Array.isArray(result)).toBe(true)
},
})
})
const result = yield* list("subdir")
expect(Array.isArray(result)).toBe(true)
}),
)
})
describe("containsPath", () => {
test("returns true for path inside directory", async () => {
await using tmp = await tmpdir({ git: true })
it.instance(
"returns true for path inside directory",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const ctx = yield* InstanceState.context
expect(containsPath(path.join(test.directory, "foo.txt"), ctx)).toBe(true)
expect(containsPath(path.join(test.directory, "src", "file.ts"), ctx)).toBe(true)
}),
{ git: true },
)
await WithInstance.provide({
directory: tmp.path,
fn: () => {
expect(containsPath(path.join(tmp.path, "foo.txt"), Instance.current)).toBe(true)
expect(containsPath(path.join(tmp.path, "src", "file.ts"), Instance.current)).toBe(true)
},
})
})
it.instance(
"returns true for path inside worktree but outside directory (monorepo subdirectory scenario)",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const subdir = path.join(test.directory, "packages", "lib")
yield* Effect.promise(() => fs.mkdir(subdir, { recursive: true }))
const ctx = { ...(yield* InstanceState.context), directory: subdir }
test("returns true for path inside worktree but outside directory (monorepo subdirectory scenario)", async () => {
await using tmp = await tmpdir({ git: true })
const subdir = path.join(tmp.path, "packages", "lib")
await fs.mkdir(subdir, { recursive: true })
await WithInstance.provide({
directory: subdir,
fn: () => {
// .opencode at worktree root, but we're running from packages/lib
expect(containsPath(path.join(tmp.path, ".opencode", "state"), Instance.current)).toBe(true)
expect(containsPath(path.join(test.directory, ".opencode", "state"), ctx)).toBe(true)
// sibling package should also be accessible
expect(containsPath(path.join(tmp.path, "packages", "other", "file.ts"), Instance.current)).toBe(true)
expect(containsPath(path.join(test.directory, "packages", "other", "file.ts"), ctx)).toBe(true)
// worktree root itself
expect(containsPath(tmp.path, Instance.current)).toBe(true)
},
})
})
expect(containsPath(test.directory, ctx)).toBe(true)
}),
{ git: true },
)
test("returns false for path outside both directory and worktree", async () => {
await using tmp = await tmpdir({ git: true })
it.instance(
"returns false for path outside both directory and worktree",
() =>
Effect.gen(function* () {
const ctx = yield* InstanceState.context
expect(containsPath("/etc/passwd", ctx)).toBe(false)
expect(containsPath("/tmp/other-project", ctx)).toBe(false)
}),
{ git: true },
)
await WithInstance.provide({
directory: tmp.path,
fn: () => {
expect(containsPath("/etc/passwd", Instance.current)).toBe(false)
expect(containsPath("/tmp/other-project", Instance.current)).toBe(false)
},
})
})
it.instance(
"returns false for path with .. escaping worktree",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const ctx = yield* InstanceState.context
expect(containsPath(path.join(test.directory, "..", "escape.txt"), ctx)).toBe(false)
}),
{ git: true },
)
test("returns false for path with .. escaping worktree", async () => {
await using tmp = await tmpdir({ git: true })
it.instance(
"handles directory === worktree (running from repo root)",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const ctx = yield* InstanceState.context
expect(ctx.directory).toBe(ctx.worktree)
expect(containsPath(path.join(test.directory, "file.txt"), ctx)).toBe(true)
expect(containsPath("/etc/passwd", ctx)).toBe(false)
}),
{ git: true },
)
await WithInstance.provide({
directory: tmp.path,
fn: () => {
expect(containsPath(path.join(tmp.path, "..", "escape.txt"), Instance.current)).toBe(false)
},
})
})
test("handles directory === worktree (running from repo root)", async () => {
await using tmp = await tmpdir({ git: true })
await WithInstance.provide({
directory: tmp.path,
fn: () => {
expect(Instance.directory).toBe(Instance.worktree)
expect(containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true)
expect(containsPath("/etc/passwd", Instance.current)).toBe(false)
},
})
})
test("non-git project does not allow arbitrary paths via worktree='/'", async () => {
await using tmp = await tmpdir() // no git: true
await WithInstance.provide({
directory: tmp.path,
fn: () => {
// worktree is "/" for non-git projects, but containsPath should NOT allow all paths
expect(containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true)
expect(containsPath("/etc/passwd", Instance.current)).toBe(false)
expect(containsPath("/tmp/other", Instance.current)).toBe(false)
},
})
})
it.instance("non-git project does not allow arbitrary paths via worktree='/'", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const ctx = yield* InstanceState.context
// worktree is "/" for non-git projects, but containsPath should NOT allow all paths
expect(containsPath(path.join(test.directory, "file.txt"), ctx)).toBe(true)
expect(containsPath("/etc/passwd", ctx)).toBe(false)
expect(containsPath("/tmp/other", ctx)).toBe(false)
}),
)
})
+200 -194
View File
@@ -1,214 +1,220 @@
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import * as Stream from "effect/Stream"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { tmpdir } from "../fixture/fixture"
import { Ripgrep } from "../../src/file/ripgrep"
import { testEffect } from "../lib/effect"
const run = <A>(effect: Effect.Effect<A, unknown, Ripgrep.Service>) =>
effect.pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
const it = testEffect(Ripgrep.defaultLayer)
const tmpdir = (init?: (dir: string) => Effect.Effect<void>) =>
Effect.acquireRelease(
Effect.promise(async () => fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-test-")))),
(dir) =>
Effect.promise(() =>
fs.rm(dir, {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 100,
}),
).pipe(Effect.ignore),
).pipe(Effect.tap((dir) => init?.(dir) ?? Effect.void))
const write = (file: string, data: string) => Effect.promise(() => Bun.write(file, data))
const mkdir = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true }))
const collectFiles = (input: Ripgrep.FilesInput) =>
Ripgrep.Service.use((rg) =>
rg.files(input).pipe(
Stream.runCollect,
Effect.map((c) => [...c]),
),
)
const withRipgrepConfig = <A, E, R>(value: string, effect: Effect.Effect<A, E, R>) =>
Effect.acquireUseRelease(
Effect.sync(() => {
const prev = process.env["RIPGREP_CONFIG_PATH"]
process.env["RIPGREP_CONFIG_PATH"] = value
return prev
}),
() => effect,
(prev) =>
Effect.sync(() => {
if (prev === undefined) delete process.env["RIPGREP_CONFIG_PATH"]
else process.env["RIPGREP_CONFIG_PATH"] = prev
}),
)
describe("file.ripgrep", () => {
test("defaults to include hidden", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "visible.txt"), "hello")
await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
await Bun.write(path.join(dir, ".opencode", "thing.json"), "{}")
},
})
it.live("defaults to include hidden", () =>
Effect.gen(function* () {
const dir = yield* tmpdir((dir) =>
Effect.gen(function* () {
yield* write(path.join(dir, "visible.txt"), "hello")
yield* mkdir(path.join(dir, ".opencode"))
yield* write(path.join(dir, ".opencode", "thing.json"), "{}")
}),
)
const files = await run(
Ripgrep.Service.use((rg) =>
rg.files({ cwd: tmp.path }).pipe(
Stream.runCollect,
Effect.map((c) => [...c]),
),
),
)
expect(files.includes("visible.txt")).toBe(true)
expect(files.includes(path.join(".opencode", "thing.json"))).toBe(true)
})
const files = yield* collectFiles({ cwd: dir })
expect(files.includes("visible.txt")).toBe(true)
expect(files.includes(path.join(".opencode", "thing.json"))).toBe(true)
}),
)
test("hidden false excludes hidden", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "visible.txt"), "hello")
await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
await Bun.write(path.join(dir, ".opencode", "thing.json"), "{}")
},
})
it.live("hidden false excludes hidden", () =>
Effect.gen(function* () {
const dir = yield* tmpdir((dir) =>
Effect.gen(function* () {
yield* write(path.join(dir, "visible.txt"), "hello")
yield* mkdir(path.join(dir, ".opencode"))
yield* write(path.join(dir, ".opencode", "thing.json"), "{}")
}),
)
const files = await run(
Ripgrep.Service.use((rg) =>
rg.files({ cwd: tmp.path, hidden: false }).pipe(
Stream.runCollect,
Effect.map((c) => [...c]),
),
),
)
expect(files.includes("visible.txt")).toBe(true)
expect(files.includes(path.join(".opencode", "thing.json"))).toBe(false)
})
const files = yield* collectFiles({ cwd: dir, hidden: false })
expect(files.includes("visible.txt")).toBe(true)
expect(files.includes(path.join(".opencode", "thing.json"))).toBe(false)
}),
)
test("search returns empty when nothing matches", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "match.ts"), "const value = 'other'\n")
},
})
it.live("search returns empty when nothing matches", () =>
Effect.gen(function* () {
const dir = yield* tmpdir((dir) => write(path.join(dir, "match.ts"), "const value = 'other'\n"))
const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle" })))
expect(result.partial).toBe(false)
expect(result.items).toEqual([])
})
const result = yield* Ripgrep.Service.use((rg) => rg.search({ cwd: dir, pattern: "needle" }))
expect(result.partial).toBe(false)
expect(result.items).toEqual([])
}),
)
test("search returns match metadata with normalized path", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.mkdir(path.join(dir, "src"), { recursive: true })
await Bun.write(path.join(dir, "src", "match.ts"), "const needle = 1\n")
},
})
it.live("search returns match metadata with normalized path", () =>
Effect.gen(function* () {
const dir = yield* tmpdir((dir) =>
Effect.gen(function* () {
yield* mkdir(path.join(dir, "src"))
yield* write(path.join(dir, "src", "match.ts"), "const needle = 1\n")
}),
)
const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle" })))
expect(result.partial).toBe(false)
expect(result.items).toHaveLength(1)
expect(result.items[0]?.path.text).toBe(path.join("src", "match.ts"))
expect(result.items[0]?.line_number).toBe(1)
expect(result.items[0]?.lines.text).toContain("needle")
})
test("search returns matched rows with glob filter", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "match.ts"), "const value = 'needle'\n")
await Bun.write(path.join(dir, "skip.txt"), "const value = 'other'\n")
},
})
const result = await run(
Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle", glob: ["*.ts"] })),
)
expect(result.partial).toBe(false)
expect(result.items).toHaveLength(1)
expect(result.items[0]?.path.text).toContain("match.ts")
expect(result.items[0]?.lines.text).toContain("needle")
})
test("search supports explicit file targets", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "match.ts"), "const value = 'needle'\n")
await Bun.write(path.join(dir, "skip.ts"), "const value = 'needle'\n")
},
})
const file = path.join(tmp.path, "match.ts")
const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle", file: [file] })))
expect(result.partial).toBe(false)
expect(result.items).toHaveLength(1)
expect(result.items[0]?.path.text).toBe(file)
})
test("files returns empty when glob matches no files", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.mkdir(path.join(dir, "packages", "console"), { recursive: true })
await Bun.write(path.join(dir, "packages", "console", "package.json"), "{}")
},
})
const files = await run(
Ripgrep.Service.use((rg) =>
rg.files({ cwd: tmp.path, glob: ["packages/*"] }).pipe(
Stream.runCollect,
Effect.map((c) => [...c]),
),
),
)
expect(files).toEqual([])
})
test("files returns stream of filenames", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "a.txt"), "hello")
await Bun.write(path.join(dir, "b.txt"), "world")
},
})
const files = await run(
Ripgrep.Service.use((rg) =>
rg.files({ cwd: tmp.path }).pipe(
Stream.runCollect,
Effect.map((c) => [...c].sort()),
),
),
)
expect(files).toEqual(["a.txt", "b.txt"])
})
test("files respects glob filter", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "keep.ts"), "yes")
await Bun.write(path.join(dir, "skip.txt"), "no")
},
})
const files = await run(
Ripgrep.Service.use((rg) =>
rg.files({ cwd: tmp.path, glob: ["*.ts"] }).pipe(
Stream.runCollect,
Effect.map((c) => [...c]),
),
),
)
expect(files).toEqual(["keep.ts"])
})
test("files dies on nonexistent directory", async () => {
const exit = await Ripgrep.Service.use((rg) =>
rg.files({ cwd: "/tmp/nonexistent-dir-12345" }).pipe(Stream.runCollect),
).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromiseExit)
expect(exit._tag).toBe("Failure")
})
test("ignores RIPGREP_CONFIG_PATH in direct mode", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "match.ts"), "const needle = 1\n")
},
})
const prev = process.env["RIPGREP_CONFIG_PATH"]
process.env["RIPGREP_CONFIG_PATH"] = path.join(tmp.path, "missing-ripgreprc")
try {
const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle" })))
const result = yield* Ripgrep.Service.use((rg) => rg.search({ cwd: dir, pattern: "needle" }))
expect(result.partial).toBe(false)
expect(result.items).toHaveLength(1)
} finally {
if (prev === undefined) delete process.env["RIPGREP_CONFIG_PATH"]
else process.env["RIPGREP_CONFIG_PATH"] = prev
}
})
expect(result.items[0]?.path.text).toBe(path.join("src", "match.ts"))
expect(result.items[0]?.line_number).toBe(1)
expect(result.items[0]?.lines.text).toContain("needle")
}),
)
test("ignores RIPGREP_CONFIG_PATH in worker mode", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "match.ts"), "const needle = 1\n")
},
})
it.live("search returns matched rows with glob filter", () =>
Effect.gen(function* () {
const dir = yield* tmpdir((dir) =>
Effect.gen(function* () {
yield* write(path.join(dir, "match.ts"), "const value = 'needle'\n")
yield* write(path.join(dir, "skip.txt"), "const value = 'other'\n")
}),
)
const prev = process.env["RIPGREP_CONFIG_PATH"]
process.env["RIPGREP_CONFIG_PATH"] = path.join(tmp.path, "missing-ripgreprc")
try {
const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle" })))
const result = yield* Ripgrep.Service.use((rg) => rg.search({ cwd: dir, pattern: "needle", glob: ["*.ts"] }))
expect(result.partial).toBe(false)
expect(result.items).toHaveLength(1)
} finally {
if (prev === undefined) delete process.env["RIPGREP_CONFIG_PATH"]
else process.env["RIPGREP_CONFIG_PATH"] = prev
}
})
expect(result.items[0]?.path.text).toContain("match.ts")
expect(result.items[0]?.lines.text).toContain("needle")
}),
)
it.live("search supports explicit file targets", () =>
Effect.gen(function* () {
const dir = yield* tmpdir((dir) =>
Effect.gen(function* () {
yield* write(path.join(dir, "match.ts"), "const value = 'needle'\n")
yield* write(path.join(dir, "skip.ts"), "const value = 'needle'\n")
}),
)
const file = path.join(dir, "match.ts")
const result = yield* Ripgrep.Service.use((rg) => rg.search({ cwd: dir, pattern: "needle", file: [file] }))
expect(result.partial).toBe(false)
expect(result.items).toHaveLength(1)
expect(result.items[0]?.path.text).toBe(file)
}),
)
it.live("files returns empty when glob matches no files", () =>
Effect.gen(function* () {
const dir = yield* tmpdir((dir) =>
Effect.gen(function* () {
yield* mkdir(path.join(dir, "packages", "console"))
yield* write(path.join(dir, "packages", "console", "package.json"), "{}")
}),
)
const files = yield* collectFiles({ cwd: dir, glob: ["packages/*"] })
expect(files).toEqual([])
}),
)
it.live("files returns stream of filenames", () =>
Effect.gen(function* () {
const dir = yield* tmpdir((dir) =>
Effect.gen(function* () {
yield* write(path.join(dir, "a.txt"), "hello")
yield* write(path.join(dir, "b.txt"), "world")
}),
)
const files = yield* collectFiles({ cwd: dir }).pipe(Effect.map((files) => files.sort()))
expect(files).toEqual(["a.txt", "b.txt"])
}),
)
it.live("files respects glob filter", () =>
Effect.gen(function* () {
const dir = yield* tmpdir((dir) =>
Effect.gen(function* () {
yield* write(path.join(dir, "keep.ts"), "yes")
yield* write(path.join(dir, "skip.txt"), "no")
}),
)
const files = yield* collectFiles({ cwd: dir, glob: ["*.ts"] })
expect(files).toEqual(["keep.ts"])
}),
)
it.live("files dies on nonexistent directory", () =>
Effect.gen(function* () {
const exit = yield* Ripgrep.Service.use((rg) =>
rg.files({ cwd: "/tmp/nonexistent-dir-12345" }).pipe(Stream.runCollect),
).pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
}),
)
it.live("ignores RIPGREP_CONFIG_PATH in direct mode", () =>
Effect.gen(function* () {
const dir = yield* tmpdir((dir) => write(path.join(dir, "match.ts"), "const needle = 1\n"))
const result = yield* withRipgrepConfig(
path.join(dir, "missing-ripgreprc"),
Ripgrep.Service.use((rg) => rg.search({ cwd: dir, pattern: "needle" })),
)
expect(result.items).toHaveLength(1)
}),
)
it.live("ignores RIPGREP_CONFIG_PATH in worker mode", () =>
Effect.gen(function* () {
const dir = yield* tmpdir((dir) => write(path.join(dir, "match.ts"), "const needle = 1\n"))
const result = yield* withRipgrepConfig(
path.join(dir, "missing-ripgreprc"),
Ripgrep.Service.use((rg) => rg.search({ cwd: dir, pattern: "needle" })),
)
expect(result.items).toHaveLength(1)
}),
)
})
+1 -1
View File
@@ -135,7 +135,7 @@ export function tmpdirScoped(options?: { git?: boolean; config?: Partial<Config.
yield* git("config", "commit.gpgsign", "false")
yield* git("config", "user.email", "test@opencode.test")
yield* git("config", "user.name", "Test")
yield* git("commit", "--allow-empty", "-m", "root commit")
yield* git("commit", "--allow-empty", "-m", `root commit ${dir}`)
}
if (options?.config) {
+101 -98
View File
@@ -1,71 +1,70 @@
import { $ } from "bun"
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { ManagedRuntime } from "effect"
import { Effect } from "effect"
import { Git } from "../../src/git"
import { tmpdir } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt"
const it = testEffect(Git.defaultLayer)
async function withGit<T>(body: (rt: ManagedRuntime.ManagedRuntime<Git.Service, never>) => Promise<T>) {
const rt = ManagedRuntime.make(Git.defaultLayer)
try {
return await body(rt)
} finally {
await rt.dispose()
}
}
const scopedTmpdir = (options?: Parameters<typeof tmpdir>[0]) =>
Effect.acquireRelease(
Effect.promise(() => tmpdir(options)),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
describe("Git", () => {
test("branch() returns current branch name", async () => {
await using tmp = await tmpdir({ git: true })
await withGit(async (rt) => {
const branch = await rt.runPromise(Git.Service.use((git) => git.branch(tmp.path)))
it.live("branch() returns current branch name", () =>
Effect.gen(function* () {
const tmp = yield* scopedTmpdir({ git: true })
const git = yield* Git.Service
const branch = yield* git.branch(tmp.path)
expect(branch).toBeDefined()
expect(typeof branch).toBe("string")
})
})
}),
)
test("branch() returns undefined for non-git directories", async () => {
await using tmp = await tmpdir()
await withGit(async (rt) => {
const branch = await rt.runPromise(Git.Service.use((git) => git.branch(tmp.path)))
it.live("branch() returns undefined for non-git directories", () =>
Effect.gen(function* () {
const tmp = yield* scopedTmpdir()
const git = yield* Git.Service
const branch = yield* git.branch(tmp.path)
expect(branch).toBeUndefined()
})
})
}),
)
test("branch() returns undefined for detached HEAD", async () => {
await using tmp = await tmpdir({ git: true })
const hash = (await $`git rev-parse HEAD`.cwd(tmp.path).quiet().text()).trim()
await $`git checkout --detach ${hash}`.cwd(tmp.path).quiet()
await withGit(async (rt) => {
const branch = await rt.runPromise(Git.Service.use((git) => git.branch(tmp.path)))
it.live("branch() returns undefined for detached HEAD", () =>
Effect.gen(function* () {
const tmp = yield* scopedTmpdir({ git: true })
const hash = (yield* Effect.promise(() => $`git rev-parse HEAD`.cwd(tmp.path).quiet().text())).trim()
yield* Effect.promise(() => $`git checkout --detach ${hash}`.cwd(tmp.path).quiet())
const git = yield* Git.Service
const branch = yield* git.branch(tmp.path)
expect(branch).toBeUndefined()
})
})
}),
)
test("defaultBranch() uses init.defaultBranch when available", async () => {
await using tmp = await tmpdir({ git: true })
await $`git branch -M trunk`.cwd(tmp.path).quiet()
await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet()
await withGit(async (rt) => {
const branch = await rt.runPromise(Git.Service.use((git) => git.defaultBranch(tmp.path)))
it.live("defaultBranch() uses init.defaultBranch when available", () =>
Effect.gen(function* () {
const tmp = yield* scopedTmpdir({ git: true })
yield* Effect.promise(() => $`git branch -M trunk`.cwd(tmp.path).quiet())
yield* Effect.promise(() => $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet())
const git = yield* Git.Service
const branch = yield* git.defaultBranch(tmp.path)
expect(branch?.name).toBe("trunk")
expect(branch?.ref).toBe("trunk")
})
})
}),
)
test("status() handles special filenames", async () => {
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8")
await withGit(async (rt) => {
const status = await rt.runPromise(Git.Service.use((git) => git.status(tmp.path)))
it.live("status() handles special filenames", () =>
Effect.gen(function* () {
const tmp = yield* scopedTmpdir({ git: true })
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8"))
const git = yield* Git.Service
const status = yield* git.status(tmp.path)
expect(status).toEqual(
expect.arrayContaining([
expect.objectContaining({
@@ -74,23 +73,24 @@ describe("Git", () => {
}),
]),
)
})
})
}),
)
test("diff(), stats(), and mergeBase() parse tracked changes", async () => {
await using tmp = await tmpdir({ git: true })
await $`git branch -M main`.cwd(tmp.path).quiet()
await fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8")
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
await $`git checkout -b feature/test`.cwd(tmp.path).quiet()
await fs.writeFile(path.join(tmp.path, weird), "after\n", "utf-8")
it.live("diff(), stats(), and mergeBase() parse tracked changes", () =>
Effect.gen(function* () {
const tmp = yield* scopedTmpdir({ git: true })
yield* Effect.promise(() => $`git branch -M main`.cwd(tmp.path).quiet())
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8"))
yield* Effect.promise(() => $`git add .`.cwd(tmp.path).quiet())
yield* Effect.promise(() => $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet())
yield* Effect.promise(() => $`git checkout -b feature/test`.cwd(tmp.path).quiet())
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, weird), "after\n", "utf-8"))
await withGit(async (rt) => {
const [base, diff, stats] = await Promise.all([
rt.runPromise(Git.Service.use((git) => git.mergeBase(tmp.path, "main"))),
rt.runPromise(Git.Service.use((git) => git.diff(tmp.path, "HEAD"))),
rt.runPromise(Git.Service.use((git) => git.stats(tmp.path, "HEAD"))),
const git = yield* Git.Service
const [base, diff, stats] = yield* Effect.all([
git.mergeBase(tmp.path, "main"),
git.diff(tmp.path, "HEAD"),
git.stats(tmp.path, "HEAD"),
])
expect(base).toBeTruthy()
@@ -111,23 +111,24 @@ describe("Git", () => {
}),
]),
)
})
})
}),
)
test("patch() returns capped native patch output", async () => {
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8")
await fs.writeFile(path.join(tmp.path, "other.txt"), "old\n", "utf-8")
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
await fs.writeFile(path.join(tmp.path, weird), "after\n", "utf-8")
await fs.writeFile(path.join(tmp.path, "other.txt"), "new\n", "utf-8")
it.live("patch() returns capped native patch output", () =>
Effect.gen(function* () {
const tmp = yield* scopedTmpdir({ git: true })
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8"))
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "other.txt"), "old\n", "utf-8"))
yield* Effect.promise(() => $`git add .`.cwd(tmp.path).quiet())
yield* Effect.promise(() => $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet())
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, weird), "after\n", "utf-8"))
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "other.txt"), "new\n", "utf-8"))
await withGit(async (rt) => {
const [patch, all, capped] = await Promise.all([
rt.runPromise(Git.Service.use((git) => git.patch(tmp.path, "HEAD", weird, { context: 2_147_483_647 }))),
rt.runPromise(Git.Service.use((git) => git.patchAll(tmp.path, "HEAD", { context: 2_147_483_647 }))),
rt.runPromise(Git.Service.use((git) => git.patch(tmp.path, "HEAD", weird, { maxOutputBytes: 1 }))),
const git = yield* Git.Service
const [patch, all, capped] = yield* Effect.all([
git.patch(tmp.path, "HEAD", weird, { context: 2_147_483_647 }),
git.patchAll(tmp.path, "HEAD", { context: 2_147_483_647 }),
git.patch(tmp.path, "HEAD", weird, { maxOutputBytes: 1 }),
])
expect(patch.truncated).toBe(false)
@@ -140,17 +141,18 @@ describe("Git", () => {
expect(all.text).toContain("+new")
expect(capped.truncated).toBe(true)
expect(capped.text).toBe("")
})
})
}),
)
test("patchUntracked() and statUntracked() handle added files", async () => {
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, weird), "one\ntwo\n", "utf-8")
it.live("patchUntracked() and statUntracked() handle added files", () =>
Effect.gen(function* () {
const tmp = yield* scopedTmpdir({ git: true })
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, weird), "one\ntwo\n", "utf-8"))
await withGit(async (rt) => {
const [patch, stat] = await Promise.all([
rt.runPromise(Git.Service.use((git) => git.patchUntracked(tmp.path, weird, { context: 2_147_483_647 }))),
rt.runPromise(Git.Service.use((git) => git.statUntracked(tmp.path, weird))),
const git = yield* Git.Service
const [patch, stat] = yield* Effect.all([
git.patchUntracked(tmp.path, weird, { context: 2_147_483_647 }),
git.statUntracked(tmp.path, weird),
])
expect(patch.truncated).toBe(false)
@@ -158,18 +160,19 @@ describe("Git", () => {
expect(patch.text).toContain("+one")
expect(patch.text).toContain("+two")
expect(stat).toEqual(expect.objectContaining({ file: weird, additions: 2, deletions: 0 }))
})
})
}),
)
test("show() returns empty text for binary blobs", async () => {
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, "bin.dat"), new Uint8Array([0, 1, 2, 3]))
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit --no-gpg-sign -m "add binary"`.cwd(tmp.path).quiet()
it.live("show() returns empty text for binary blobs", () =>
Effect.gen(function* () {
const tmp = yield* scopedTmpdir({ git: true })
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "bin.dat"), new Uint8Array([0, 1, 2, 3])))
yield* Effect.promise(() => $`git add .`.cwd(tmp.path).quiet())
yield* Effect.promise(() => $`git commit --no-gpg-sign -m "add binary"`.cwd(tmp.path).quiet())
await withGit(async (rt) => {
const text = await rt.runPromise(Git.Service.use((git) => git.show(tmp.path, "HEAD", "bin.dat")))
const git = yield* Git.Service
const text = yield* git.show(tmp.path, "HEAD", "bin.dat")
expect(text).toBe("")
})
})
}),
)
})
+414 -375
View File
@@ -4,26 +4,28 @@ import { Project } from "@/project/project"
import * as Log from "@opencode-ai/core/util/log"
import { $ } from "bun"
import path from "path"
import { tmpdir } from "../fixture/fixture"
import { tmpdirScoped } from "../fixture/fixture"
import { GlobalBus } from "../../src/bus/global"
import { ProjectID } from "../../src/project/schema"
import { Effect, Layer, Stream } from "effect"
import { Cause, Effect, Exit, Layer, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodePath } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { testEffect } from "../lib/effect"
void Log.init({ print: false })
const encoder = new TextEncoder()
function run<A>(fn: (svc: Project.Interface) => Effect.Effect<A>, layer = Project.defaultLayer) {
return Effect.runPromise(
Effect.gen(function* () {
const svc = yield* Project.Service
return yield* fn(svc)
}).pipe(Effect.provide(layer)),
)
const layer = Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer)
const it = testEffect(layer)
function run<A>(fn: (svc: Project.Interface) => Effect.Effect<A>) {
return Effect.gen(function* () {
const svc = yield* Project.Service
return yield* fn(svc)
})
}
/**
@@ -70,400 +72,430 @@ function projectLayerWithFailure(failArg: string) {
)
}
const failureIt = (failArg: string) => testEffect(Layer.mergeAll(projectLayerWithFailure(failArg), CrossSpawnSpawner.defaultLayer))
describe("Project.fromDirectory", () => {
test("should handle git repository with no commits", async () => {
await using tmp = await tmpdir()
await $`git init`.cwd(tmp.path).quiet()
it.live("should handle git repository with no commits", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
yield* Effect.promise(() => $`git init`.cwd(tmp).quiet())
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project).toBeDefined()
expect(project.id).toBe(ProjectID.global)
expect(project.vcs).toBe("git")
expect(project.worktree).toBe(tmp.path)
expect(project).toBeDefined()
expect(project.id).toBe(ProjectID.global)
expect(project.vcs).toBe("git")
expect(project.worktree).toBe(tmp)
const opencodeFile = path.join(tmp.path, ".git", "opencode")
expect(await Bun.file(opencodeFile).exists()).toBe(false)
})
const opencodeFile = path.join(tmp, ".git", "opencode")
expect(yield* Effect.promise(() => Bun.file(opencodeFile).exists())).toBe(false)
}),
)
test("should handle git repository with commits", async () => {
await using tmp = await tmpdir({ git: true })
it.live("should handle git repository with commits", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project).toBeDefined()
expect(project.id).not.toBe(ProjectID.global)
expect(project.vcs).toBe("git")
expect(project.worktree).toBe(tmp.path)
expect(project).toBeDefined()
expect(project.id).not.toBe(ProjectID.global)
expect(project.vcs).toBe("git")
expect(project.worktree).toBe(tmp)
const opencodeFile = path.join(tmp.path, ".git", "opencode")
expect(await Bun.file(opencodeFile).exists()).toBe(true)
})
const opencodeFile = path.join(tmp, ".git", "opencode")
expect(yield* Effect.promise(() => Bun.file(opencodeFile).exists())).toBe(true)
}),
)
test("returns global for non-git directory", async () => {
await using tmp = await tmpdir()
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project.id).toBe(ProjectID.global)
})
it.live("returns global for non-git directory", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project.id).toBe(ProjectID.global)
}),
)
test("derives stable project ID from root commit", async () => {
await using tmp = await tmpdir({ git: true })
const { project: a } = await run((svc) => svc.fromDirectory(tmp.path))
const { project: b } = await run((svc) => svc.fromDirectory(tmp.path))
expect(b.id).toBe(a.id)
})
it.live("derives stable project ID from root commit", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project: a } = yield* run((svc) => svc.fromDirectory(tmp))
const { project: b } = yield* run((svc) => svc.fromDirectory(tmp))
expect(b.id).toBe(a.id)
}),
)
})
describe("Project.fromDirectory git failure paths", () => {
test("keeps vcs when rev-list exits non-zero (no commits)", async () => {
await using tmp = await tmpdir()
await $`git init`.cwd(tmp.path).quiet()
it.live("keeps vcs when rev-list exits non-zero (no commits)", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
yield* Effect.promise(() => $`git init`.cwd(tmp).quiet())
// rev-list fails because HEAD doesn't exist yet this is the natural scenario
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project.vcs).toBe("git")
expect(project.id).toBe(ProjectID.global)
expect(project.worktree).toBe(tmp.path)
})
// rev-list fails because HEAD doesn't exist yet: this is the natural scenario.
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project.vcs).toBe("git")
expect(project.id).toBe(ProjectID.global)
expect(project.worktree).toBe(tmp)
}),
)
test("handles show-toplevel failure gracefully", async () => {
await using tmp = await tmpdir({ git: true })
const layer = projectLayerWithFailure("--show-toplevel")
failureIt("--show-toplevel").live("handles show-toplevel failure gracefully", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project, sandbox } = await run((svc) => svc.fromDirectory(tmp.path), layer)
expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(tmp.path)
})
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project.worktree).toBe(tmp)
expect(sandbox).toBe(tmp)
}),
)
test("handles git-common-dir failure gracefully", async () => {
await using tmp = await tmpdir({ git: true })
const layer = projectLayerWithFailure("--git-common-dir")
failureIt("--git-common-dir").live("handles git-common-dir failure gracefully", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project, sandbox } = await run((svc) => svc.fromDirectory(tmp.path), layer)
expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(tmp.path)
})
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project.worktree).toBe(tmp)
expect(sandbox).toBe(tmp)
}),
)
})
describe("Project.fromDirectory with worktrees", () => {
test("should set worktree to root when called from root", async () => {
await using tmp = await tmpdir({ git: true })
it.live("should set worktree to root when called from root", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project, sandbox } = await run((svc) => svc.fromDirectory(tmp.path))
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(tmp.path)
expect(project.sandboxes).not.toContain(tmp.path)
})
expect(project.worktree).toBe(tmp)
expect(sandbox).toBe(tmp)
expect(project.sandboxes).not.toContain(tmp)
}),
)
test("should set worktree to root when called from a worktree", async () => {
await using tmp = await tmpdir({ git: true })
it.live("should set worktree to root when called from a worktree", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-worktree")
try {
await $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp.path).quiet()
const worktreePath = path.join(tmp, "..", path.basename(tmp) + "-worktree")
yield* Effect.addFinalizer(() => Effect.promise(() => $`git worktree remove ${worktreePath}`.cwd(tmp).quiet().catch(() => {})))
yield* Effect.promise(() => $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp).quiet())
const { project, sandbox } = await run((svc) => svc.fromDirectory(worktreePath))
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(worktreePath))
expect(project.worktree).toBe(tmp.path)
expect(project.worktree).toBe(tmp)
expect(sandbox).toBe(worktreePath)
expect(project.sandboxes).toContain(worktreePath)
expect(project.sandboxes).not.toContain(tmp.path)
} finally {
await $`git worktree remove ${worktreePath}`
.cwd(tmp.path)
.quiet()
.catch(() => {})
}
})
expect(project.sandboxes).not.toContain(tmp)
}),
)
test("worktree should share project ID with main repo", async () => {
await using tmp = await tmpdir({ git: true })
it.live("worktree should share project ID with main repo", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project: main } = await run((svc) => svc.fromDirectory(tmp.path))
const { project: main } = yield* run((svc) => svc.fromDirectory(tmp))
const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt-shared")
try {
await $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp.path).quiet()
const worktreePath = path.join(tmp, "..", path.basename(tmp) + "-wt-shared")
yield* Effect.addFinalizer(() => Effect.promise(() => $`git worktree remove ${worktreePath}`.cwd(tmp).quiet().catch(() => {})))
yield* Effect.promise(() => $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp).quiet())
const { project: wt } = await run((svc) => svc.fromDirectory(worktreePath))
const { project: wt } = yield* run((svc) => svc.fromDirectory(worktreePath))
expect(wt.id).toBe(main.id)
// Cache should live in the common .git dir, not the worktree's .git file
const cache = path.join(tmp.path, ".git", "opencode")
const exists = await Bun.file(cache).exists()
const cache = path.join(tmp, ".git", "opencode")
const exists = yield* Effect.promise(() => Bun.file(cache).exists())
expect(exists).toBe(true)
} finally {
await $`git worktree remove ${worktreePath}`
.cwd(tmp.path)
.quiet()
.catch(() => {})
}
})
}),
)
test("separate clones of the same repo should share project ID", async () => {
await using tmp = await tmpdir({ git: true })
it.live("separate clones of the same repo should share project ID", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
// Create a bare remote, push, then clone into a second directory
const bare = tmp.path + "-bare"
const clone = tmp.path + "-clone"
try {
await $`git clone --bare ${tmp.path} ${bare}`.quiet()
await $`git clone ${bare} ${clone}`.quiet()
const bare = tmp + "-bare"
const clone = tmp + "-clone"
yield* Effect.addFinalizer(() => Effect.promise(() => $`rm -rf ${bare} ${clone}`.quiet().nothrow()).pipe(Effect.ignore))
yield* Effect.promise(() => $`git clone --bare ${tmp} ${bare}`.quiet())
yield* Effect.promise(() => $`git clone ${bare} ${clone}`.quiet())
const { project: a } = await run((svc) => svc.fromDirectory(tmp.path))
const { project: b } = await run((svc) => svc.fromDirectory(clone))
const { project: a } = yield* run((svc) => svc.fromDirectory(tmp))
const { project: b } = yield* run((svc) => svc.fromDirectory(clone))
expect(b.id).toBe(a.id)
} finally {
await $`rm -rf ${bare} ${clone}`.quiet().nothrow()
}
})
}),
)
test("should accumulate multiple worktrees in sandboxes", async () => {
await using tmp = await tmpdir({ git: true })
it.live("should accumulate multiple worktrees in sandboxes", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const worktree1 = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt1")
const worktree2 = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt2")
try {
await $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp.path).quiet()
await $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp.path).quiet()
const worktree1 = path.join(tmp, "..", path.basename(tmp) + "-wt1")
const worktree2 = path.join(tmp, "..", path.basename(tmp) + "-wt2")
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
yield* Effect.promise(() => $`git worktree remove ${worktree1}`.cwd(tmp).quiet().catch(() => {}))
yield* Effect.promise(() => $`git worktree remove ${worktree2}`.cwd(tmp).quiet().catch(() => {}))
}),
)
yield* Effect.promise(() => $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp).quiet())
yield* Effect.promise(() => $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp).quiet())
await run((svc) => svc.fromDirectory(worktree1))
const { project } = await run((svc) => svc.fromDirectory(worktree2))
yield* run((svc) => svc.fromDirectory(worktree1))
const { project } = yield* run((svc) => svc.fromDirectory(worktree2))
expect(project.worktree).toBe(tmp.path)
expect(project.worktree).toBe(tmp)
expect(project.sandboxes).toContain(worktree1)
expect(project.sandboxes).toContain(worktree2)
expect(project.sandboxes).not.toContain(tmp.path)
} finally {
await $`git worktree remove ${worktree1}`
.cwd(tmp.path)
.quiet()
.catch(() => {})
await $`git worktree remove ${worktree2}`
.cwd(tmp.path)
.quiet()
.catch(() => {})
}
})
expect(project.sandboxes).not.toContain(tmp)
}),
)
})
describe("Project.discover", () => {
test("should discover favicon.png in root", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
it.live("should discover favicon.png in root", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
await Bun.write(path.join(tmp.path, "favicon.png"), pngData)
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData))
await run((svc) => svc.discover(project))
yield* run((svc) => svc.discover(project))
const updated = Project.get(project.id)
expect(updated).toBeDefined()
expect(updated!.icon).toBeDefined()
expect(updated!.icon?.url).toStartWith("data:")
expect(updated!.icon?.url).toContain("base64")
expect(updated!.icon?.color).toBeUndefined()
})
const updated = Project.get(project.id)
expect(updated).toBeDefined()
expect(updated!.icon).toBeDefined()
expect(updated!.icon?.url).toStartWith("data:")
expect(updated!.icon?.url).toContain("base64")
expect(updated!.icon?.color).toBeUndefined()
}),
)
test("should not discover non-image files", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
it.live("should not discover non-image files", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
await Bun.write(path.join(tmp.path, "favicon.txt"), "not an image")
yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.txt"), "not an image"))
await run((svc) => svc.discover(project))
yield* run((svc) => svc.discover(project))
const updated = Project.get(project.id)
expect(updated).toBeDefined()
expect(updated!.icon).toBeUndefined()
})
const updated = Project.get(project.id)
expect(updated).toBeDefined()
expect(updated!.icon).toBeUndefined()
}),
)
test("should not discover favicon when override is set", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
it.live("should not discover favicon when override is set", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
await run((svc) =>
svc.update({
projectID: project.id,
icon: { override: "data:image/png;base64,override" },
}),
)
yield* run((svc) =>
svc.update({
projectID: project.id,
icon: { override: "data:image/png;base64,override" },
}),
)
const updatedProject = await run((svc) => svc.get(project.id))
if (!updatedProject) throw new Error("Project not found")
const updatedProject = yield* run((svc) => svc.get(project.id))
if (!updatedProject) throw new Error("Project not found")
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
await Bun.write(path.join(tmp.path, "favicon.png"), pngData)
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData))
await run((svc) => svc.discover(updatedProject))
yield* run((svc) => svc.discover(updatedProject))
const updated = Project.get(project.id)
expect(updated).toBeDefined()
expect(updated!.icon?.override).toBe("data:image/png;base64,override")
expect(updated!.icon?.url).toBeUndefined()
})
const updated = Project.get(project.id)
expect(updated).toBeDefined()
expect(updated!.icon?.override).toBe("data:image/png;base64,override")
expect(updated!.icon?.url).toBeUndefined()
}),
)
})
describe("Project.update", () => {
test("should update name", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
it.live("should update name", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const updated = await run((svc) =>
svc.update({
projectID: project.id,
name: "New Project Name",
}),
)
const updated = yield* run((svc) =>
svc.update({
projectID: project.id,
name: "New Project Name",
}),
)
expect(updated.name).toBe("New Project Name")
expect(updated.name).toBe("New Project Name")
const fromDb = Project.get(project.id)
expect(fromDb?.name).toBe("New Project Name")
})
const fromDb = Project.get(project.id)
expect(fromDb?.name).toBe("New Project Name")
}),
)
test("should update icon url", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
it.live("should update icon url", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const updated = await run((svc) =>
svc.update({
projectID: project.id,
icon: { url: "https://example.com/icon.png" },
}),
)
const updated = yield* run((svc) =>
svc.update({
projectID: project.id,
icon: { url: "https://example.com/icon.png" },
}),
)
expect(updated.icon?.url).toBe("https://example.com/icon.png")
expect(updated.icon?.url).toBe("https://example.com/icon.png")
const fromDb = Project.get(project.id)
expect(fromDb?.icon?.url).toBe("https://example.com/icon.png")
})
const fromDb = Project.get(project.id)
expect(fromDb?.icon?.url).toBe("https://example.com/icon.png")
}),
)
test("should update icon color", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
it.live("should update icon color", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const updated = await run((svc) =>
svc.update({
projectID: project.id,
icon: { color: "#ff0000" },
}),
)
const updated = yield* run((svc) =>
svc.update({
projectID: project.id,
icon: { color: "#ff0000" },
}),
)
expect(updated.icon?.color).toBe("#ff0000")
expect(updated.icon?.color).toBe("#ff0000")
const fromDb = Project.get(project.id)
expect(fromDb?.icon?.color).toBe("#ff0000")
})
const fromDb = Project.get(project.id)
expect(fromDb?.icon?.color).toBe("#ff0000")
}),
)
test("should update icon override", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
it.live("should update icon override", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const updated = await run((svc) =>
svc.update({
projectID: project.id,
icon: { override: "data:image/png;base64,abc123" },
}),
)
const updated = yield* run((svc) =>
svc.update({
projectID: project.id,
icon: { override: "data:image/png;base64,abc123" },
}),
)
expect(updated.icon?.override).toBe("data:image/png;base64,abc123")
expect(updated.icon?.override).toBe("data:image/png;base64,abc123")
const fromDb = Project.get(project.id)
expect(fromDb?.icon?.override).toBe("data:image/png;base64,abc123")
})
const fromDb = Project.get(project.id)
expect(fromDb?.icon?.override).toBe("data:image/png;base64,abc123")
}),
)
test("should update commands", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
it.live("should update commands", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const updated = await run((svc) =>
svc.update({
projectID: project.id,
commands: { start: "npm run dev" },
}),
)
const updated = yield* run((svc) =>
svc.update({
projectID: project.id,
commands: { start: "npm run dev" },
}),
)
expect(updated.commands?.start).toBe("npm run dev")
expect(updated.commands?.start).toBe("npm run dev")
const fromDb = Project.get(project.id)
expect(fromDb?.commands?.start).toBe("npm run dev")
})
const fromDb = Project.get(project.id)
expect(fromDb?.commands?.start).toBe("npm run dev")
}),
)
test("should throw error when project not found", async () => {
await expect(
run((svc) =>
it.live("should throw error when project not found", () =>
Effect.gen(function* () {
const exit = yield* run((svc) =>
svc.update({
projectID: ProjectID.make("nonexistent-project-id"),
name: "Should Fail",
}),
),
).rejects.toThrow("Project not found: nonexistent-project-id")
})
).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const error = Cause.squash(exit.cause)
expect(error instanceof Error ? error.message : String(error)).toContain("Project not found: nonexistent-project-id")
}
}),
)
test("should emit GlobalBus event on update", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
it.live("should emit GlobalBus event on update", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
let eventPayload: any = null
const on = (data: any) => {
eventPayload = data
}
GlobalBus.on("event", on)
let eventPayload: any = null
const on = (data: any) => {
eventPayload = data
}
GlobalBus.on("event", on)
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
try {
await run((svc) => svc.update({ projectID: project.id, name: "Updated Name" }))
yield* run((svc) => svc.update({ projectID: project.id, name: "Updated Name" }))
expect(eventPayload).not.toBeNull()
expect(eventPayload.payload.type).toBe("project.updated")
expect(eventPayload.payload.properties.name).toBe("Updated Name")
} finally {
GlobalBus.off("event", on)
}
})
}),
)
test("should update multiple fields at once", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
it.live("should update multiple fields at once", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const updated = await run((svc) =>
svc.update({
projectID: project.id,
name: "Multi Update",
icon: { url: "https://example.com/favicon.ico", override: "data:image/png;base64,abc123", color: "#00ff00" },
commands: { start: "make start" },
}),
)
const updated = yield* run((svc) =>
svc.update({
projectID: project.id,
name: "Multi Update",
icon: { url: "https://example.com/favicon.ico", override: "data:image/png;base64,abc123", color: "#00ff00" },
commands: { start: "make start" },
}),
)
expect(updated.name).toBe("Multi Update")
expect(updated.icon?.url).toBe("https://example.com/favicon.ico")
expect(updated.icon?.override).toBe("data:image/png;base64,abc123")
expect(updated.icon?.color).toBe("#00ff00")
expect(updated.commands?.start).toBe("make start")
})
expect(updated.name).toBe("Multi Update")
expect(updated.icon?.url).toBe("https://example.com/favicon.ico")
expect(updated.icon?.override).toBe("data:image/png;base64,abc123")
expect(updated.icon?.color).toBe("#00ff00")
expect(updated.commands?.start).toBe("make start")
}),
)
})
describe("Project.list and Project.get", () => {
test("list returns all projects", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
it.live("list returns all projects", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const all = Project.list()
expect(all.length).toBeGreaterThan(0)
expect(all.find((p) => p.id === project.id)).toBeDefined()
})
const all = Project.list()
expect(all.length).toBeGreaterThan(0)
expect(all.find((p) => p.id === project.id)).toBeDefined()
}),
)
test("get returns project by id", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
it.live("get returns project by id", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const found = Project.get(project.id)
expect(found).toBeDefined()
expect(found!.id).toBe(project.id)
})
const found = Project.get(project.id)
expect(found).toBeDefined()
expect(found!.id).toBe(project.id)
}),
)
test("get returns undefined for unknown id", () => {
const found = Project.get(ProjectID.make("nonexistent"))
@@ -472,65 +504,72 @@ describe("Project.list and Project.get", () => {
})
describe("Project.setInitialized", () => {
test("sets time_initialized on project", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
it.live("sets time_initialized on project", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project.time.initialized).toBeUndefined()
expect(project.time.initialized).toBeUndefined()
Project.setInitialized(project.id)
Project.setInitialized(project.id)
const updated = Project.get(project.id)
expect(updated?.time.initialized).toBeDefined()
})
const updated = Project.get(project.id)
expect(updated?.time.initialized).toBeDefined()
}),
)
})
describe("Project.addSandbox and Project.removeSandbox", () => {
test("addSandbox adds directory and removeSandbox removes it", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const sandboxDir = path.join(tmp.path, "sandbox-test")
it.live("addSandbox adds directory and removeSandbox removes it", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const sandboxDir = path.join(tmp, "sandbox-test")
await run((svc) => svc.addSandbox(project.id, sandboxDir))
yield* run((svc) => svc.addSandbox(project.id, sandboxDir))
let found = Project.get(project.id)
expect(found?.sandboxes).toContain(sandboxDir)
let found = Project.get(project.id)
expect(found?.sandboxes).toContain(sandboxDir)
await run((svc) => svc.removeSandbox(project.id, sandboxDir))
yield* run((svc) => svc.removeSandbox(project.id, sandboxDir))
found = Project.get(project.id)
expect(found?.sandboxes).not.toContain(sandboxDir)
})
found = Project.get(project.id)
expect(found?.sandboxes).not.toContain(sandboxDir)
}),
)
test("addSandbox emits GlobalBus event", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const sandboxDir = path.join(tmp.path, "sandbox-event")
it.live("addSandbox emits GlobalBus event", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const sandboxDir = path.join(tmp, "sandbox-event")
const events: any[] = []
const on = (evt: any) => events.push(evt)
GlobalBus.on("event", on)
const events: any[] = []
const on = (evt: any) => events.push(evt)
GlobalBus.on("event", on)
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
await run((svc) => svc.addSandbox(project.id, sandboxDir))
yield* run((svc) => svc.addSandbox(project.id, sandboxDir))
GlobalBus.off("event", on)
expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true)
})
expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true)
}),
)
})
describe("Project.fromDirectory with bare repos", () => {
test("worktree from bare repo should cache in bare repo, not parent", async () => {
await using tmp = await tmpdir({ git: true })
it.live("worktree from bare repo should cache in bare repo, not parent", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const parentDir = path.dirname(tmp.path)
const barePath = path.join(parentDir, `bare-${Date.now()}.git`)
const worktreePath = path.join(parentDir, `worktree-${Date.now()}`)
const parentDir = path.dirname(tmp)
const barePath = path.join(parentDir, `bare-${Date.now()}.git`)
const worktreePath = path.join(parentDir, `worktree-${Date.now()}`)
yield* Effect.addFinalizer(() => Effect.promise(() => $`rm -rf ${barePath} ${worktreePath}`.quiet().nothrow()).pipe(Effect.ignore))
try {
await $`git clone --bare ${tmp.path} ${barePath}`.quiet()
await $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet()
yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet())
yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet())
const { project } = await run((svc) => svc.fromDirectory(worktreePath))
const { project } = yield* run((svc) => svc.fromDirectory(worktreePath))
expect(project.id).not.toBe(ProjectID.global)
expect(project.worktree).toBe(barePath)
@@ -538,31 +577,34 @@ describe("Project.fromDirectory with bare repos", () => {
const correctCache = path.join(barePath, "opencode")
const wrongCache = path.join(parentDir, ".git", "opencode")
expect(await Bun.file(correctCache).exists()).toBe(true)
expect(await Bun.file(wrongCache).exists()).toBe(false)
} finally {
await $`rm -rf ${barePath} ${worktreePath}`.quiet().nothrow()
}
})
expect(yield* Effect.promise(() => Bun.file(correctCache).exists())).toBe(true)
expect(yield* Effect.promise(() => Bun.file(wrongCache).exists())).toBe(false)
}),
)
test("different bare repos under same parent should not share project ID", async () => {
await using tmp1 = await tmpdir({ git: true })
await using tmp2 = await tmpdir({ git: true })
it.live("different bare repos under same parent should not share project ID", () =>
Effect.gen(function* () {
const tmp1 = yield* tmpdirScoped({ git: true })
const tmp2 = yield* tmpdirScoped({ git: true })
const parentDir = path.dirname(tmp1.path)
const bareA = path.join(parentDir, `bare-a-${Date.now()}.git`)
const bareB = path.join(parentDir, `bare-b-${Date.now()}.git`)
const worktreeA = path.join(parentDir, `wt-a-${Date.now()}`)
const worktreeB = path.join(parentDir, `wt-b-${Date.now()}`)
const parentDir = path.dirname(tmp1)
const bareA = path.join(parentDir, `bare-a-${Date.now()}.git`)
const bareB = path.join(parentDir, `bare-b-${Date.now()}.git`)
const worktreeA = path.join(parentDir, `wt-a-${Date.now()}`)
const worktreeB = path.join(parentDir, `wt-b-${Date.now()}`)
yield* Effect.addFinalizer(() =>
Effect.promise(() => $`rm -rf ${bareA} ${bareB} ${worktreeA} ${worktreeB}`.quiet().nothrow()).pipe(
Effect.ignore,
),
)
try {
await $`git clone --bare ${tmp1.path} ${bareA}`.quiet()
await $`git clone --bare ${tmp2.path} ${bareB}`.quiet()
await $`git worktree add ${worktreeA} HEAD`.cwd(bareA).quiet()
await $`git worktree add ${worktreeB} HEAD`.cwd(bareB).quiet()
yield* Effect.promise(() => $`git clone --bare ${tmp1} ${bareA}`.quiet())
yield* Effect.promise(() => $`git clone --bare ${tmp2} ${bareB}`.quiet())
yield* Effect.promise(() => $`git worktree add ${worktreeA} HEAD`.cwd(bareA).quiet())
yield* Effect.promise(() => $`git worktree add ${worktreeB} HEAD`.cwd(bareB).quiet())
const { project: projA } = await run((svc) => svc.fromDirectory(worktreeA))
const { project: projB } = await run((svc) => svc.fromDirectory(worktreeB))
const { project: projA } = yield* run((svc) => svc.fromDirectory(worktreeA))
const { project: projB } = yield* run((svc) => svc.fromDirectory(worktreeB))
expect(projA.id).not.toBe(projB.id)
@@ -570,34 +612,31 @@ describe("Project.fromDirectory with bare repos", () => {
const cacheB = path.join(bareB, "opencode")
const wrongCache = path.join(parentDir, ".git", "opencode")
expect(await Bun.file(cacheA).exists()).toBe(true)
expect(await Bun.file(cacheB).exists()).toBe(true)
expect(await Bun.file(wrongCache).exists()).toBe(false)
} finally {
await $`rm -rf ${bareA} ${bareB} ${worktreeA} ${worktreeB}`.quiet().nothrow()
}
})
expect(yield* Effect.promise(() => Bun.file(cacheA).exists())).toBe(true)
expect(yield* Effect.promise(() => Bun.file(cacheB).exists())).toBe(true)
expect(yield* Effect.promise(() => Bun.file(wrongCache).exists())).toBe(false)
}),
)
test("bare repo without .git suffix is still detected via core.bare", async () => {
await using tmp = await tmpdir({ git: true })
it.live("bare repo without .git suffix is still detected via core.bare", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const parentDir = path.dirname(tmp.path)
const barePath = path.join(parentDir, `bare-no-suffix-${Date.now()}`)
const worktreePath = path.join(parentDir, `worktree-${Date.now()}`)
const parentDir = path.dirname(tmp)
const barePath = path.join(parentDir, `bare-no-suffix-${Date.now()}`)
const worktreePath = path.join(parentDir, `worktree-${Date.now()}`)
yield* Effect.addFinalizer(() => Effect.promise(() => $`rm -rf ${barePath} ${worktreePath}`.quiet().nothrow()).pipe(Effect.ignore))
try {
await $`git clone --bare ${tmp.path} ${barePath}`.quiet()
await $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet()
yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet())
yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet())
const { project } = await run((svc) => svc.fromDirectory(worktreePath))
const { project } = yield* run((svc) => svc.fromDirectory(worktreePath))
expect(project.id).not.toBe(ProjectID.global)
expect(project.worktree).toBe(barePath)
const correctCache = path.join(barePath, "opencode")
expect(await Bun.file(correctCache).exists()).toBe(true)
} finally {
await $`rm -rf ${barePath} ${worktreePath}`.quiet().nothrow()
}
})
expect(yield* Effect.promise(() => Bun.file(correctCache).exists())).toBe(true)
}),
)
})
@@ -582,64 +582,52 @@ it.instance(
},
)
test("model options are merged from existing model", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
anthropic: {
models: {
"claude-sonnet-4-20250514": {
options: {
customOption: "custom-value",
},
},
it.instance(
"model options are merged from existing model",
Effect.gen(function* () {
const providers = yield* Provider.Service.use((provider) => provider.list())
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.options.customOption).toBe("custom-value")
}),
{
config: {
provider: {
anthropic: {
options: {
apiKey: "test-api-key",
},
models: {
"claude-sonnet-4-20250514": {
options: {
customOption: "custom-value",
},
},
},
}),
)
},
},
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
const providers = await list()
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.options.customOption).toBe("custom-value")
},
})
})
},
)
test("provider removed when all models filtered out", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
anthropic: {
whitelist: ["nonexistent-model"],
},
it.instance(
"provider removed when all models filtered out",
Effect.gen(function* () {
const providers = yield* Provider.Service.use((provider) => provider.list())
expect(providers[ProviderID.anthropic]).toBeUndefined()
}),
{
config: {
provider: {
anthropic: {
options: {
apiKey: "test-api-key",
},
}),
)
whitelist: ["nonexistent-model"],
},
},
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
const providers = await list()
expect(providers[ProviderID.anthropic]).toBeUndefined()
},
})
})
},
)
test("closest finds model by partial match", async () => {
await using tmp = await tmpdir({
+161 -175
View File
@@ -1,33 +1,25 @@
import { afterEach, describe, expect, test } from "bun:test"
import { afterEach, describe, expect } from "bun:test"
import { Effect } from "effect"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Session as SessionNs } from "@/session/session"
import * as Log from "@opencode-ai/core/util/log"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture"
import { Flag } from "@opencode-ai/core/flag/flag"
import { mkdir } from "fs/promises"
import path from "path"
import { Database } from "@/storage/db"
import { SessionTable } from "@/session/session.sql"
import { eq } from "drizzle-orm"
import { testEffect } from "../lib/effect"
void Log.init({ print: false })
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
const it = testEffect(SessionNs.defaultLayer)
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
}
const svc = {
...SessionNs,
create(input?: SessionNs.CreateInput) {
return run(SessionNs.Service.use((svc) => svc.create(input)))
},
list(input?: SessionNs.ListInput) {
return run(SessionNs.Service.use((svc) => svc.list(input)))
},
}
const withSession = (input?: Parameters<SessionNs.Interface["create"]>[0]) =>
Effect.acquireRelease(
SessionNs.Service.use((session) => session.create(input)),
(created) => SessionNs.Service.use((session) => session.remove(created.id).pipe(Effect.ignore)),
)
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
@@ -35,205 +27,199 @@ afterEach(async () => {
})
describe("session.list", () => {
test("does not filter by directory when directory is omitted", async () => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false
await using tmp = await tmpdir({ git: true })
await mkdir(path.join(tmp.path, "packages", "opencode"), { recursive: true })
await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true })
it.instance(
"does not filter by directory when directory is omitted",
() =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false
const test = yield* TestInstance
yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "opencode"), { recursive: true }))
yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true }))
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const root = await svc.create({ title: "root" })
const root = yield* withSession({ title: "root" })
const parent = yield* withSession({ title: "parent" }).pipe(
provideInstance(path.join(test.directory, "packages")),
)
const current = yield* withSession({ title: "current" }).pipe(
provideInstance(path.join(test.directory, "packages", "opencode")),
)
const sibling = yield* withSession({ title: "sibling" }).pipe(
provideInstance(path.join(test.directory, "packages", "app")),
)
const parent = await WithInstance.provide({
directory: path.join(tmp.path, "packages"),
fn: async () => svc.create({ title: "parent" }),
})
const current = await WithInstance.provide({
directory: path.join(tmp.path, "packages", "opencode"),
fn: async () => svc.create({ title: "current" }),
})
const sibling = await WithInstance.provide({
directory: path.join(tmp.path, "packages", "app"),
fn: async () => svc.create({ title: "sibling" }),
})
const ids = (await svc.list()).map((s) => s.id)
const ids = (yield* SessionNs.Service.use((session) => session.list())).map((session) => session.id)
expect(ids).toContain(root.id)
expect(ids).toContain(parent.id)
expect(ids).toContain(current.id)
expect(ids).toContain(sibling.id)
},
})
})
}),
{ git: true },
)
test("filters by directory when directory is provided", async () => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false
await using tmp = await tmpdir({ git: true })
await mkdir(path.join(tmp.path, "packages", "opencode"), { recursive: true })
await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true })
it.instance(
"filters by directory when directory is provided",
() =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false
const test = yield* TestInstance
yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "opencode"), { recursive: true }))
yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true }))
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const root = await svc.create({ title: "root" })
const root = yield* withSession({ title: "root" })
const parent = yield* withSession({ title: "parent" }).pipe(
provideInstance(path.join(test.directory, "packages")),
)
const current = yield* withSession({ title: "current" }).pipe(
provideInstance(path.join(test.directory, "packages", "opencode")),
)
const sibling = yield* withSession({ title: "sibling" }).pipe(
provideInstance(path.join(test.directory, "packages", "app")),
)
const parent = await WithInstance.provide({
directory: path.join(tmp.path, "packages"),
fn: async () => svc.create({ title: "parent" }),
})
const current = await WithInstance.provide({
directory: path.join(tmp.path, "packages", "opencode"),
fn: async () => svc.create({ title: "current" }),
})
const sibling = await WithInstance.provide({
directory: path.join(tmp.path, "packages", "app"),
fn: async () => svc.create({ title: "sibling" }),
})
const ids = (await svc.list({ directory: path.join(tmp.path, "packages", "opencode") })).map((s) => s.id)
const ids = (yield* SessionNs.Service.use((session) =>
session.list({ directory: path.join(test.directory, "packages", "opencode") }),
)).map((session) => session.id)
expect(ids).not.toContain(root.id)
expect(ids).not.toContain(parent.id)
expect(ids).toContain(current.id)
expect(ids).not.toContain(sibling.id)
},
})
})
}),
{ git: true },
)
test("filters by path and ignores directory when path is provided", async () => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false
await using tmp = await tmpdir({ git: true })
await mkdir(path.join(tmp.path, "packages", "opencode", "src", "deep"), { recursive: true })
await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true })
it.instance(
"filters by path and ignores directory when path is provided",
() =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false
const test = yield* TestInstance
yield* Effect.promise(() =>
mkdir(path.join(test.directory, "packages", "opencode", "src", "deep"), { recursive: true }),
)
yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true }))
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const parent = await WithInstance.provide({
directory: path.join(tmp.path, "packages", "opencode"),
fn: async () => svc.create({ title: "parent" }),
})
const current = await WithInstance.provide({
directory: path.join(tmp.path, "packages", "opencode", "src"),
fn: async () => svc.create({ title: "current" }),
})
const deeper = await WithInstance.provide({
directory: path.join(tmp.path, "packages", "opencode", "src", "deep"),
fn: async () => svc.create({ title: "deeper" }),
})
const sibling = await WithInstance.provide({
directory: path.join(tmp.path, "packages", "app"),
fn: async () => svc.create({ title: "sibling" }),
})
const parent = yield* withSession({ title: "parent" }).pipe(
provideInstance(path.join(test.directory, "packages", "opencode")),
)
const current = yield* withSession({ title: "current" }).pipe(
provideInstance(path.join(test.directory, "packages", "opencode", "src")),
)
const deeper = yield* withSession({ title: "deeper" }).pipe(
provideInstance(path.join(test.directory, "packages", "opencode", "src", "deep")),
)
const sibling = yield* withSession({ title: "sibling" }).pipe(
provideInstance(path.join(test.directory, "packages", "app")),
)
const pathIDs = (
await svc.list({
directory: path.join(tmp.path, "packages", "app"),
const pathIDs = (yield* SessionNs.Service.use((session) =>
session.list({
directory: path.join(test.directory, "packages", "app"),
path: "packages/opencode/src",
})
).map((s) => s.id)
}),
)).map((session) => session.id)
expect(pathIDs).not.toContain(parent.id)
expect(pathIDs).toContain(current.id)
expect(pathIDs).toContain(deeper.id)
expect(pathIDs).not.toContain(sibling.id)
},
})
})
}),
{ git: true },
)
test("falls back to directory when filtering legacy sessions without path", async () => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false
await using tmp = await tmpdir({ git: true })
await mkdir(path.join(tmp.path, "packages", "opencode", "src"), { recursive: true })
await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true })
it.instance(
"falls back to directory when filtering legacy sessions without path",
() =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false
const test = yield* TestInstance
yield* Effect.promise(() =>
mkdir(path.join(test.directory, "packages", "opencode", "src"), { recursive: true }),
)
yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true }))
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const current = await WithInstance.provide({
directory: path.join(tmp.path, "packages", "opencode", "src"),
fn: async () => svc.create({ title: "legacy-current" }),
})
const sibling = await WithInstance.provide({
directory: path.join(tmp.path, "packages", "app"),
fn: async () => svc.create({ title: "legacy-sibling" }),
})
const current = yield* withSession({ title: "legacy-current" }).pipe(
provideInstance(path.join(test.directory, "packages", "opencode", "src")),
)
const sibling = yield* withSession({ title: "legacy-sibling" }).pipe(
provideInstance(path.join(test.directory, "packages", "app")),
)
Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, current.id)).run())
Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sibling.id)).run())
yield* Effect.sync(() =>
Database.use((db) =>
db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, current.id)).run(),
),
)
yield* Effect.sync(() =>
Database.use((db) =>
db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sibling.id)).run(),
),
)
const pathIDs = (
await svc.list({
directory: path.join(tmp.path, "packages", "opencode", "src"),
const pathIDs = (yield* SessionNs.Service.use((session) =>
session.list({
directory: path.join(test.directory, "packages", "opencode", "src"),
path: "packages/opencode/src",
})
).map((s) => s.id)
}),
)).map((session) => session.id)
expect(pathIDs).toContain(current.id)
expect(pathIDs).not.toContain(sibling.id)
},
})
})
}),
{ git: true },
)
test("filters root sessions", async () => {
await using tmp = await tmpdir({ git: true })
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const root = await svc.create({ title: "root-session" })
const child = await svc.create({ title: "child-session", parentID: root.id })
it.instance(
"filters root sessions",
() =>
Effect.gen(function* () {
const root = yield* withSession({ title: "root-session" })
const child = yield* withSession({ title: "child-session", parentID: root.id })
const sessions = await svc.list({ roots: true })
const ids = sessions.map((s) => s.id)
const sessions = yield* SessionNs.Service.use((session) => session.list({ roots: true }))
const ids = sessions.map((session) => session.id)
expect(ids).toContain(root.id)
expect(ids).not.toContain(child.id)
},
})
})
}),
{ git: true },
)
test("filters by start time", async () => {
await using tmp = await tmpdir({ git: true })
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await svc.create({ title: "new-session" })
const futureStart = Date.now() + 86400000
const sessions = await svc.list({ start: futureStart })
it.instance(
"filters by start time",
() =>
Effect.gen(function* () {
yield* withSession({ title: "new-session" })
const sessions = yield* SessionNs.Service.use((session) => session.list({ start: Date.now() + 86400000 }))
expect(sessions.length).toBe(0)
},
})
})
}),
{ git: true },
)
test("filters by search term", async () => {
await using tmp = await tmpdir({ git: true })
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await svc.create({ title: "unique-search-term-abc" })
await svc.create({ title: "other-session-xyz" })
it.instance(
"filters by search term",
() =>
Effect.gen(function* () {
yield* withSession({ title: "unique-search-term-abc" })
yield* withSession({ title: "other-session-xyz" })
const sessions = await svc.list({ search: "unique-search" })
const titles = sessions.map((s) => s.title)
const sessions = yield* SessionNs.Service.use((session) => session.list({ search: "unique-search" }))
const titles = sessions.map((session) => session.title)
expect(titles).toContain("unique-search-term-abc")
expect(titles).not.toContain("other-session-xyz")
},
})
})
}),
{ git: true },
)
test("respects limit parameter", async () => {
await using tmp = await tmpdir({ git: true })
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await svc.create({ title: "session-1" })
await svc.create({ title: "session-2" })
await svc.create({ title: "session-3" })
it.instance(
"respects limit parameter",
() =>
Effect.gen(function* () {
yield* withSession({ title: "session-1" })
yield* withSession({ title: "session-2" })
yield* withSession({ title: "session-3" })
const sessions = await svc.list({ limit: 2 })
const sessions = yield* SessionNs.Service.use((session) => session.list({ limit: 2 }))
expect(sessions.length).toBe(2)
},
})
})
}),
{ git: true },
)
})
+132 -144
View File
@@ -1,186 +1,174 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Exit, Layer } from "effect"
import { Session as SessionNs } from "@/session/session"
import { Bus } from "../../src/bus"
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
import * as Log from "@opencode-ai/core/util/log"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Flag } from "@opencode-ai/core/flag/flag"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { AppRuntime } from "../../src/effect/app-runtime"
import { tmpdir } from "../fixture/fixture"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const projectRoot = path.join(__dirname, "../..")
void Log.init({ print: false })
function create(input?: SessionNs.CreateInput) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create(input)))
}
const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, CrossSpawnSpawner.defaultLayer))
function get(id: SessionID) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.get(id)))
}
const awaitDeferred = <T>(deferred: Deferred.Deferred<T>, message: string) =>
Effect.race(
Deferred.await(deferred),
Effect.sleep("2 seconds").pipe(Effect.flatMap(() => Effect.fail(new Error(message)))),
)
function remove(id: SessionID) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.remove(id)))
}
const remove = (id: SessionID) => SessionNs.Service.use((svc) => svc.remove(id))
function updateMessage<T extends MessageV2.Info>(msg: T) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updateMessage(msg)))
}
function updatePart<T extends MessageV2.Part>(part: T) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updatePart(part)))
const subscribeGlobal = (type: string, callback: (event: NonNullable<GlobalEvent["payload"]>) => void) => {
const listener = (event: GlobalEvent) => {
if (event.payload?.type === type) callback(event.payload)
}
GlobalBus.on("event", listener)
return () => GlobalBus.off("event", listener)
}
describe("session.created event", () => {
test("should emit session.created event when session is created", async () => {
await WithInstance.provide({
directory: projectRoot,
fn: async () => {
let eventReceived = false
let receivedInfo: SessionNs.Info | undefined
it.instance("should emit session.created event when session is created", () =>
Effect.gen(function* () {
const session = yield* SessionNs.Service
const received = yield* Deferred.make<SessionNs.Info>()
const unsub = Bus.subscribe(SessionNs.Event.Created, (event) => {
eventReceived = true
receivedInfo = event.properties.info as SessionNs.Info
})
const unsub = subscribeGlobal(SessionNs.Event.Created.type, (event) => {
Deferred.doneUnsafe(received, Effect.succeed(event.properties.info as SessionNs.Info))
})
yield* Effect.addFinalizer(() => Effect.sync(unsub))
const info = await create({})
await new Promise((resolve) => setTimeout(resolve, 100))
unsub()
const info = yield* session.create({})
const receivedInfo = yield* awaitDeferred(received, "timed out waiting for session.created")
expect(eventReceived).toBe(true)
expect(receivedInfo).toBeDefined()
expect(receivedInfo?.id).toBe(info.id)
expect(receivedInfo?.projectID).toBe(info.projectID)
expect(receivedInfo?.directory).toBe(info.directory)
expect(receivedInfo?.path).toBe(info.path)
expect(receivedInfo?.title).toBe(info.title)
expect(receivedInfo.id).toBe(info.id)
expect(receivedInfo.projectID).toBe(info.projectID)
expect(receivedInfo.directory).toBe(info.directory)
expect(receivedInfo.path).toBe(info.path)
expect(receivedInfo.title).toBe(info.title)
await remove(info.id)
},
})
})
yield* session.remove(info.id)
}),
)
test("session.created event should be emitted before session.updated", async () => {
await WithInstance.provide({
directory: projectRoot,
fn: async () => {
const events: string[] = []
it.instance("session.created event should be emitted before session.updated", () =>
Effect.gen(function* () {
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return
const unsubCreated = Bus.subscribe(SessionNs.Event.Created, () => {
events.push("created")
})
const session = yield* SessionNs.Service
const events: string[] = []
const received = yield* Deferred.make<string[]>()
const push = (event: string) => {
events.push(event)
if (events.includes("created") && events.includes("updated")) {
Deferred.doneUnsafe(received, Effect.succeed(events))
}
}
const unsubUpdated = Bus.subscribe(SessionNs.Event.Updated, () => {
events.push("updated")
})
const unsubCreated = subscribeGlobal(SessionNs.Event.Created.type, () => {
push("created")
})
yield* Effect.addFinalizer(() => Effect.sync(unsubCreated))
const info = await create({})
await new Promise((resolve) => setTimeout(resolve, 100))
unsubCreated()
unsubUpdated()
const unsubUpdated = subscribeGlobal(SessionNs.Event.Updated.type, () => {
push("updated")
})
yield* Effect.addFinalizer(() => Effect.sync(unsubUpdated))
expect(events).toContain("created")
expect(events).toContain("updated")
expect(events.indexOf("created")).toBeLessThan(events.indexOf("updated"))
const info = yield* session.create({})
const receivedEvents = yield* awaitDeferred(received, "timed out waiting for session created/updated events")
await remove(info.id)
},
})
})
expect(receivedEvents).toContain("created")
expect(receivedEvents).toContain("updated")
expect(receivedEvents.indexOf("created")).toBeLessThan(receivedEvents.indexOf("updated"))
yield* session.remove(info.id)
}),
)
})
describe("step-finish token propagation via Bus event", () => {
test(
it.instance(
"non-zero tokens propagate through PartUpdated event",
async () => {
await WithInstance.provide({
directory: projectRoot,
fn: async () => {
const info = await create({})
() =>
Effect.gen(function* () {
const session = yield* SessionNs.Service
const info = yield* session.create({})
const messageID = MessageID.ascending()
await updateMessage({
id: messageID,
sessionID: info.id,
role: "user",
time: { created: Date.now() },
agent: "user",
model: { providerID: "test", modelID: "test" },
tools: {},
mode: "",
} as unknown as MessageV2.Info)
const messageID = MessageID.ascending()
yield* session.updateMessage({
id: messageID,
sessionID: info.id,
role: "user",
time: { created: Date.now() },
agent: "user",
model: { providerID: "test", modelID: "test" },
tools: {},
mode: "",
} as unknown as MessageV2.Info)
// Bus subscribers receive readonly Schema.Type payloads; `MessageV2.Part`
// is the mutable domain type. Cast bridges the two — safe because the
// test only reads the value afterwards.
let received: MessageV2.Part | undefined
const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, (event) => {
received = event.properties.part as MessageV2.Part
})
// Bus subscribers receive readonly Schema.Type payloads; `MessageV2.Part`
// is the mutable domain type. Cast bridges the two — safe because the
// test only reads the value afterwards.
const received = yield* Deferred.make<MessageV2.Part>()
const unsub = subscribeGlobal(MessageV2.Event.PartUpdated.type, (event) => {
Deferred.doneUnsafe(received, Effect.succeed(event.properties.part as MessageV2.Part))
})
yield* Effect.addFinalizer(() => Effect.sync(unsub))
const tokens = {
total: 1500,
input: 500,
output: 800,
reasoning: 200,
cache: { read: 100, write: 50 },
}
const tokens = {
total: 1500,
input: 500,
output: 800,
reasoning: 200,
cache: { read: 100, write: 50 },
}
const partInput = {
id: PartID.ascending(),
messageID,
sessionID: info.id,
type: "step-finish" as const,
reason: "stop",
cost: 0.005,
tokens,
}
const partInput = {
id: PartID.ascending(),
messageID,
sessionID: info.id,
type: "step-finish" as const,
reason: "stop",
cost: 0.005,
tokens,
}
await updatePart(partInput)
await new Promise((resolve) => setTimeout(resolve, 100))
yield* session.updatePart(partInput)
const receivedPart = yield* awaitDeferred(received, "timed out waiting for message.part.updated")
expect(received).toBeDefined()
expect(received!.type).toBe("step-finish")
const finish = received as MessageV2.StepFinishPart
expect(finish.tokens.input).toBe(500)
expect(finish.tokens.output).toBe(800)
expect(finish.tokens.reasoning).toBe(200)
expect(finish.tokens.total).toBe(1500)
expect(finish.tokens.cache.read).toBe(100)
expect(finish.tokens.cache.write).toBe(50)
expect(finish.cost).toBe(0.005)
expect(received).not.toBe(partInput)
expect(receivedPart.type).toBe("step-finish")
const finish = receivedPart as MessageV2.StepFinishPart
expect(finish.tokens.input).toBe(500)
expect(finish.tokens.output).toBe(800)
expect(finish.tokens.reasoning).toBe(200)
expect(finish.tokens.total).toBe(1500)
expect(finish.tokens.cache.read).toBe(100)
expect(finish.tokens.cache.write).toBe(50)
expect(finish.cost).toBe(0.005)
expect(receivedPart).not.toBe(partInput)
unsub()
await remove(info.id)
},
})
},
yield* session.remove(info.id)
}),
{ timeout: 30000 },
)
})
describe("Session", () => {
test("remove works without an instance", async () => {
await using tmp = await tmpdir({ git: true })
it.live("remove works without an instance", () =>
Effect.gen(function* () {
const session = yield* SessionNs.Service
const dir = yield* tmpdirScoped({ git: true })
const info = yield* provideInstance(dir)(session.create({ title: "remove-without-instance" }))
const info = await WithInstance.provide({
directory: tmp.path,
fn: () => create({ title: "remove-without-instance" }),
})
const removeExit = yield* remove(info.id).pipe(Effect.exit)
expect(Exit.isSuccess(removeExit)).toBe(true)
await expect(async () => {
await remove(info.id)
}).not.toThrow()
let missing = false
await get(info.id).catch(() => {
missing = true
})
expect(missing).toBe(true)
})
const getExit = yield* session.get(info.id).pipe(Effect.exit)
expect(Exit.isFailure(getExit)).toBe(true)
}),
)
})
+397 -479
View File
@@ -1,20 +1,19 @@
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import path from "path"
import * as fs from "fs/promises"
import { Effect, ManagedRuntime, Layer } from "effect"
import { Cause, Effect, Exit, Layer } from "effect"
import { ApplyPatchTool } from "../../src/tool/apply_patch"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { LSP } from "@/lsp/lsp"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Format } from "../../src/format"
import { Agent } from "../../src/agent/agent"
import { Bus } from "../../src/bus"
import { Truncate } from "@/tool/truncate"
import { tmpdir } from "../fixture/fixture"
import { TestInstance } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema"
import { testEffect } from "../lib/effect"
const runtime = ManagedRuntime.make(
const it = testEffect(
Layer.mergeAll(
LSP.defaultLayer,
AppFileSystem.defaultLayer,
@@ -58,11 +57,11 @@ type ToolCtx = typeof baseCtx & {
ask: (input: AskInput) => Effect.Effect<void>
}
const execute = async (params: { patchText: string }, ctx: ToolCtx) => {
const info = await runtime.runPromise(ApplyPatchTool)
const tool = await runtime.runPromise(info.init())
return Effect.runPromise(tool.execute(params, ctx))
}
const execute = Effect.fn("ApplyPatchToolTest.execute")(function* (params: { patchText: string }, ctx: ToolCtx) {
const info = yield* ApplyPatchTool
const tool = yield* info.init()
return yield* tool.execute(params, ctx)
})
const makeCtx = () => {
const calls: AskInput[] = []
@@ -77,39 +76,56 @@ const makeCtx = () => {
return { ctx, calls }
}
const readText = (filepath: string) => Effect.promise(() => fs.readFile(filepath, "utf-8"))
const writeText = (filepath: string, content: string) => Effect.promise(() => fs.writeFile(filepath, content, "utf-8"))
const makeDir = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true }))
const expectFailure = <A, E, R>(effect: Effect.Effect<A, E, R>, message?: string) =>
Effect.gen(function* () {
const exit = yield* Effect.exit(effect)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit) && message) expect(Cause.pretty(exit.cause)).toContain(message)
})
const expectReadFailure = (filepath: string) => expectFailure(readText(filepath))
describe("tool.apply_patch freeform", () => {
test("requires patchText", async () => {
const { ctx } = makeCtx()
await expect(execute({ patchText: "" }, ctx)).rejects.toThrow("patchText is required")
})
it.live("requires patchText", () =>
Effect.gen(function* () {
const { ctx } = makeCtx()
yield* expectFailure(execute({ patchText: "" }, ctx), "patchText is required")
}),
)
test("rejects invalid patch format", async () => {
const { ctx } = makeCtx()
await expect(execute({ patchText: "invalid patch" }, ctx)).rejects.toThrow("apply_patch verification failed")
})
it.live("rejects invalid patch format", () =>
Effect.gen(function* () {
const { ctx } = makeCtx()
yield* expectFailure(execute({ patchText: "invalid patch" }, ctx), "apply_patch verification failed")
}),
)
test("rejects empty patch", async () => {
const { ctx } = makeCtx()
const emptyPatch = "*** Begin Patch\n*** End Patch"
await expect(execute({ patchText: emptyPatch }, ctx)).rejects.toThrow("patch rejected: empty patch")
})
it.live("rejects empty patch", () =>
Effect.gen(function* () {
const { ctx } = makeCtx()
yield* expectFailure(execute({ patchText: "*** Begin Patch\n*** End Patch" }, ctx), "patch rejected: empty patch")
}),
)
test("applies add/update/delete in one patch", async () => {
await using fixture = await tmpdir({ git: true })
const { ctx, calls } = makeCtx()
await WithInstance.provide({
directory: fixture.path,
fn: async () => {
const modifyPath = path.join(fixture.path, "modify.txt")
const deletePath = path.join(fixture.path, "delete.txt")
await fs.writeFile(modifyPath, "line1\nline2\n", "utf-8")
await fs.writeFile(deletePath, "obsolete\n", "utf-8")
it.instance(
"applies add/update/delete in one patch",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx, calls } = makeCtx()
const modifyPath = path.join(test.directory, "modify.txt")
const deletePath = path.join(test.directory, "delete.txt")
yield* writeText(modifyPath, "line1\nline2\n")
yield* writeText(deletePath, "obsolete\n")
const patchText =
"*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Delete File: delete.txt\n*** Update File: modify.txt\n@@\n-line2\n+changed\n*** End Patch"
const result = await execute({ patchText }, ctx)
const result = yield* execute({ patchText }, ctx)
expect(result.title).toContain("Success. Updated the following files")
expect(result.output).toContain("Success. Updated the following files")
@@ -129,38 +145,34 @@ describe("tool.apply_patch freeform", () => {
expect(permissionCall.metadata.files.map((f) => f.type).sort()).toEqual(["add", "delete", "update"])
const addFile = permissionCall.metadata.files.find((f) => f.type === "add")
expect(addFile).toBeDefined()
expect(addFile!.relativePath).toBe("nested/new.txt")
expect(addFile!.patch).toContain("+created")
expect(addFile?.relativePath).toBe("nested/new.txt")
expect(addFile?.patch).toContain("+created")
const updateFile = permissionCall.metadata.files.find((f) => f.type === "update")
expect(updateFile).toBeDefined()
expect(updateFile!.patch).toContain("-line2")
expect(updateFile!.patch).toContain("+changed")
expect(updateFile?.patch).toContain("-line2")
expect(updateFile?.patch).toContain("+changed")
const added = await fs.readFile(path.join(fixture.path, "nested", "new.txt"), "utf-8")
expect(added).toBe("created\n")
expect(await fs.readFile(modifyPath, "utf-8")).toBe("line1\nchanged\n")
await expect(fs.readFile(deletePath, "utf-8")).rejects.toThrow()
},
})
})
expect(yield* readText(path.join(test.directory, "nested", "new.txt"))).toBe("created\n")
expect(yield* readText(modifyPath)).toBe("line1\nchanged\n")
yield* expectReadFailure(deletePath)
}),
{ git: true },
)
test("permission metadata includes move file info", async () => {
await using fixture = await tmpdir({ git: true })
const { ctx, calls } = makeCtx()
await WithInstance.provide({
directory: fixture.path,
fn: async () => {
const original = path.join(fixture.path, "old", "name.txt")
await fs.mkdir(path.dirname(original), { recursive: true })
await fs.writeFile(original, "old content\n", "utf-8")
it.instance(
"permission metadata includes move file info",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx, calls } = makeCtx()
const original = path.join(test.directory, "old", "name.txt")
yield* makeDir(path.dirname(original))
yield* writeText(original, "old content\n")
const patchText =
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch"
await execute({ patchText }, ctx)
yield* execute({ patchText }, ctx)
expect(calls.length).toBe(1)
const permissionCall = calls[0]
@@ -169,447 +181,353 @@ describe("tool.apply_patch freeform", () => {
const moveFile = permissionCall.metadata.files[0]
expect(moveFile.type).toBe("move")
expect(moveFile.relativePath).toBe("renamed/dir/name.txt")
expect(moveFile.movePath).toBe(path.join(fixture.path, "renamed/dir/name.txt"))
expect(moveFile.movePath).toBe(path.join(test.directory, "renamed/dir/name.txt"))
expect(moveFile.patch).toContain("-old content")
expect(moveFile.patch).toContain("+new content")
},
})
})
test("applies multiple hunks to one file", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await WithInstance.provide({
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "multi.txt")
await fs.writeFile(target, "line1\nline2\nline3\nline4\n", "utf-8")
const patchText =
"*** Begin Patch\n*** Update File: multi.txt\n@@\n-line2\n+changed2\n@@\n-line4\n+changed4\n*** End Patch"
await execute({ patchText }, ctx)
expect(await fs.readFile(target, "utf-8")).toBe("line1\nchanged2\nline3\nchanged4\n")
},
})
})
test("does not invent a first-line diff for BOM files", async () => {
await using fixture = await tmpdir()
const { ctx, calls } = makeCtx()
await WithInstance.provide({
directory: fixture.path,
fn: async () => {
const bom = String.fromCharCode(0xfeff)
const target = path.join(fixture.path, "example.cs")
await fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`, "utf-8")
const patchText =
"*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch"
await execute({ patchText }, ctx)
expect(calls.length).toBe(1)
const shown = calls[0].metadata.files[0]?.patch ?? ""
expect(shown).not.toContain(bom)
expect(shown).not.toContain("-using System;")
expect(shown).not.toContain("+using System;")
const content = await fs.readFile(target, "utf-8")
expect(content.charCodeAt(0)).toBe(0xfeff)
expect(content.slice(1)).toBe("using System;\n\nclass Test {}\nclass Next {}\n")
},
})
})
test("inserts lines with insert-only hunk", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await WithInstance.provide({
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "insert_only.txt")
await fs.writeFile(target, "alpha\nomega\n", "utf-8")
const patchText = "*** Begin Patch\n*** Update File: insert_only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch"
await execute({ patchText }, ctx)
expect(await fs.readFile(target, "utf-8")).toBe("alpha\nbeta\nomega\n")
},
})
})
test("appends trailing newline on update", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await WithInstance.provide({
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "no_newline.txt")
await fs.writeFile(target, "no newline at end", "utf-8")
const patchText =
"*** Begin Patch\n*** Update File: no_newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch"
await execute({ patchText }, ctx)
const contents = await fs.readFile(target, "utf-8")
expect(contents.endsWith("\n")).toBe(true)
expect(contents).toBe("first line\nsecond line\n")
},
})
})
test("moves file to a new directory", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await WithInstance.provide({
directory: fixture.path,
fn: async () => {
const original = path.join(fixture.path, "old", "name.txt")
await fs.mkdir(path.dirname(original), { recursive: true })
await fs.writeFile(original, "old content\n", "utf-8")
const patchText =
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch"
await execute({ patchText }, ctx)
const moved = path.join(fixture.path, "renamed", "dir", "name.txt")
await expect(fs.readFile(original, "utf-8")).rejects.toThrow()
expect(await fs.readFile(moved, "utf-8")).toBe("new content\n")
},
})
})
test("moves file overwriting existing destination", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await WithInstance.provide({
directory: fixture.path,
fn: async () => {
const original = path.join(fixture.path, "old", "name.txt")
const destination = path.join(fixture.path, "renamed", "dir", "name.txt")
await fs.mkdir(path.dirname(original), { recursive: true })
await fs.mkdir(path.dirname(destination), { recursive: true })
await fs.writeFile(original, "from\n", "utf-8")
await fs.writeFile(destination, "existing\n", "utf-8")
const patchText =
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-from\n+new\n*** End Patch"
await execute({ patchText }, ctx)
await expect(fs.readFile(original, "utf-8")).rejects.toThrow()
expect(await fs.readFile(destination, "utf-8")).toBe("new\n")
},
})
})
test("adds file overwriting existing file", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await WithInstance.provide({
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "duplicate.txt")
await fs.writeFile(target, "old content\n", "utf-8")
const patchText = "*** Begin Patch\n*** Add File: duplicate.txt\n+new content\n*** End Patch"
await execute({ patchText }, ctx)
expect(await fs.readFile(target, "utf-8")).toBe("new content\n")
},
})
})
test("rejects update when target file is missing", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await WithInstance.provide({
directory: fixture.path,
fn: async () => {
const patchText = "*** Begin Patch\n*** Update File: missing.txt\n@@\n-nope\n+better\n*** End Patch"
await expect(execute({ patchText }, ctx)).rejects.toThrow(
"apply_patch verification failed: Failed to read file to update",
)
},
})
})
test("rejects delete when file is missing", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await WithInstance.provide({
directory: fixture.path,
fn: async () => {
const patchText = "*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch"
await expect(execute({ patchText }, ctx)).rejects.toThrow()
},
})
})
test("rejects delete when target is a directory", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await WithInstance.provide({
directory: fixture.path,
fn: async () => {
const dirPath = path.join(fixture.path, "dir")
await fs.mkdir(dirPath)
const patchText = "*** Begin Patch\n*** Delete File: dir\n*** End Patch"
await expect(execute({ patchText }, ctx)).rejects.toThrow()
},
})
})
test("rejects invalid hunk header", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await WithInstance.provide({
directory: fixture.path,
fn: async () => {
const patchText = "*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"
await expect(execute({ patchText }, ctx)).rejects.toThrow("apply_patch verification failed")
},
})
})
test("rejects update with missing context", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await WithInstance.provide({
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "modify.txt")
await fs.writeFile(target, "line1\nline2\n", "utf-8")
const patchText = "*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch"
await expect(execute({ patchText }, ctx)).rejects.toThrow("apply_patch verification failed")
expect(await fs.readFile(target, "utf-8")).toBe("line1\nline2\n")
},
})
})
test("verification failure leaves no side effects", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await WithInstance.provide({
directory: fixture.path,
fn: async () => {
const patchText =
"*** Begin Patch\n*** Add File: created.txt\n+hello\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch"
await expect(execute({ patchText }, ctx)).rejects.toThrow()
const createdPath = path.join(fixture.path, "created.txt")
await expect(fs.readFile(createdPath, "utf-8")).rejects.toThrow()
},
})
})
test("supports end of file anchor", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await WithInstance.provide({
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "tail.txt")
await fs.writeFile(target, "alpha\nlast\n", "utf-8")
const patchText = "*** Begin Patch\n*** Update File: tail.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch"
await execute({ patchText }, ctx)
expect(await fs.readFile(target, "utf-8")).toBe("alpha\nend\n")
},
})
})
test("rejects missing second chunk context", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await WithInstance.provide({
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "two_chunks.txt")
await fs.writeFile(target, "a\nb\nc\nd\n", "utf-8")
const patchText = "*** Begin Patch\n*** Update File: two_chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch"
await expect(execute({ patchText }, ctx)).rejects.toThrow()
expect(await fs.readFile(target, "utf-8")).toBe("a\nb\nc\nd\n")
},
})
})
test("disambiguates change context with @@ header", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await WithInstance.provide({
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "multi_ctx.txt")
await fs.writeFile(target, "fn a\nx=10\ny=2\nfn b\nx=10\ny=20\n", "utf-8")
const patchText = "*** Begin Patch\n*** Update File: multi_ctx.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch"
await execute({ patchText }, ctx)
expect(await fs.readFile(target, "utf-8")).toBe("fn a\nx=10\ny=2\nfn b\nx=11\ny=20\n")
},
})
})
test("EOF anchor matches from end of file first", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await WithInstance.provide({
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "eof_anchor.txt")
// File has duplicate "marker" lines - one in middle, one at end
await fs.writeFile(target, "start\nmarker\nmiddle\nmarker\nend\n", "utf-8")
// With EOF anchor, should match the LAST "marker" line, not the first
const patchText =
"*** Begin Patch\n*** Update File: eof_anchor.txt\n@@\n-marker\n-end\n+marker-changed\n+end\n*** End of File\n*** End Patch"
await execute({ patchText }, ctx)
// First marker unchanged, second marker changed
expect(await fs.readFile(target, "utf-8")).toBe("start\nmarker\nmiddle\nmarker-changed\nend\n")
},
})
})
test("parses heredoc-wrapped patch", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await WithInstance.provide({
directory: fixture.path,
fn: async () => {
const patchText = `cat <<'EOF'
}),
{ git: true },
)
it.instance("applies multiple hunks to one file", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const target = path.join(test.directory, "multi.txt")
yield* writeText(target, "line1\nline2\nline3\nline4\n")
const patchText =
"*** Begin Patch\n*** Update File: multi.txt\n@@\n-line2\n+changed2\n@@\n-line4\n+changed4\n*** End Patch"
yield* execute({ patchText }, ctx)
expect(yield* readText(target)).toBe("line1\nchanged2\nline3\nchanged4\n")
}),
)
it.instance("does not invent a first-line diff for BOM files", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx, calls } = makeCtx()
const bom = String.fromCharCode(0xfeff)
const target = path.join(test.directory, "example.cs")
yield* writeText(target, `${bom}using System;\n\nclass Test {}\n`)
const patchText =
"*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch"
yield* execute({ patchText }, ctx)
expect(calls.length).toBe(1)
const shown = calls[0].metadata.files[0]?.patch ?? ""
expect(shown).not.toContain(bom)
expect(shown).not.toContain("-using System;")
expect(shown).not.toContain("+using System;")
const content = yield* readText(target)
expect(content.charCodeAt(0)).toBe(0xfeff)
expect(content.slice(1)).toBe("using System;\n\nclass Test {}\nclass Next {}\n")
}),
)
it.instance("inserts lines with insert-only hunk", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const target = path.join(test.directory, "insert_only.txt")
yield* writeText(target, "alpha\nomega\n")
const patchText = "*** Begin Patch\n*** Update File: insert_only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch"
yield* execute({ patchText }, ctx)
expect(yield* readText(target)).toBe("alpha\nbeta\nomega\n")
}),
)
it.instance("appends trailing newline on update", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const target = path.join(test.directory, "no_newline.txt")
yield* writeText(target, "no newline at end")
const patchText =
"*** Begin Patch\n*** Update File: no_newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch"
yield* execute({ patchText }, ctx)
const contents = yield* readText(target)
expect(contents.endsWith("\n")).toBe(true)
expect(contents).toBe("first line\nsecond line\n")
}),
)
it.instance("moves file to a new directory", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const original = path.join(test.directory, "old", "name.txt")
yield* makeDir(path.dirname(original))
yield* writeText(original, "old content\n")
const patchText =
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch"
yield* execute({ patchText }, ctx)
const moved = path.join(test.directory, "renamed", "dir", "name.txt")
yield* expectReadFailure(original)
expect(yield* readText(moved)).toBe("new content\n")
}),
)
it.instance("moves file overwriting existing destination", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const original = path.join(test.directory, "old", "name.txt")
const destination = path.join(test.directory, "renamed", "dir", "name.txt")
yield* makeDir(path.dirname(original))
yield* makeDir(path.dirname(destination))
yield* writeText(original, "from\n")
yield* writeText(destination, "existing\n")
const patchText =
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-from\n+new\n*** End Patch"
yield* execute({ patchText }, ctx)
yield* expectReadFailure(original)
expect(yield* readText(destination)).toBe("new\n")
}),
)
it.instance("adds file overwriting existing file", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const target = path.join(test.directory, "duplicate.txt")
yield* writeText(target, "old content\n")
const patchText = "*** Begin Patch\n*** Add File: duplicate.txt\n+new content\n*** End Patch"
yield* execute({ patchText }, ctx)
expect(yield* readText(target)).toBe("new content\n")
}),
)
it.instance("rejects update when target file is missing", () =>
Effect.gen(function* () {
const { ctx } = makeCtx()
const patchText = "*** Begin Patch\n*** Update File: missing.txt\n@@\n-nope\n+better\n*** End Patch"
yield* expectFailure(
execute({ patchText }, ctx),
"apply_patch verification failed: Failed to read file to update",
)
}),
)
it.instance("rejects delete when file is missing", () =>
Effect.gen(function* () {
const { ctx } = makeCtx()
const patchText = "*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch"
yield* expectFailure(execute({ patchText }, ctx))
}),
)
it.instance("rejects delete when target is a directory", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const dirPath = path.join(test.directory, "dir")
yield* makeDir(dirPath)
const patchText = "*** Begin Patch\n*** Delete File: dir\n*** End Patch"
yield* expectFailure(execute({ patchText }, ctx))
}),
)
it.instance("rejects invalid hunk header", () =>
Effect.gen(function* () {
const { ctx } = makeCtx()
const patchText = "*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"
yield* expectFailure(execute({ patchText }, ctx), "apply_patch verification failed")
}),
)
it.instance("rejects update with missing context", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const target = path.join(test.directory, "modify.txt")
yield* writeText(target, "line1\nline2\n")
const patchText = "*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch"
yield* expectFailure(execute({ patchText }, ctx), "apply_patch verification failed")
expect(yield* readText(target)).toBe("line1\nline2\n")
}),
)
it.instance("verification failure leaves no side effects", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const patchText =
"*** Begin Patch\n*** Add File: created.txt\n+hello\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch"
yield* expectFailure(execute({ patchText }, ctx))
yield* expectReadFailure(path.join(test.directory, "created.txt"))
}),
)
it.instance("supports end of file anchor", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const target = path.join(test.directory, "tail.txt")
yield* writeText(target, "alpha\nlast\n")
const patchText = "*** Begin Patch\n*** Update File: tail.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch"
yield* execute({ patchText }, ctx)
expect(yield* readText(target)).toBe("alpha\nend\n")
}),
)
it.instance("rejects missing second chunk context", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const target = path.join(test.directory, "two_chunks.txt")
yield* writeText(target, "a\nb\nc\nd\n")
const patchText = "*** Begin Patch\n*** Update File: two_chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch"
yield* expectFailure(execute({ patchText }, ctx))
expect(yield* readText(target)).toBe("a\nb\nc\nd\n")
}),
)
it.instance("disambiguates change context with @@ header", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const target = path.join(test.directory, "multi_ctx.txt")
yield* writeText(target, "fn a\nx=10\ny=2\nfn b\nx=10\ny=20\n")
const patchText = "*** Begin Patch\n*** Update File: multi_ctx.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch"
yield* execute({ patchText }, ctx)
expect(yield* readText(target)).toBe("fn a\nx=10\ny=2\nfn b\nx=11\ny=20\n")
}),
)
it.instance("EOF anchor matches from end of file first", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const target = path.join(test.directory, "eof_anchor.txt")
// File has duplicate "marker" lines - one in middle, one at end
yield* writeText(target, "start\nmarker\nmiddle\nmarker\nend\n")
// With EOF anchor, should match the LAST "marker" line, not the first
const patchText =
"*** Begin Patch\n*** Update File: eof_anchor.txt\n@@\n-marker\n-end\n+marker-changed\n+end\n*** End of File\n*** End Patch"
yield* execute({ patchText }, ctx)
// First marker unchanged, second marker changed
expect(yield* readText(target)).toBe("start\nmarker\nmiddle\nmarker-changed\nend\n")
}),
)
it.instance("parses heredoc-wrapped patch", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const patchText = `cat <<'EOF'
*** Begin Patch
*** Add File: heredoc_test.txt
+heredoc content
*** End Patch
EOF`
await execute({ patchText }, ctx)
const content = await fs.readFile(path.join(fixture.path, "heredoc_test.txt"), "utf-8")
expect(content).toBe("heredoc content\n")
},
})
})
yield* execute({ patchText }, ctx)
expect(yield* readText(path.join(test.directory, "heredoc_test.txt"))).toBe("heredoc content\n")
}),
)
test("parses heredoc-wrapped patch without cat", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await WithInstance.provide({
directory: fixture.path,
fn: async () => {
const patchText = `<<EOF
it.instance("parses heredoc-wrapped patch without cat", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const patchText = `<<EOF
*** Begin Patch
*** Add File: heredoc_no_cat.txt
+no cat prefix
*** End Patch
EOF`
await execute({ patchText }, ctx)
const content = await fs.readFile(path.join(fixture.path, "heredoc_no_cat.txt"), "utf-8")
expect(content).toBe("no cat prefix\n")
},
})
})
yield* execute({ patchText }, ctx)
expect(yield* readText(path.join(test.directory, "heredoc_no_cat.txt"))).toBe("no cat prefix\n")
}),
)
test("matches with trailing whitespace differences", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
it.instance("matches with trailing whitespace differences", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const target = path.join(test.directory, "trailing_ws.txt")
// File has trailing spaces on some lines
yield* writeText(target, "line1 \nline2\nline3 \n")
await WithInstance.provide({
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "trailing_ws.txt")
// File has trailing spaces on some lines
await fs.writeFile(target, "line1 \nline2\nline3 \n", "utf-8")
// Patch doesn't have trailing spaces - should still match via rstrip pass
const patchText = "*** Begin Patch\n*** Update File: trailing_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
// Patch doesn't have trailing spaces - should still match via rstrip pass
const patchText = "*** Begin Patch\n*** Update File: trailing_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
yield* execute({ patchText }, ctx)
expect(yield* readText(target)).toBe("line1 \nchanged\nline3 \n")
}),
)
await execute({ patchText }, ctx)
expect(await fs.readFile(target, "utf-8")).toBe("line1 \nchanged\nline3 \n")
},
})
})
it.instance("matches with leading whitespace differences", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const target = path.join(test.directory, "leading_ws.txt")
// File has leading spaces
yield* writeText(target, " line1\nline2\n line3\n")
test("matches with leading whitespace differences", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
// Patch without leading spaces - should match via trim pass
const patchText = "*** Begin Patch\n*** Update File: leading_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
await WithInstance.provide({
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "leading_ws.txt")
// File has leading spaces
await fs.writeFile(target, " line1\nline2\n line3\n", "utf-8")
yield* execute({ patchText }, ctx)
expect(yield* readText(target)).toBe(" line1\nchanged\n line3\n")
}),
)
// Patch without leading spaces - should match via trim pass
const patchText = "*** Begin Patch\n*** Update File: leading_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
it.instance("matches with Unicode punctuation differences", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const target = path.join(test.directory, "unicode.txt")
// File has fancy Unicode quotes (U+201C, U+201D) and em-dash (U+2014)
const leftQuote = "\u201C"
const rightQuote = "\u201D"
const emDash = "\u2014"
yield* writeText(target, `He said ${leftQuote}hello${rightQuote}\nsome${emDash}dash\nend\n`)
await execute({ patchText }, ctx)
expect(await fs.readFile(target, "utf-8")).toBe(" line1\nchanged\n line3\n")
},
})
})
// Patch uses ASCII equivalents - should match via normalized pass
// The replacement uses ASCII quotes from the patch (not preserving Unicode)
const patchText =
'*** Begin Patch\n*** Update File: unicode.txt\n@@\n-He said "hello"\n+He said "hi"\n*** End Patch'
test("matches with Unicode punctuation differences", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await WithInstance.provide({
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "unicode.txt")
// File has fancy Unicode quotes (U+201C, U+201D) and em-dash (U+2014)
const leftQuote = "\u201C"
const rightQuote = "\u201D"
const emDash = "\u2014"
await fs.writeFile(target, `He said ${leftQuote}hello${rightQuote}\nsome${emDash}dash\nend\n`, "utf-8")
// Patch uses ASCII equivalents - should match via normalized pass
// The replacement uses ASCII quotes from the patch (not preserving Unicode)
const patchText =
'*** Begin Patch\n*** Update File: unicode.txt\n@@\n-He said "hello"\n+He said "hi"\n*** End Patch'
await execute({ patchText }, ctx)
// Result has ASCII quotes because that's what the patch specifies
expect(await fs.readFile(target, "utf-8")).toBe(`He said "hi"\nsome${emDash}dash\nend\n`)
},
})
})
yield* execute({ patchText }, ctx)
// Result has ASCII quotes because that's what the patch specifies
expect(yield* readText(target)).toBe(`He said "hi"\nsome${emDash}dash\nend\n`)
}),
)
})
+43 -67
View File
@@ -1,10 +1,9 @@
import { afterAll, afterEach, describe, test, expect } from "bun:test"
import { afterEach, describe, expect } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { Cause, Deferred, Effect, Exit, Layer, ManagedRuntime } from "effect"
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { EditTool } from "../../src/tool/edit"
import { WithInstance } from "../../src/project/with-instance"
import { disposeAllInstances, TestInstance, tmpdir } from "../fixture/fixture"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { LSP } from "@/lsp/lsp"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Format } from "../../src/format"
@@ -42,20 +41,6 @@ const layer = Layer.mergeAll(
const it = testEffect(layer)
const runtime = ManagedRuntime.make(layer)
afterAll(async () => {
await runtime.dispose()
})
const resolve = () =>
runtime.runPromise(
Effect.gen(function* () {
const info = yield* EditTool
return yield* info.init()
}),
)
const init = Effect.fn("EditToolTest.init")(function* () {
const info = yield* EditTool
return yield* info.init()
@@ -500,58 +485,49 @@ describe("tool.edit", () => {
})
describe("concurrent editing", () => {
test("preserves concurrent edits to different sections of the same file", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "top = 0\nmiddle = keep\nbottom = 0\n", "utf-8")
it.instance("preserves concurrent edits to different sections of the same file", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "file.txt")
yield* put(filepath, "top = 0\nmiddle = keep\nbottom = 0\n")
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const edit = await resolve()
let asks = 0
const firstAsk = Promise.withResolvers<void>()
const delayedCtx = {
...ctx,
ask: () =>
Effect.gen(function* () {
asks++
if (asks !== 1) return
firstAsk.resolve()
yield* Effect.promise(() => Bun.sleep(50))
}),
}
const firstAsk = yield* Deferred.make<void>()
let asks = 0
const delayedCtx = {
...ctx,
ask: () =>
Effect.gen(function* () {
asks++
if (asks !== 1) return
yield* Deferred.succeed(firstAsk, undefined)
yield* Effect.promise(() => Bun.sleep(50))
}),
}
const promise1 = Effect.runPromise(
edit.execute(
{
filePath: filepath,
oldString: "top = 0",
newString: "top = 1",
},
delayedCtx,
),
)
const first = yield* run(
{
filePath: filepath,
oldString: "top = 0",
newString: "top = 1",
},
delayedCtx,
).pipe(Effect.forkScoped)
await firstAsk.promise
yield* Deferred.await(firstAsk)
yield* Effect.all([
Fiber.join(first),
run(
{
filePath: filepath,
oldString: "bottom = 0",
newString: "bottom = 2",
},
delayedCtx,
),
])
const promise2 = Effect.runPromise(
edit.execute(
{
filePath: filepath,
oldString: "bottom = 0",
newString: "bottom = 2",
},
delayedCtx,
),
)
const results = await Promise.allSettled([promise1, promise2])
expect(results[0]?.status).toBe("fulfilled")
expect(results[1]?.status).toBe("fulfilled")
expect(await fs.readFile(filepath, "utf-8")).toBe("top = 1\nmiddle = keep\nbottom = 2\n")
},
})
})
expect(yield* load(filepath)).toBe("top = 1\nmiddle = keep\nbottom = 2\n")
}),
)
})
})
@@ -1,14 +1,16 @@
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import path from "path"
import { Effect } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import type { Tool } from "@/tool/tool"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { assertExternalDirectory } from "../../src/tool/external-directory"
import { assertExternalDirectoryEffect } from "../../src/tool/external-directory"
import { Filesystem } from "@/util/filesystem"
import { tmpdir } from "../fixture/fixture"
import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture"
import type { Permission } from "../../src/permission"
import { SessionID, MessageID } from "../../src/session/schema"
import { testEffect } from "../lib/effect"
const it = testEffect(CrossSpawnSpawner.defaultLayer)
const baseCtx: Omit<Tool.Context, "ask"> = {
sessionID: SessionID.make("ses_test"),
@@ -36,135 +38,120 @@ function makeCtx() {
}
describe("tool.assertExternalDirectory", () => {
test("no-ops for empty target", async () => {
const { requests, ctx } = makeCtx()
it.live("no-ops for empty target", () =>
Effect.gen(function* () {
const { requests, ctx } = makeCtx()
await WithInstance.provide({
directory: "/tmp",
fn: async () => {
await assertExternalDirectory(ctx)
},
})
yield* assertExternalDirectoryEffect(ctx)
expect(requests.length).toBe(0)
})
expect(requests.length).toBe(0)
}),
)
test("no-ops for paths inside Instance.directory", async () => {
const { requests, ctx } = makeCtx()
it.live("no-ops for paths inside Instance.directory", () =>
provideInstance("/tmp/project")(
Effect.gen(function* () {
const { requests, ctx } = makeCtx()
await WithInstance.provide({
directory: "/tmp/project",
fn: async () => {
await assertExternalDirectory(ctx, path.join("/tmp/project", "file.txt"))
},
})
yield* assertExternalDirectoryEffect(ctx, path.join("/tmp/project", "file.txt"))
expect(requests.length).toBe(0)
})
expect(requests.length).toBe(0)
}),
),
)
test("asks with a single canonical glob", async () => {
const { requests, ctx } = makeCtx()
it.live("asks with a single canonical glob", () =>
Effect.gen(function* () {
const { requests, ctx } = makeCtx()
const directory = "/tmp/project"
const target = "/tmp/outside/file.txt"
const expected = glob(path.join(path.dirname(target), "*"))
const directory = "/tmp/project"
const target = "/tmp/outside/file.txt"
const expected = glob(path.join(path.dirname(target), "*"))
await WithInstance.provide({
directory,
fn: async () => {
await assertExternalDirectory(ctx, target)
},
})
yield* provideInstance(directory)(assertExternalDirectoryEffect(ctx, target))
const req = requests.find((r) => r.permission === "external_directory")
expect(req).toBeDefined()
expect(req!.patterns).toEqual([expected])
expect(req!.always).toEqual([expected])
})
const req = requests.find((r) => r.permission === "external_directory")
expect(req).toBeDefined()
expect(req!.patterns).toEqual([expected])
expect(req!.always).toEqual([expected])
}),
)
test("uses target directory when kind=directory", async () => {
const { requests, ctx } = makeCtx()
it.live("uses target directory when kind=directory", () =>
Effect.gen(function* () {
const { requests, ctx } = makeCtx()
const directory = "/tmp/project"
const target = "/tmp/outside"
const expected = glob(path.join(target, "*"))
const directory = "/tmp/project"
const target = "/tmp/outside"
const expected = glob(path.join(target, "*"))
await WithInstance.provide({
directory,
fn: async () => {
await assertExternalDirectory(ctx, target, { kind: "directory" })
},
})
yield* provideInstance(directory)(assertExternalDirectoryEffect(ctx, target, { kind: "directory" }))
const req = requests.find((r) => r.permission === "external_directory")
expect(req).toBeDefined()
expect(req!.patterns).toEqual([expected])
expect(req!.always).toEqual([expected])
})
const req = requests.find((r) => r.permission === "external_directory")
expect(req).toBeDefined()
expect(req!.patterns).toEqual([expected])
expect(req!.always).toEqual([expected])
}),
)
test("skips prompting when bypass=true", async () => {
const { requests, ctx } = makeCtx()
it.live("skips prompting when bypass=true", () =>
provideInstance("/tmp/project")(
Effect.gen(function* () {
const { requests, ctx } = makeCtx()
await WithInstance.provide({
directory: "/tmp/project",
fn: async () => {
await assertExternalDirectory(ctx, "/tmp/outside/file.txt", { bypass: true })
},
})
yield* assertExternalDirectoryEffect(ctx, "/tmp/outside/file.txt", { bypass: true })
expect(requests.length).toBe(0)
})
expect(requests.length).toBe(0)
}),
),
)
if (process.platform === "win32") {
test("normalizes Windows path variants to one glob", async () => {
const { requests, ctx } = makeCtx()
it.instance(
"normalizes Windows path variants to one glob",
() =>
Effect.gen(function* () {
const { requests, ctx } = makeCtx()
await using outerTmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "outside.txt"), "x")
},
})
await using tmp = await tmpdir({ git: true })
const outerTmp = yield* tmpdirScoped()
yield* Effect.promise(() => Bun.write(path.join(outerTmp, "outside.txt"), "x"))
const target = path.join(outerTmp.path, "outside.txt")
const alt = target
.replace(/^[A-Za-z]:/, "")
.replaceAll("\\", "/")
.toLowerCase()
const target = path.join(outerTmp, "outside.txt")
const alt = target
.replace(/^[A-Za-z]:/, "")
.replaceAll("\\", "/")
.toLowerCase()
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await assertExternalDirectory(ctx, alt)
},
})
yield* assertExternalDirectoryEffect(ctx, alt)
const req = requests.find((r) => r.permission === "external_directory")
const expected = glob(path.join(outerTmp.path, "*"))
expect(req).toBeDefined()
expect(req!.patterns).toEqual([expected])
expect(req!.always).toEqual([expected])
})
const req = requests.find((r) => r.permission === "external_directory")
const expected = glob(path.join(outerTmp, "*"))
expect(req).toBeDefined()
expect(req!.patterns).toEqual([expected])
expect(req!.always).toEqual([expected])
}),
{ git: true },
)
test("uses drive root glob for root files", async () => {
const { requests, ctx } = makeCtx()
it.instance(
"uses drive root glob for root files",
() =>
Effect.gen(function* () {
const { requests, ctx } = makeCtx()
await using tmp = await tmpdir({ git: true })
const root = path.parse(tmp.path).root
const target = path.join(root, "boot.ini")
const tmp = yield* TestInstance
const root = path.parse(tmp.directory).root
const target = path.join(root, "boot.ini")
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await assertExternalDirectory(ctx, target)
},
})
yield* assertExternalDirectoryEffect(ctx, target)
const req = requests.find((r) => r.permission === "external_directory")
const expected = path.join(root, "*")
expect(req).toBeDefined()
expect(req!.patterns).toEqual([expected])
expect(req!.always).toEqual([expected])
})
const req = requests.find((r) => r.permission === "external_directory")
const expected = path.join(root, "*")
expect(req).toBeDefined()
expect(req!.patterns).toEqual([expected])
expect(req!.always).toEqual([expected])
}),
{ git: true },
)
}
})
File diff suppressed because it is too large Load Diff
+60 -55
View File
@@ -1,11 +1,12 @@
import { describe, test, expect } from "bun:test"
import { Effect, Layer, ManagedRuntime, Schema } from "effect"
import { describe, expect } from "bun:test"
import { Effect, Layer, Schema } from "effect"
import { Agent } from "../../src/agent/agent"
import { MessageID, SessionID } from "../../src/session/schema"
import { Tool } from "@/tool/tool"
import { Truncate } from "@/tool/truncate"
import { testEffect } from "../lib/effect"
const runtime = ManagedRuntime.make(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer))
const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer))
const params = Schema.Struct({ input: Schema.String })
@@ -21,49 +22,53 @@ function makeTool(id: string, executeFn?: () => void) {
}
describe("Tool.define", () => {
test("object-defined tool does not mutate the original init object", async () => {
const original = makeTool("test")
const originalExecute = original.execute
it.effect("object-defined tool does not mutate the original init object", () =>
Effect.gen(function* () {
const original = makeTool("test")
const originalExecute = original.execute
const info = await runtime.runPromise(Tool.define("test-tool", Effect.succeed(original)))
const info = yield* Tool.define("test-tool", Effect.succeed(original))
await Effect.runPromise(info.init())
await Effect.runPromise(info.init())
await Effect.runPromise(info.init())
yield* info.init()
yield* info.init()
yield* info.init()
expect(original.execute).toBe(originalExecute)
})
expect(original.execute).toBe(originalExecute)
}),
)
test("effect-defined tool returns fresh objects and is unaffected", async () => {
const info = await runtime.runPromise(
Tool.define(
it.effect("effect-defined tool returns fresh objects and is unaffected", () =>
Effect.gen(function* () {
const info = yield* Tool.define(
"test-fn-tool",
Effect.succeed(() => Effect.succeed(makeTool("test"))),
),
)
)
const first = await Effect.runPromise(info.init())
const second = await Effect.runPromise(info.init())
const first = yield* info.init()
const second = yield* info.init()
expect(first).not.toBe(second)
})
expect(first).not.toBe(second)
}),
)
test("object-defined tool returns distinct objects per init() call", async () => {
const info = await runtime.runPromise(Tool.define("test-copy", Effect.succeed(makeTool("test"))))
it.effect("object-defined tool returns distinct objects per init() call", () =>
Effect.gen(function* () {
const info = yield* Tool.define("test-copy", Effect.succeed(makeTool("test")))
const first = await Effect.runPromise(info.init())
const second = await Effect.runPromise(info.init())
const first = yield* info.init()
const second = yield* info.init()
expect(first).not.toBe(second)
})
expect(first).not.toBe(second)
}),
)
test("execute receives decoded parameters", async () => {
const parameters = Schema.Struct({
count: Schema.NumberFromString.pipe(Schema.optional, Schema.withDecodingDefaultType(Effect.succeed(5))),
})
const calls: Array<Schema.Schema.Type<typeof parameters>> = []
const info = await runtime.runPromise(
Tool.define(
it.effect("execute receives decoded parameters", () =>
Effect.gen(function* () {
const parameters = Schema.Struct({
count: Schema.NumberFromString.pipe(Schema.optional, Schema.withDecodingDefaultType(Effect.succeed(5))),
})
const calls: Array<Schema.Schema.Type<typeof parameters>> = []
const info = yield* Tool.define(
"test-decoded",
Effect.succeed({
description: "test tool",
@@ -73,27 +78,27 @@ describe("Tool.define", () => {
return Effect.succeed({ title: "test", output: "ok", metadata: { truncated: false } })
},
}),
),
)
const ctx: Tool.Context = {
sessionID: SessionID.descending(),
messageID: MessageID.ascending(),
agent: "build",
abort: new AbortController().signal,
messages: [],
metadata() {
return Effect.void
},
ask() {
return Effect.void
},
}
const tool = await Effect.runPromise(info.init())
const execute = tool.execute as unknown as (args: unknown, ctx: Tool.Context) => ReturnType<typeof tool.execute>
)
const ctx: Tool.Context = {
sessionID: SessionID.descending(),
messageID: MessageID.ascending(),
agent: "build",
abort: new AbortController().signal,
messages: [],
metadata() {
return Effect.void
},
ask() {
return Effect.void
},
}
const tool = yield* info.init()
const execute = tool.execute as unknown as (args: unknown, ctx: Tool.Context) => ReturnType<typeof tool.execute>
await Effect.runPromise(execute({}, ctx))
await Effect.runPromise(execute({ count: "7" }, ctx))
yield* execute({}, ctx)
yield* execute({ count: "7" }, ctx)
expect(calls).toEqual([{ count: 5 }, { count: 7 }])
})
expect(calls).toEqual([{ count: 5 }, { count: 7 }])
}),
)
})
+6 -11
View File
@@ -1,9 +1,7 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { NamedError } from "@opencode-ai/core/util/error"
import { errorData, errorFormat, errorMessage } from "../../src/util/error"
import { namedSchemaError } from "../../src/util/named-schema-error"
import { UI } from "../../src/cli/ui"
import { MessageError } from "../../src/session/message-error"
describe("util.error", () => {
test("formats native Error instances", () => {
@@ -53,17 +51,14 @@ describe("util.error", () => {
expect(String(data.formatted)).toContain("ResolveMessage")
})
test("named schema errors are real NamedError instances", () => {
const ExampleError = namedSchemaError("ExampleError", { message: Schema.String })
const error = new ExampleError({ message: "boom" })
test("schema-backed named errors are real NamedError instances", () => {
const error = new MessageError.AuthError({ providerID: "anthropic", message: "boom" })
expect(error).toBeInstanceOf(NamedError)
expect(error.toObject()).toEqual({ name: "ExampleError", data: { message: "boom" } })
expect(error.toObject()).toEqual({ name: "ProviderAuthError", data: { providerID: "anthropic", message: "boom" } })
})
test("void named errors accept JSON without data", () => {
const serialized = JSON.parse(JSON.stringify(new UI.CancelledError(undefined).toObject()))
expect(Schema.decodeUnknownOption(UI.CancelledError.Schema)(serialized)._tag).toBe("Some")
test("named errors without fields serialize data", () => {
expect(new MessageError.OutputLengthError({}).toObject()).toEqual({ name: "MessageOutputLengthError", data: {} })
})
})
@@ -40,3 +40,16 @@ export function useGlobalSync() {
},
}
}
export function useQueryOptions() {
return {
agents: (directory: string) => ({
queryKey: [directory, "agents"],
queryFn: async () => [],
}),
providers: (directory: string | null) => ({
queryKey: [directory, "providers"],
queryFn: async () => provider,
}),
}
}