Compare commits

..

12 Commits

Author SHA1 Message Date
Kit Langton a2b0ef9823 feat(session): execute tools in native LLM runtime 2026-05-12 09:25:05 -04:00
Kit Langton a6e8ec4b35 fix(session): rely on native LLM lifecycle events 2026-05-12 09:25:05 -04:00
Kit Langton 4c75b463e7 fix(session): finalize native text before request finish 2026-05-12 09:25:05 -04:00
Kit Langton 03b60f7623 fix(session): finish native request streams 2026-05-12 09:25:05 -04:00
Kit Langton a878036f63 feat(session): add native OpenAI runtime opt-in 2026-05-12 09:25:05 -04:00
Kit Langton 9bbb688459 test(session): compile native LLM requests 2026-05-12 09:25:02 -04:00
Kit Langton a8a58fd61e fix(session): use current native LLM constructors 2026-05-12 09:24:56 -04:00
Kit Langton 3e8fc071ed fix(session): target native OpenRouter route 2026-05-12 09:24:55 -04:00
Kit Langton d79e8ba701 refactor(session): add native LLM request adapter 2026-05-12 09:24:55 -04:00
Kit Langton 4481d46071 refactor(session): simplify LLM event adapter 2026-05-12 09:24:52 -04:00
Kit Langton 648c5cd1b6 fix(session): align OpenAI response stream fixtures 2026-05-12 09:24:52 -04:00
Kit Langton 83d07b7e16 refactor(session): consume native LLM events 2026-05-12 09:24:52 -04:00
87 changed files with 5789 additions and 4892 deletions
+3
View File
@@ -65,6 +65,7 @@
"solid-list": "catalog:",
"tailwindcss": "catalog:",
"virtua": "catalog:",
"zod": "catalog:",
},
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
@@ -213,6 +214,7 @@
"npm-package-arg": "13.0.2",
"semver": "^7.6.3",
"xdg-basedir": "5.1.0",
"zod": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
@@ -394,6 +396,7 @@
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
+1 -2
View File
@@ -1,7 +1,6 @@
[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-MUHog06sZEi6bXR1m8exdkjSNW9bHEv9bPQXACJ7SFw=",
"aarch64-linux": "sha256-3dwdZ3It++OsdGT8xMOQ10Arz8eeODp/LXOrI4DLEhY=",
"aarch64-darwin": "sha256-TmUPGDCewjsrT13npVH6B55J43NKKut67p/HgPJpQNM=",
"x86_64-darwin": "sha256-j8I7t3MZoUQUMFRWyaFO75TRbAw5TauSZAa4yKOHFMA="
"x86_64-linux": "sha256-Q9r1S15YL9LQK7DRhuOpw3Fxi24BPovEM995GZJayKw=",
"aarch64-linux": "sha256-C0rRTLnxxuuEkCBc3JZbkR66TUVwpcPFif3BU9GRAuA=",
"aarch64-darwin": "sha256-1HvalOO/pOkRlYH8CZ93psapt90C+pYzui1JCadBE1Q=",
"x86_64-darwin": "sha256-RrndyLWfhWm4mZ88XytFF2NI+ly8la550Z5LBN/g5u4="
}
}
+2 -1
View File
@@ -73,6 +73,7 @@
"solid-js": "catalog:",
"solid-list": "catalog:",
"tailwindcss": "catalog:",
"virtua": "catalog:"
"virtua": "catalog:",
"zod": "catalog:"
}
}
+5 -3
View File
@@ -3,13 +3,15 @@ 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 isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
const abortError = z.object({
name: z.literal("AbortError"),
})
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
name: "GlobalSDK",
@@ -101,7 +103,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
let streamErrorLogged = false
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
const aborted = isAbortError
const aborted = (error: unknown) => abortError.safeParse(error).success
let attempt: AbortController | undefined
let run: Promise<void> | undefined
+6
View File
@@ -1,3 +1,5 @@
import z from "zod"
const prefixes = {
session: "ses",
message: "msg",
@@ -13,6 +15,10 @@ 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)
}
@@ -299,6 +299,7 @@ export async function handler(
let buffer = ""
let responseLength = 0
let timestampFirstByte = 0
let timestampLastByte = 0
function pump(): Promise<void> {
return (
+2 -1
View File
@@ -41,7 +41,8 @@
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"semver": "^7.6.3",
"xdg-basedir": "5.1.0"
"xdg-basedir": "5.1.0",
"zod": "catalog:"
},
"overrides": {
"drizzle-orm": "catalog:"
+11
View File
@@ -0,0 +1,11 @@
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
}
+3 -6
View File
@@ -4,14 +4,11 @@ import path from "path"
import fs from "fs/promises"
import { createWriteStream } from "fs"
import * as Global from "../global"
import { Schema } from "effect"
import z from "zod"
import { Glob } from "./glob"
export const Level = Schema.Literals(["DEBUG", "INFO", "WARN", "ERROR"]).annotate({
identifier: "LogLevel",
description: "Log level",
})
export type Level = Schema.Schema.Type<typeof Level>
export const Level = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).meta({ ref: "LogLevel", description: "Log level" })
export type Level = z.infer<typeof Level>
const levelPriority: Record<Level, number> = {
DEBUG: 0,
+1 -4
View File
@@ -1,12 +1,9 @@
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,7 +78,7 @@ const streamText = LLM.stream(request).pipe(
Stream.tap((event) =>
Effect.sync(() => {
if (event.type === "text-delta") process.stdout.write(`\ntext: ${event.text}`)
if (event.type === "finish") process.stdout.write(`\nfinish: ${event.reason}\n`)
if (event.type === "request-finish") process.stdout.write(`\nfinish: ${event.reason}\n`)
}),
),
Stream.runDrain,
@@ -185,7 +185,7 @@ const FakeProtocol = Protocol.make<FakeBody, string, string, void>({
event: Schema.String,
initial: () => undefined,
step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", id: "text-0", text: frame }]] as const),
onHalt: () => [{ type: "finish", reason: "stop" }],
onHalt: () => [{ type: "request-finish", reason: "stop" }],
},
})
-1
View File
@@ -17,7 +17,6 @@ export type {
ExecutableTools,
Tool as ToolShape,
ToolExecute,
ToolExecuteContext,
Tools,
ToolSchema,
} from "./tool"
@@ -380,7 +380,7 @@ type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
const NO_EVENTS: StepResult["1"] = []
// `response.completed` / `response.incomplete` are clean finishes that emit a
// `finish` event; `response.failed` is a hard failure that emits a
// `request-finish` event; `response.failed` is a hard failure that emits a
// `provider-error`. All three end the stream — kept in one set so `step` and
// the protocol's `terminal` predicate stay in sync.
const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"])
@@ -80,7 +80,7 @@ export const finish = (
usage: input.usage,
providerMetadata: input.providerMetadata,
}),
LLMEvent.finish(input),
LLMEvent.requestFinish(input),
)
return { ...stepped, stepStarted: false }
}
+22 -17
View File
@@ -1,5 +1,5 @@
import { Schema } from "effect"
import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids"
import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, ResponseID, RouteID, ToolCallID } from "./ids"
import { ModelRef } from "./options"
import { ToolResultValue } from "./messages"
@@ -66,13 +66,14 @@ export class Usage extends Schema.Class<Usage>("LLM.Usage")({
get visibleOutputTokens() {
return Math.max(0, (this.outputTokens ?? 0) - (this.reasoningTokens ?? 0))
}
static from(input: UsageInput) {
return input instanceof Usage ? input : new Usage(input)
}
}
export type UsageInput = Usage | ConstructorParameters<typeof Usage>[0]
export const RequestStart = Schema.Struct({
type: Schema.tag("request-start"),
id: ResponseID,
model: ModelRef,
}).annotate({ identifier: "LLM.Event.RequestStart" })
export type RequestStart = Schema.Schema.Type<typeof RequestStart>
export const StepStart = Schema.Struct({
type: Schema.tag("step-start"),
@@ -184,13 +185,13 @@ export const StepFinish = Schema.Struct({
}).annotate({ identifier: "LLM.Event.StepFinish" })
export type StepFinish = Schema.Schema.Type<typeof StepFinish>
export const Finish = Schema.Struct({
type: Schema.tag("finish"),
export const RequestFinish = Schema.Struct({
type: Schema.tag("request-finish"),
reason: FinishReason,
usage: Schema.optional(Usage),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.Finish" })
export type Finish = Schema.Schema.Type<typeof Finish>
}).annotate({ identifier: "LLM.Event.RequestFinish" })
export type RequestFinish = Schema.Schema.Type<typeof RequestFinish>
export const ProviderErrorEvent = Schema.Struct({
type: Schema.tag("provider-error"),
@@ -201,6 +202,7 @@ export const ProviderErrorEvent = Schema.Struct({
export type ProviderErrorEvent = Schema.Schema.Type<typeof ProviderErrorEvent>
const llmEventTagged = Schema.Union([
RequestStart,
StepStart,
TextStart,
TextDelta,
@@ -215,15 +217,16 @@ const llmEventTagged = Schema.Union([
ToolResult,
ToolError,
StepFinish,
Finish,
RequestFinish,
ProviderErrorEvent,
]).pipe(Schema.toTaggedUnion("type"))
type WithID<Event extends { readonly id: unknown }, ID> = Omit<Event, "type" | "id"> & { readonly id: ID | string }
type WithUsage<Event extends { readonly usage?: Usage }> = Omit<Event, "type" | "usage"> & {
readonly usage?: UsageInput
readonly usage?: Usage | ConstructorParameters<typeof Usage>[0]
}
const responseID = (value: ResponseID | string) => ResponseID.make(value)
const contentBlockID = (value: ContentBlockID | string) => ContentBlockID.make(value)
const toolCallID = (value: ToolCallID | string) => ToolCallID.make(value)
@@ -233,6 +236,7 @@ const toolCallID = (value: ToolCallID | string) => ToolCallID.make(value)
* `events.filter(LLMEvent.guards["tool-call"])`.
*/
export const LLMEvent = Object.assign(llmEventTagged, {
requestStart: (input: WithID<RequestStart, ResponseID>) => RequestStart.make({ ...input, id: responseID(input.id) }),
stepStart: StepStart.make,
textStart: (input: WithID<TextStart, ContentBlockID>) => TextStart.make({ ...input, id: contentBlockID(input.id) }),
textDelta: (input: WithID<TextDelta, ContentBlockID>) => TextDelta.make({ ...input, id: contentBlockID(input.id) }),
@@ -254,15 +258,16 @@ export const LLMEvent = Object.assign(llmEventTagged, {
stepFinish: (input: WithUsage<StepFinish>) =>
StepFinish.make({
...input,
usage: input.usage === undefined ? undefined : Usage.from(input.usage),
usage: input.usage === undefined ? undefined : input.usage instanceof Usage ? input.usage : new Usage(input.usage),
}),
finish: (input: WithUsage<Finish>) =>
Finish.make({
requestFinish: (input: WithUsage<RequestFinish>) =>
RequestFinish.make({
...input,
usage: input.usage === undefined ? undefined : Usage.from(input.usage),
usage: input.usage === undefined ? undefined : input.usage instanceof Usage ? input.usage : new Usage(input.usage),
}),
providerError: ProviderErrorEvent.make,
is: {
requestStart: llmEventTagged.guards["request-start"],
stepStart: llmEventTagged.guards["step-start"],
textStart: llmEventTagged.guards["text-start"],
textDelta: llmEventTagged.guards["text-delta"],
@@ -277,7 +282,7 @@ export const LLMEvent = Object.assign(llmEventTagged, {
toolResult: llmEventTagged.guards["tool-result"],
toolError: llmEventTagged.guards["tool-error"],
stepFinish: llmEventTagged.guards["step-finish"],
finish: llmEventTagged.guards.finish,
requestFinish: llmEventTagged.guards["request-finish"],
providerError: llmEventTagged.guards["provider-error"],
},
})
+9 -79
View File
@@ -12,7 +12,6 @@ import {
ToolFailure,
ToolResultPart,
type ToolResultValue,
Usage,
} from "./schema"
import { type AnyTool, type ExecutableTools, type Tools, toDefinitions } from "./tool"
@@ -73,42 +72,19 @@ export const stream = <T extends Tools>(options: StreamOptions<T>): Stream.Strea
tools: [...options.request.tools.filter((tool) => !runtimeToolNames.has(tool.name)), ...runtimeTools],
})
const loop = (
request: LLMRequest,
step: number,
usage: Usage | undefined,
providerMetadata: ProviderMetadata | undefined,
): Stream.Stream<LLMEvent, LLMError> =>
const loop = (request: LLMRequest, step: number): Stream.Stream<LLMEvent, LLMError> =>
Stream.unwrap(
Effect.gen(function* () {
const state: StepState = {
assistantContent: [],
toolCalls: [],
finishReason: undefined,
usage: undefined,
providerMetadata: undefined,
}
const state: StepState = { assistantContent: [], toolCalls: [], finishReason: undefined }
const modelStream = options
.stream(request)
.pipe(Stream.map((event) => indexStep(event, step)))
.pipe(Stream.tap((event) => Effect.sync(() => accumulate(state, event))))
.pipe(Stream.filter((event) => event.type !== "finish"))
const continuation = Stream.unwrap(
Effect.gen(function* () {
const totalUsage = addUsage(usage, state.usage)
const totalProviderMetadata = mergeProviderMetadata(providerMetadata, state.providerMetadata)
const finishStream = Stream.fromIterable([
LLMEvent.finish({
reason: state.finishReason ?? "unknown",
usage: totalUsage,
providerMetadata: totalProviderMetadata,
}),
])
if (state.finishReason !== "tool-calls" || state.toolCalls.length === 0) return finishStream
if (options.toolExecution === "none") return finishStream
if (state.finishReason !== "tool-calls" || state.toolCalls.length === 0) return Stream.empty
if (options.toolExecution === "none") return Stream.empty
const dispatched = yield* Effect.forEach(
state.toolCalls,
@@ -117,14 +93,10 @@ export const stream = <T extends Tools>(options: StreamOptions<T>): Stream.Strea
)
const resultStream = Stream.fromIterable(dispatched.flatMap(([call, result]) => emitEvents(call, result)))
if (!options.stopWhen) return resultStream.pipe(Stream.concat(finishStream))
if (options.stopWhen({ step, request })) return resultStream.pipe(Stream.concat(finishStream))
if (!options.stopWhen) return resultStream
if (options.stopWhen({ step, request })) return resultStream
return resultStream.pipe(
Stream.concat(
loop(followUpRequest(request, state, dispatched), step + 1, totalUsage, totalProviderMetadata),
),
)
return resultStream.pipe(Stream.concat(loop(followUpRequest(request, state, dispatched), step + 1)))
}),
)
@@ -132,21 +104,13 @@ export const stream = <T extends Tools>(options: StreamOptions<T>): Stream.Strea
}),
)
return loop(initialRequest, 0, undefined, undefined)
}
const indexStep = (event: LLMEvent, index: number): LLMEvent => {
if (event.type === "step-start") return LLMEvent.stepStart({ index })
if (event.type === "step-finish") return LLMEvent.stepFinish({ ...event, index })
return event
return loop(initialRequest, 0)
}
interface StepState {
assistantContent: ContentPart[]
toolCalls: ToolCallPart[]
finishReason: FinishReason | undefined
usage: Usage | undefined
providerMetadata: ProviderMetadata | undefined
}
const accumulate = (state: StepState, event: LLMEvent) => {
@@ -190,43 +154,9 @@ const accumulate = (state: StepState, event: LLMEvent) => {
)
return
}
if (event.type === "step-finish") {
if (event.type === "step-finish" || event.type === "request-finish") {
state.finishReason = event.reason === "stop" && state.toolCalls.length > 0 ? "tool-calls" : event.reason
state.usage = addUsage(state.usage, event.usage)
state.providerMetadata = mergeProviderMetadata(state.providerMetadata, event.providerMetadata)
return
}
if (event.type === "finish") {
state.finishReason ??= event.reason
state.usage ??= event.usage
state.providerMetadata = mergeProviderMetadata(state.providerMetadata, event.providerMetadata)
}
}
const addUsage = (left: Usage | undefined, right: Usage | undefined) => {
if (!left) return right
if (!right) return left
type UsageKey =
| "inputTokens"
| "outputTokens"
| "nonCachedInputTokens"
| "cacheReadInputTokens"
| "cacheWriteInputTokens"
| "reasoningTokens"
| "totalTokens"
const sum = (key: UsageKey) =>
left[key] === undefined && right[key] === undefined ? undefined : Number(left[key] ?? 0) + Number(right[key] ?? 0)
return new Usage({
inputTokens: sum("inputTokens"),
outputTokens: sum("outputTokens"),
nonCachedInputTokens: sum("nonCachedInputTokens"),
cacheReadInputTokens: sum("cacheReadInputTokens"),
cacheWriteInputTokens: sum("cacheWriteInputTokens"),
reasoningTokens: sum("reasoningTokens"),
totalTokens: sum("totalTokens"),
providerMetadata: mergeProviderMetadata(left.providerMetadata, right.providerMetadata),
})
}
const sameProviderMetadata = (left: ProviderMetadata | undefined, right: ProviderMetadata | undefined) =>
+10 -8
View File
@@ -1,5 +1,5 @@
import { Effect, JsonSchema, Schema } from "effect"
import type { ToolCallPart, ToolDefinition as ToolDefinitionClass } from "./schema"
import type { ToolDefinition as ToolDefinitionClass } from "./schema"
import { ToolDefinition, ToolFailure } from "./schema"
/**
@@ -8,14 +8,10 @@ import { ToolDefinition, ToolFailure } from "./schema"
* beyond pure data conversion belongs in the handler closure.
*/
export type ToolSchema<T> = Schema.Codec<T, any, never, never>
export interface ToolExecuteContext {
readonly id: ToolCallPart["id"]
readonly name: ToolCallPart["name"]
}
export type ToolExecute<Parameters extends ToolSchema<any>, Success extends ToolSchema<any>> = (
params: Schema.Schema.Type<Parameters>,
context?: ToolExecuteContext,
context?: { readonly id: string; readonly name: string },
) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
/**
@@ -66,7 +62,10 @@ type TypedToolConfig = {
type DynamicToolConfig = {
readonly description: string
readonly jsonSchema: JsonSchema.JsonSchema
readonly execute?: (params: unknown, context?: ToolExecuteContext) => Effect.Effect<unknown, ToolFailure>
readonly execute?: (
params: unknown,
context?: { readonly id: string; readonly name: string },
) => Effect.Effect<unknown, ToolFailure>
}
/**
@@ -115,7 +114,10 @@ export function make<Parameters extends ToolSchema<any>, Success extends ToolSch
export function make(config: {
readonly description: string
readonly jsonSchema: JsonSchema.JsonSchema
readonly execute: (params: unknown, context?: ToolExecuteContext) => Effect.Effect<unknown, ToolFailure>
readonly execute: (
params: unknown,
context?: { readonly id: string; readonly name: string },
) => Effect.Effect<unknown, ToolFailure>
}): AnyExecutableTool
export function make(config: {
readonly description: string
+3 -3
View File
@@ -51,7 +51,7 @@ const request = LLM.request({
const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent =>
event.type === "finish"
? { type: "finish", reason: event.reason }
? { type: "request-finish", reason: event.reason }
: { type: "text-delta", id: "text-0", text: event.text }
const fakeProtocol = Protocol.make<FakeBody, FakeEvent, FakeEvent, void>({
@@ -112,8 +112,8 @@ describe("llm route", () => {
const events = Array.from(yield* llm.stream(request).pipe(Stream.runCollect))
const response = yield* llm.generate(request)
expect(events.map((event) => event.type)).toEqual(["text-delta", "finish"])
expect(response.events.map((event) => event.type)).toEqual(["text-delta", "finish"])
expect(events.map((event) => event.type)).toEqual(["text-delta", "request-finish"])
expect(response.events.map((event) => event.type)).toEqual(["text-delta", "request-finish"])
}),
)
+1 -1
View File
@@ -127,7 +127,7 @@ describe("llm constructors", () => {
LLMResponse.text({
events: [
{ type: "text-delta", id: "text-0", text: "hi" },
{ type: "finish", reason: "stop" },
{ type: "request-finish", reason: "stop" },
],
}),
).toBe("hi")
@@ -124,7 +124,7 @@ describe("Anthropic Messages route", () => {
providerMetadata: { anthropic: { signature: "sig_1" } },
})
expect(response.events.at(-1)).toMatchObject({
type: "finish",
type: "request-finish",
reason: "stop",
providerMetadata: { anthropic: { stopSequence: "\n\nHuman:" } },
})
@@ -182,7 +182,7 @@ describe("Anthropic Messages route", () => {
},
{ type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
{
type: "finish",
type: "request-finish",
reason: "tool-calls",
providerMetadata: undefined,
usage,
@@ -275,7 +275,7 @@ describe("Anthropic Messages route", () => {
providerMetadata: { anthropic: { blockType: "web_search_tool_result" } },
})
expect(response.text).toBe("Found it.")
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" })
expect(response.events.at(-1)).toMatchObject({ type: "request-finish", reason: "stop" })
}),
)
@@ -169,12 +169,12 @@ describe("Bedrock Converse route", () => {
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.text).toBe("Hello!")
const finishes = response.events.filter((event) => event.type === "finish")
const finishes = response.events.filter((event) => event.type === "request-finish")
// Bedrock splits the finish across `messageStop` (carries reason) and
// `metadata` (carries usage). We consolidate them into a single
// terminal `finish` event with both.
// terminal `request-finish` event with both.
expect(finishes).toHaveLength(1)
expect(finishes[0]).toMatchObject({ type: "finish", reason: "stop" })
expect(finishes[0]).toMatchObject({ type: "request-finish", reason: "stop" })
expect(response.usage).toMatchObject({
inputTokens: 5,
outputTokens: 2,
@@ -213,7 +213,7 @@ describe("Bedrock Converse route", () => {
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"' },
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: ':"weather"}' },
])
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" })
expect(response.events.at(-1)).toMatchObject({ type: "request-finish", reason: "tool-calls" })
}),
)
+7 -7
View File
@@ -232,7 +232,7 @@ describe("Gemini route", () => {
{ type: "text-end", id: "text-0" },
{ type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined },
{
type: "finish",
type: "request-finish",
reason: "stop",
usage,
},
@@ -291,7 +291,7 @@ describe("Gemini route", () => {
},
{ type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
{
type: "finish",
type: "request-finish",
reason: "tool-calls",
usage,
},
@@ -325,7 +325,7 @@ describe("Gemini route", () => {
{ type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } },
{ type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } },
])
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" })
expect(response.events.at(-1)).toMatchObject({ type: "request-finish", reason: "tool-calls" })
}),
)
@@ -344,10 +344,10 @@ describe("Gemini route", () => {
),
)
expect(length.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
expect(length.events.at(-1)).toMatchObject({ type: "finish", reason: "length" })
expect(filtered.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
expect(filtered.events.at(-1)).toMatchObject({ type: "finish", reason: "content-filter" })
expect(length.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "request-finish"])
expect(length.events.at(-1)).toMatchObject({ type: "request-finish", reason: "length" })
expect(filtered.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "request-finish"])
expect(filtered.events.at(-1)).toMatchObject({ type: "request-finish", reason: "content-filter" })
}),
)
@@ -249,7 +249,7 @@ describe("OpenAI Chat route", () => {
{ type: "text-end", id: "text-0" },
{ type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined },
{
type: "finish",
type: "request-finish",
reason: "stop",
usage,
},
@@ -288,7 +288,7 @@ describe("OpenAI Chat route", () => {
providerMetadata: undefined,
},
{ type: "step-finish", index: 0, reason: "tool-calls", usage: undefined, providerMetadata: undefined },
{ type: "finish", reason: "tool-calls", usage: undefined },
{ type: "request-finish", reason: "tool-calls", usage: undefined },
])
}),
)
@@ -231,7 +231,7 @@ describe("OpenAI-compatible Chat route", () => {
expect(response.text).toBe("Hello!")
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" })
expect(response.events.at(-1)).toMatchObject({ type: "request-finish", reason: "stop" })
}),
)
})
@@ -366,7 +366,7 @@ describe("OpenAI Responses route", () => {
usage,
},
{
type: "finish",
type: "request-finish",
reason: "stop",
providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } },
usage,
@@ -447,7 +447,7 @@ describe("OpenAI Responses route", () => {
},
{ type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
{
type: "finish",
type: "request-finish",
reason: "tool-calls",
providerMetadata: undefined,
usage,
+7 -9
View File
@@ -120,8 +120,8 @@ export const runWeatherToolLoop = (request: LLMRequest) =>
export const expectFinish = (
events: ReadonlyArray<LLMEvent>,
reason: Extract<LLMEvent, { readonly type: "finish" }>["reason"],
) => expect(events.at(-1)).toMatchObject({ type: "finish", reason })
reason: Extract<LLMEvent, { readonly type: "request-finish" }>["reason"],
) => expect(events.at(-1)).toMatchObject({ type: "request-finish", reason })
export const expectWeatherToolCall = (response: LLMResponse) =>
expect(response.toolCalls).toMatchObject([
@@ -129,12 +129,10 @@ export const expectWeatherToolCall = (response: LLMResponse) =>
])
export const expectWeatherToolLoop = (events: ReadonlyArray<LLMEvent>) => {
const finishes = events.filter(LLMEvent.is.finish)
expect(finishes).toHaveLength(1)
expect(finishes[0]?.reason).toBe("stop")
const stepFinishes = events.filter(LLMEvent.is.stepFinish)
expect(stepFinishes.map((event) => event.reason)).toEqual(["tool-calls", "stop"])
const finishes = events.filter(LLMEvent.is.requestFinish)
expect(finishes).toHaveLength(2)
expect(finishes[0]?.reason).toBe("tool-calls")
expect(finishes.at(-1)?.reason).toBe("stop")
const toolCalls = events.filter(LLMEvent.is.toolCall)
expect(toolCalls).toHaveLength(1)
@@ -274,7 +272,7 @@ export const eventSummary = (events: ReadonlyArray<LLMEvent>) => {
summary.push({ type: "tool-error", name: event.name, message: event.message })
continue
}
if (event.type === "finish") {
if (event.type === "request-finish") {
summary.push({ type: "finish", reason: event.reason, usage: usageSummary(event.usage) })
}
}
-5
View File
@@ -44,11 +44,6 @@ describe("llm schema", () => {
expect(() => Schema.decodeUnknownSync(LLMEvent)({ type: "bogus" })).toThrow()
})
test("finish constructors accept usage input", () => {
expect(LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 1 } }).usage).toBeInstanceOf(Usage)
expect(LLMEvent.finish({ reason: "stop", usage: { outputTokens: 2 } }).usage).toBeInstanceOf(Usage)
})
test("content part tagged union exposes guards", () => {
expect(ContentPart.guards.text({ type: "text", text: "hi" })).toBe(true)
expect(ContentPart.guards.media({ type: "text", text: "hi" })).toBe(false)
+6 -86
View File
@@ -4,8 +4,7 @@ import { GenerationOptions, LLM, LLMEvent, LLMRequest, LLMResponse, ToolChoice }
import { LLMClient } from "../src/route"
import * as AnthropicMessages from "../src/protocols/anthropic-messages"
import * as OpenAIChat from "../src/protocols/openai-chat"
import { tool, ToolFailure, type ToolExecuteContext } from "../src/tool"
import { ToolRuntime } from "../src/tool-runtime"
import { tool, ToolFailure } from "../src/tool"
import { it } from "./lib/effect"
import * as TestToolRuntime from "./lib/tool-runtime"
import { dynamicResponse, scriptedResponses } from "./lib/http"
@@ -130,7 +129,7 @@ describe("LLMClient tools", () => {
name: "get_weather",
result: { type: "json", value: { temperature: 22, condition: "sunny" } },
})
expect(events.at(-1)?.type).toBe("finish")
expect(events.at(-1)?.type).toBe("request-finish")
expect(LLMResponse.text({ events })).toBe("It's sunny in Paris.")
}),
)
@@ -149,40 +148,11 @@ describe("LLMClient tools", () => {
),
)
expect(events.filter(LLMEvent.is.finish)).toHaveLength(1)
expect(events.filter(LLMEvent.is.requestFinish)).toHaveLength(1)
expect(events.find(LLMEvent.is.toolResult)).toMatchObject({ type: "tool-result", id: "call_1" })
}),
)
it.effect("passes tool call context to execute", () =>
Effect.gen(function* () {
let context: ToolExecuteContext | undefined
const contextual = tool({
description: "Capture tool context.",
parameters: Schema.Struct({ value: Schema.String }),
success: Schema.Struct({ ok: Schema.Boolean }),
execute: (_params, ctx) =>
Effect.sync(() => {
context = ctx
return { ok: true }
}),
})
const events = Array.from(
yield* TestToolRuntime.runTools({ request: baseRequest, tools: { contextual } }).pipe(
Stream.runCollect,
Effect.provide(
scriptedResponses([
sseEvents(toolCallChunk("call_ctx", "contextual", '{"value":"x"}'), finishChunk("tool_calls")),
]),
),
),
)
expect(events.some(LLMEvent.is.toolResult)).toBe(true)
expect(context).toEqual({ id: "call_ctx", name: "contextual" })
}),
)
it.effect("can expose tool schemas without executing tool calls", () =>
Effect.gen(function* () {
const layer = scriptedResponses([
@@ -349,7 +319,7 @@ describe("LLMClient tools", () => {
"text-delta",
"text-end",
"step-finish",
"finish",
"request-finish",
])
expect(LLMResponse.text({ events })).toBe("Done.")
}),
@@ -373,57 +343,7 @@ describe("LLMClient tools", () => {
),
)
expect(events.filter(LLMEvent.is.finish)).toHaveLength(1)
expect(events.filter(LLMEvent.is.stepStart).map((event) => event.index)).toEqual([0, 1])
expect(events.filter(LLMEvent.is.stepFinish).map((event) => event.index)).toEqual([0, 1])
}),
)
it.effect("emits one final finish with aggregate usage", () =>
Effect.gen(function* () {
let calls = 0
const events = Array.from(
yield* ToolRuntime.stream({
request: baseRequest,
tools: { get_weather },
stopWhen: ToolRuntime.stepCountIs(2),
stream: () =>
Stream.fromIterable<LLMEvent>(
calls++ === 0
? [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call_1", name: "get_weather", input: { city: "Paris" } }),
LLMEvent.stepFinish({
index: 0,
reason: "tool-calls",
usage: { inputTokens: 1, outputTokens: 2, totalTokens: 3 },
}),
LLMEvent.finish({
reason: "tool-calls",
usage: { inputTokens: 1, outputTokens: 2, totalTokens: 3 },
}),
]
: [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textDelta({ id: "text_1", text: "Done." }),
LLMEvent.stepFinish({
index: 0,
reason: "stop",
usage: { inputTokens: 4, outputTokens: 5, totalTokens: 9 },
}),
LLMEvent.finish({ reason: "stop", usage: { inputTokens: 4, outputTokens: 5, totalTokens: 9 } }),
],
),
}).pipe(Stream.runCollect),
)
expect(events.filter(LLMEvent.is.stepFinish).map((event) => event.index)).toEqual([0, 1])
expect(events.filter(LLMEvent.is.finish)).toHaveLength(1)
expect(events.find(LLMEvent.is.finish)?.usage).toMatchObject({
inputTokens: 5,
outputTokens: 7,
totalTokens: 12,
})
expect(events.filter(LLMEvent.is.requestFinish)).toHaveLength(2)
}),
)
@@ -442,7 +362,7 @@ describe("LLMClient tools", () => {
}).pipe(Stream.runCollect, Effect.provide(layer)),
)
expect(events.filter(LLMEvent.is.finish)).toHaveLength(1)
expect(events.filter(LLMEvent.is.requestFinish)).toHaveLength(1)
expect(events.find(LLMEvent.is.toolResult)).toMatchObject({ type: "tool-result", id: "call_1" })
}),
)
+1
View File
@@ -105,6 +105,7 @@
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
+3 -4
View File
@@ -1094,8 +1094,8 @@ export class Agent implements ACPAgent {
const currentModeId = await (async () => {
if (!availableModes.length) return undefined
const defaultAgent = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultInfo()))
const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgent.name)?.id ?? availableModes[0].id
const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent()))
const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id
this.sessionManager.setMode(sessionId, resolvedModeId)
return resolvedModeId
})()
@@ -1328,8 +1328,7 @@ export class Agent implements ACPAgent {
if (!current) {
this.sessionManager.setModel(session.id, model)
}
const agent =
session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultInfo()))).name
const agent = session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent())))
const parts: Array<
| { type: "text"; text: string; synthetic?: boolean; ignored?: boolean }
+3 -12
View File
@@ -57,7 +57,6 @@ const GeneratedAgent = Schema.Struct({
export interface Interface {
readonly get: (agent: string) => Effect.Effect<Info>
readonly list: () => Effect.Effect<Info[]>
readonly defaultInfo: () => Effect.Effect<Info>
readonly defaultAgent: () => Effect.Effect<string>
readonly generate: (input: {
description: string
@@ -334,28 +333,23 @@ export const layer = Layer.effect(
)
})
const defaultInfo = Effect.fnUntraced(function* () {
const defaultAgent = Effect.fnUntraced(function* () {
const c = yield* config.get()
if (c.default_agent) {
const agent = agents[c.default_agent]
if (!agent) throw new Error(`default agent "${c.default_agent}" not found`)
if (agent.mode === "subagent") throw new Error(`default agent "${c.default_agent}" is a subagent`)
if (agent.hidden === true) throw new Error(`default agent "${c.default_agent}" is hidden`)
return agent
return agent.name
}
const visible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true)
if (!visible) throw new Error("no primary visible agent found")
return visible
})
const defaultAgent = Effect.fnUntraced(function* () {
return (yield* defaultInfo()).name
return visible.name
})
return {
get,
list,
defaultInfo,
defaultAgent,
} satisfies State
}),
@@ -368,9 +362,6 @@ export const layer = Layer.effect(
list: Effect.fn("Agent.list")(function* () {
return yield* InstanceState.useEffect(state, (s) => s.list())
}),
defaultInfo: Effect.fn("Agent.defaultInfo")(function* () {
return yield* InstanceState.useEffect(state, (s) => s.defaultInfo())
}),
defaultAgent: Effect.fn("Agent.defaultAgent")(function* () {
return yield* InstanceState.useEffect(state, (s) => s.defaultAgent())
}),
+2 -2
View File
@@ -78,8 +78,8 @@ export function FormatError(input: unknown) {
].join("\n")
}
// UICancelledError: user cancelled an interactive CLI prompt
if (isTaggedError(input, "UICancelledError") || NamedError.hasName(input, "UICancelledError")) {
// UICancelledError: void (no data)
if (NamedError.hasName(input, "UICancelledError")) {
return ""
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { EOL } from "os"
import { NamedError } from "@opencode-ai/core/util/error"
import { Schema } from "effect"
import { logo as glyphs } from "./logo"
@@ -9,7 +10,7 @@ const wordmark = [
`▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`,
]
export class CancelledError extends Schema.TaggedErrorClass<CancelledError>()("UICancelledError", {}) {}
export const CancelledError = NamedError.create("UICancelledError", Schema.optional(Schema.Void))
export const Style = {
TEXT_HIGHLIGHT: "\x1b[96m",
+1 -4
View File
@@ -94,7 +94,6 @@ 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()
@@ -146,9 +145,7 @@ export const layer = Layer.effect(
)
}
}).pipe(
Effect.tapCause((cause) =>
Effect.logError("failed to run data migrations").pipe(Effect.annotateLogs("cause", cause)),
),
Effect.tapCause((cause) => Effect.logError("failed to run data migrations", { 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.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid)
yield* Deferred.await(st.run.done).pipe(Effect.exit, Effect.asVoid)
yield* idleIfCurrent()
}),
{ _tag: "Idle" } as const,
+9 -7
View File
@@ -6,7 +6,6 @@ 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,
@@ -15,6 +14,7 @@ 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,8 +35,13 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
const log = Log.create({ service: "mcp" })
const DEFAULT_TIMEOUT = 30_000
const TolerantListToolsResultSchema = ListToolsResultSchema.extend({
tools: ToolSchema.omit({ outputSchema: true }).array(),
const TolerantToolSchema = ToolSchema.extend({
outputSchema: z.unknown().optional(),
})
const TolerantListToolsResultSchema = z.looseObject({
tools: z.array(TolerantToolSchema),
nextCursor: z.string().optional(),
})
export const Resource = Schema.Struct({
@@ -132,10 +137,7 @@ 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").pipe(Effect.annotateLogs("directory", ctx.directory))
yield* Effect.logInfo("bootstrapping", { 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,9 +156,7 @@ 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").pipe(
Effect.annotateLogs({ key: item[0], cause: exit.cause }),
)
yield* Effect.logWarning("instance dispose failed", { 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 { NamedError } from "@opencode-ai/core/util/error"
import { namedSchemaError } from "@/util/named-schema-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 = NamedError.create("ProviderAuthOauthMissing", { providerID: ProviderID })
export const OauthMissing = namedSchemaError("ProviderAuthOauthMissing", { providerID: ProviderID })
export const OauthCodeMissing = NamedError.create("ProviderAuthOauthCodeMissing", { providerID: ProviderID })
export const OauthCodeMissing = namedSchemaError("ProviderAuthOauthCodeMissing", { providerID: ProviderID })
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", {})
export const OauthCallbackFailed = namedSchemaError("ProviderAuthOauthCallbackFailed", {})
export const ValidationFailed = NamedError.create("ProviderAuthValidationFailed", {
export const ValidationFailed = namedSchemaError("ProviderAuthValidationFailed", {
field: Schema.String,
message: Schema.String,
})
+1 -3
View File
@@ -177,9 +177,7 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | HttpClie
yield* invalidate
}),
).pipe(
Effect.tapCause((cause) =>
Effect.logError("Failed to fetch models.dev").pipe(Effect.annotateLogs("cause", cause)),
),
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { 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 { NamedError } from "@opencode-ai/core/util/error"
import { namedSchemaError } from "@/util/named-schema-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 = NamedError.create("ProviderModelNotFoundError", {
export const ModelNotFoundError = namedSchemaError("ProviderModelNotFoundError", {
providerID: ProviderID,
modelID: ModelID,
suggestions: Schema.optional(Schema.Array(Schema.String)),
})
export const InitError = NamedError.create("ProviderInitError", {
export const InitError = namedSchemaError("ProviderInitError", {
providerID: ProviderID,
})
+1 -3
View File
@@ -169,9 +169,7 @@ export const layer = Layer.effect(
).pipe(
Effect.asVoid,
Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize reference repository").pipe(
Effect.annotateLogs({ name: reference.name, cause }),
),
Effect.logWarning("failed to materialize reference repository", { name: reference.name, cause }),
),
),
)
@@ -79,7 +79,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
const list = yield* registry.tools({
providerID: ctx.query.provider,
modelID: ctx.query.model,
agent: yield* agents.defaultInfo(),
agent: yield* agents.get(yield* agents.defaultAgent()),
})
return list.map((item) => ({
id: item.id,
@@ -299,9 +299,7 @@ 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").pipe(
Effect.annotateLogs({ sessionID: ctx.params.sessionID, cause }),
)
yield* Effect.logError("prompt_async failed", { sessionID: ctx.params.sessionID, cause })
yield* bus.publish(Session.Event.Error, {
sessionID: ctx.params.sessionID,
error: new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(),
+230
View File
@@ -0,0 +1,230 @@
import { FinishReason, LLMEvent, ProviderMetadata, ToolResultValue } from "@opencode-ai/llm"
import { Effect, Schema } from "effect"
import { type streamText } from "ai"
import { errorMessage } from "@/util/error"
type Result = Awaited<ReturnType<typeof streamText>>
type AISDKEvent = Result["fullStream"] extends AsyncIterable<infer T> ? T : never
export function adapterState() {
return {
step: 0,
text: 0,
reasoning: 0,
currentTextID: undefined as string | undefined,
currentReasoningID: undefined as string | undefined,
toolNames: {} as Record<string, string>,
}
}
function finishReason(value: string | undefined): FinishReason {
return Schema.is(FinishReason)(value) ? value : "unknown"
}
function providerMetadata(value: unknown): ProviderMetadata | undefined {
return Schema.is(ProviderMetadata)(value) ? value : undefined
}
function usage(value: unknown) {
if (!value || typeof value !== "object") return undefined
const item = value as {
inputTokens?: number
outputTokens?: number
totalTokens?: number
reasoningTokens?: number
cachedInputTokens?: number
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number }
outputTokenDetails?: { reasoningTokens?: number }
}
const result = Object.fromEntries(
Object.entries({
inputTokens: item.inputTokens,
outputTokens: item.outputTokens,
totalTokens: item.totalTokens,
reasoningTokens: item.outputTokenDetails?.reasoningTokens ?? item.reasoningTokens,
cacheReadInputTokens: item.inputTokenDetails?.cacheReadTokens ?? item.cachedInputTokens,
cacheWriteInputTokens: item.inputTokenDetails?.cacheWriteTokens,
}).filter((entry) => entry[1] !== undefined),
)
return result
}
function currentTextID(state: ReturnType<typeof adapterState>, id: string | undefined) {
state.currentTextID = id ?? state.currentTextID ?? `text-${state.text++}`
return state.currentTextID
}
function currentReasoningID(state: ReturnType<typeof adapterState>, id: string | undefined) {
state.currentReasoningID = id ?? state.currentReasoningID ?? `reasoning-${state.reasoning++}`
return state.currentReasoningID
}
export function toLLMEvents(
state: ReturnType<typeof adapterState>,
event: AISDKEvent,
): Effect.Effect<ReadonlyArray<LLMEvent>, unknown> {
switch (event.type) {
case "start":
return Effect.succeed([])
case "start-step":
return Effect.succeed([LLMEvent.stepStart({ index: state.step })])
case "finish-step":
return Effect.sync(() => [
LLMEvent.stepFinish({
index: state.step++,
reason: finishReason(event.finishReason),
usage: usage(event.usage),
providerMetadata: providerMetadata(event.providerMetadata),
}),
])
case "finish":
return Effect.sync(() => {
state.toolNames = {}
return [
LLMEvent.requestFinish({
reason: finishReason(event.finishReason),
usage: usage(event.totalUsage),
}),
]
})
case "text-start":
return Effect.sync(() => {
state.currentTextID = currentTextID(state, event.id)
return [
LLMEvent.textStart({
id: state.currentTextID,
providerMetadata: providerMetadata(event.providerMetadata),
}),
]
})
case "text-delta":
return Effect.succeed([
LLMEvent.textDelta({
id: currentTextID(state, event.id),
text: event.text,
}),
])
case "text-end":
return Effect.succeed([
LLMEvent.textEnd({
id: currentTextID(state, event.id),
providerMetadata: providerMetadata(event.providerMetadata),
}),
])
case "reasoning-start":
return Effect.sync(() => {
state.currentReasoningID = currentReasoningID(state, event.id)
return [
LLMEvent.reasoningStart({
id: state.currentReasoningID,
providerMetadata: providerMetadata(event.providerMetadata),
}),
]
})
case "reasoning-delta":
return Effect.succeed([
LLMEvent.reasoningDelta({
id: currentReasoningID(state, event.id),
text: event.text,
}),
])
case "reasoning-end":
return Effect.sync(() => {
const id = currentReasoningID(state, event.id)
state.currentReasoningID = undefined
return [
LLMEvent.reasoningEnd({
id,
providerMetadata: providerMetadata(event.providerMetadata),
}),
]
})
case "tool-input-start":
return Effect.sync(() => {
state.toolNames[event.id] = event.toolName
return [
LLMEvent.toolInputStart({
id: event.id,
name: event.toolName,
providerMetadata: providerMetadata(event.providerMetadata),
}),
]
})
case "tool-input-delta":
return Effect.succeed([
LLMEvent.toolInputDelta({
id: event.id,
name: state.toolNames[event.id] ?? "unknown",
text: event.delta ?? "",
}),
])
case "tool-input-end":
return Effect.succeed([
LLMEvent.toolInputEnd({
id: event.id,
name: state.toolNames[event.id] ?? "unknown",
}),
])
case "tool-call":
return Effect.sync(() => {
state.toolNames[event.toolCallId] = event.toolName
return [
LLMEvent.toolCall({
id: event.toolCallId,
name: event.toolName,
input: event.input,
providerExecuted: "providerExecuted" in event ? event.providerExecuted : undefined,
providerMetadata: providerMetadata(event.providerMetadata),
}),
]
})
case "tool-result":
return Effect.sync(() => {
const name = state.toolNames[event.toolCallId] ?? "unknown"
delete state.toolNames[event.toolCallId]
return [
LLMEvent.toolResult({
id: event.toolCallId,
name,
result: ToolResultValue.make(event.output),
providerExecuted: "providerExecuted" in event ? event.providerExecuted : undefined,
}),
]
})
case "tool-error":
return Effect.sync(() => {
const name = state.toolNames[event.toolCallId] ?? "unknown"
delete state.toolNames[event.toolCallId]
return [
LLMEvent.toolError({
id: event.toolCallId,
name,
message: errorMessage(event.error),
}),
]
})
case "error":
return Effect.fail(event.error)
default:
return Effect.succeed([])
}
}
export * as LLMAISDK from "./llm-ai-sdk"
+190
View File
@@ -0,0 +1,190 @@
import type { JsonSchema, LLMRequest, ProviderMetadata } from "@opencode-ai/llm"
import { LLM, Message, SystemPart, ToolCallPart, ToolDefinition, ToolResultPart } from "@opencode-ai/llm"
import "@opencode-ai/llm/providers"
import type { ModelMessage } from "ai"
import type { Provider } from "@/provider/provider"
type ToolInput = {
readonly description?: string
readonly inputSchema?: unknown
}
export type RequestInput = {
readonly model: Provider.Model
readonly apiKey?: string
readonly baseURL?: string
readonly system?: readonly string[]
readonly messages: readonly ModelMessage[]
readonly tools?: Record<string, ToolInput>
readonly toolChoice?: "auto" | "required" | "none"
readonly temperature?: number
readonly topP?: number
readonly topK?: number
readonly maxOutputTokens?: number
readonly providerOptions?: LLMRequest["providerOptions"]
readonly headers?: Record<string, string>
}
const DEFAULT_BASE_URL: Record<string, string> = {
"@ai-sdk/openai": "https://api.openai.com/v1",
"@ai-sdk/anthropic": "https://api.anthropic.com/v1",
"@ai-sdk/google": "https://generativelanguage.googleapis.com/v1beta",
"@ai-sdk/amazon-bedrock": "https://bedrock-runtime.us-east-1.amazonaws.com",
"@openrouter/ai-sdk-provider": "https://openrouter.ai/api/v1",
}
const ROUTE: Record<string, string> = {
"@ai-sdk/openai": "openai-responses",
"@ai-sdk/azure": "azure-openai-responses",
"@ai-sdk/anthropic": "anthropic-messages",
"@ai-sdk/google": "gemini",
"@ai-sdk/amazon-bedrock": "bedrock-converse",
"@ai-sdk/openai-compatible": "openai-compatible-chat",
"@openrouter/ai-sdk-provider": "openrouter",
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
const providerMetadata = (value: unknown): ProviderMetadata | undefined => {
if (!isRecord(value)) return undefined
const result = Object.fromEntries(
Object.entries(value).filter((entry): entry is [string, Record<string, unknown>] => isRecord(entry[1])),
)
return Object.keys(result).length === 0 ? undefined : result
}
const textPart = (part: Record<string, unknown>) => ({
type: "text" as const,
text: typeof part.text === "string" ? part.text : "",
providerMetadata: providerMetadata(part.providerOptions),
})
const mediaPart = (part: Record<string, unknown>) => {
if (typeof part.data !== "string" && !(part.data instanceof Uint8Array))
throw new Error("Native LLM request adapter only supports file parts with string or Uint8Array data")
return {
type: "media" as const,
mediaType: typeof part.mediaType === "string" ? part.mediaType : "application/octet-stream",
data: part.data,
filename: typeof part.filename === "string" ? part.filename : undefined,
}
}
const toolResult = (part: Record<string, unknown>) => {
const output = isRecord(part.output) ? part.output : { type: "json", value: part.output }
const type = output.type === "text" ? "text" : output.type === "error-text" ? "error" : "json"
return ToolResultPart.make({
id: typeof part.toolCallId === "string" ? part.toolCallId : "",
name: typeof part.toolName === "string" ? part.toolName : "",
result: "value" in output ? output.value : output,
resultType: type,
providerExecuted: typeof part.providerExecuted === "boolean" ? part.providerExecuted : undefined,
providerMetadata: providerMetadata(part.providerOptions),
})
}
const contentPart = (part: unknown) => {
if (!isRecord(part)) throw new Error("Native LLM request adapter only supports object content parts")
if (part.type === "text") return textPart(part)
if (part.type === "file") return mediaPart(part)
if (part.type === "reasoning")
return {
type: "reasoning" as const,
text: typeof part.text === "string" ? part.text : "",
providerMetadata: providerMetadata(part.providerOptions),
}
if (part.type === "tool-call")
return ToolCallPart.make({
id: typeof part.toolCallId === "string" ? part.toolCallId : "",
name: typeof part.toolName === "string" ? part.toolName : "",
input: part.input,
providerExecuted: typeof part.providerExecuted === "boolean" ? part.providerExecuted : undefined,
providerMetadata: providerMetadata(part.providerOptions),
})
if (part.type === "tool-result") return toolResult(part)
throw new Error(`Native LLM request adapter does not support ${String(part.type)} content parts`)
}
const content = (value: ModelMessage["content"]) =>
typeof value === "string" ? [{ type: "text" as const, text: value }] : value.map(contentPart)
const messages = (input: readonly ModelMessage[]) => {
const system = input.flatMap((message) => (message.role === "system" ? [SystemPart.make(message.content)] : []))
const messages = input.flatMap((message) => {
if (message.role === "system") return []
return [
Message.make({
role: message.role,
content: content(message.content),
native: isRecord(message.providerOptions) ? { providerOptions: message.providerOptions } : undefined,
}),
]
})
return { system, messages }
}
const schema = (value: unknown): JsonSchema => {
if (!isRecord(value)) return { type: "object", properties: {} }
if (isRecord(value.jsonSchema)) return value.jsonSchema
return value
}
const tools = (input: Record<string, ToolInput> | undefined): ToolDefinition[] =>
Object.entries(input ?? {}).map(([name, item]) =>
ToolDefinition.make({
name,
description: item.description ?? "",
inputSchema: schema(item.inputSchema),
}),
)
const generation = (input: RequestInput) => {
const result = {
temperature: input.temperature,
topP: input.topP,
topK: input.topK,
maxTokens: input.maxOutputTokens,
}
return Object.values(result).some((value) => value !== undefined) ? result : undefined
}
const baseURL = (model: Provider.Model) => {
if (model.api.url) return model.api.url
const fallback = DEFAULT_BASE_URL[model.api.npm]
if (fallback) return fallback
throw new Error(`Native LLM request adapter requires a base URL for ${model.providerID}/${model.id}`)
}
export const model = (input: Provider.Model | RequestInput, headers?: Record<string, string>) => {
const model = "model" in input ? input.model : input
const route = ROUTE[model.api.npm]
if (!route) throw new Error(`Native LLM request adapter does not support provider package ${model.api.npm}`)
return LLM.model({
id: model.api.id,
provider: model.providerID,
route,
baseURL: "model" in input && input.baseURL ? input.baseURL : baseURL(model),
apiKey: "model" in input ? input.apiKey : undefined,
headers: Object.keys({ ...model.headers, ...headers }).length === 0 ? undefined : { ...model.headers, ...headers },
limits: {
context: model.limit.context,
output: model.limit.output,
},
})
}
export const request = (input: RequestInput) => {
const converted = messages(input.messages)
return LLM.request({
model: model(input, input.headers),
system: [...(input.system ?? []).map(SystemPart.make), ...converted.system],
messages: converted.messages,
tools: tools(input.tools),
toolChoice: input.toolChoice,
generation: generation(input),
providerOptions: input.providerOptions,
})
}
export * as LLMNative from "./llm-native"
+162 -83
View File
@@ -2,7 +2,9 @@ import { Provider } from "@/provider/provider"
import * as Log from "@opencode-ai/core/util/log"
import { Context, Effect, Layer, Record } from "effect"
import * as Stream from "effect/Stream"
import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai"
import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool as aiTool, jsonSchema, asSchema } from "ai"
import { tool as nativeTool, ToolFailure, type JsonSchema, type LLMEvent } from "@opencode-ai/llm"
import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
import { mergeDeep } from "remeda"
import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
import { ProviderTransform } from "@/provider/transform"
@@ -16,23 +18,26 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { Bus } from "@/bus"
import { errorMessage } from "@/util/error"
import { Wildcard } from "@/util/wildcard"
import { SessionID } from "@/session/schema"
import { Auth } from "@/auth"
import { Installation } from "@/installation"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { EffectBridge } from "@/effect/bridge"
import * as Option from "effect/Option"
import * as OtelTracer from "@effect/opentelemetry/Tracer"
import { LLMAISDK } from "./llm-ai-sdk"
import { LLMNative } from "./llm-native"
const log = Log.create({ service: "llm" })
export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
type Result = Awaited<ReturnType<typeof streamText>>
// Avoid re-instantiating remeda's deep merge types in this hot LLM path; the runtime behavior is still mergeDeep.
const mergeOptions = (target: Record<string, any>, source: Record<string, any> | undefined): Record<string, any> =>
mergeDeep(target, source ?? {}) as Record<string, any>
const runtime = () => (process.env.OPENCODE_LLM_RUNTIME === "native" ? "native" : "ai-sdk")
export type StreamInput = {
user: MessageV2.User
sessionID: string
@@ -52,10 +57,8 @@ export type StreamRequest = StreamInput & {
abort: AbortSignal
}
export type Event = Result["fullStream"] extends AsyncIterable<infer T> ? T : never
export interface Interface {
readonly stream: (input: StreamInput) => Stream.Stream<Event, unknown>
readonly stream: (input: StreamInput) => Stream.Stream<LLMEvent, unknown>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {}
@@ -214,7 +217,7 @@ const live: Layer.Layer<
Object.keys(tools).length === 0 &&
hasToolCalls(input.messages)
) {
tools["_noop"] = tool({
tools["_noop"] = aiTool({
description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
inputSchema: jsonSchema({
type: "object",
@@ -334,86 +337,123 @@ const live: Layer.Layer<
? (yield* InstanceState.context).project.id
: undefined
return streamText({
onError(error) {
l.error("stream error", {
error,
})
},
async experimental_repairToolCall(failed) {
const lower = failed.toolCall.toolName.toLowerCase()
if (lower !== failed.toolCall.toolName && sortedTools[lower]) {
l.info("repairing tool call", {
tool: failed.toolCall.toolName,
repaired: lower,
const requestHeaders = {
...(input.model.providerID.startsWith("opencode")
? {
...(opencodeProjectID ? { "x-opencode-project": opencodeProjectID } : {}),
"x-opencode-session": input.sessionID,
"x-opencode-request": input.user.id,
"x-opencode-client": Flag.OPENCODE_CLIENT,
"User-Agent": `opencode/${InstallationVersion}`,
}
: {
"x-session-affinity": input.sessionID,
...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
"User-Agent": `opencode/${InstallationVersion}`,
}),
...input.model.headers,
...headers,
}
if (runtime() === "native") {
if (input.model.providerID !== "openai" || input.model.api.npm !== "@ai-sdk/openai") {
return yield* Effect.fail(new Error("Native LLM runtime currently only supports OpenAI models"))
}
const apiKey =
info?.type === "api" ? info.key : typeof item.options.apiKey === "string" ? item.options.apiKey : undefined
if (!apiKey) return yield* Effect.fail(new Error("Native LLM runtime requires API key auth for OpenAI"))
const baseURL = typeof item.options.baseURL === "string" ? item.options.baseURL : undefined
const request = LLMNative.request({
model: input.model,
apiKey,
baseURL,
system: isOpenaiOauth ? system : [],
messages: ProviderTransform.message(messages, input.model, options),
tools: sortedTools,
toolChoice: input.toolChoice,
temperature: params.temperature,
topP: params.topP,
topK: params.topK,
maxOutputTokens: params.maxOutputTokens,
providerOptions: ProviderTransform.providerOptions(input.model, params.options),
headers: requestHeaders,
})
return {
type: "native" as const,
stream: LLMClient.stream({ request, tools: nativeTools(sortedTools, input) }).pipe(
Stream.provide(LLMClient.layer),
Stream.provide(RequestExecutor.defaultLayer),
),
}
}
return {
type: "ai-sdk" as const,
result: streamText({
onError(error) {
l.error("stream error", {
error,
})
},
async experimental_repairToolCall(failed) {
const lower = failed.toolCall.toolName.toLowerCase()
if (lower !== failed.toolCall.toolName && sortedTools[lower]) {
l.info("repairing tool call", {
tool: failed.toolCall.toolName,
repaired: lower,
})
return {
...failed.toolCall,
toolName: lower,
}
}
return {
...failed.toolCall,
toolName: lower,
}
}
return {
...failed.toolCall,
input: JSON.stringify({
tool: failed.toolCall.toolName,
error: failed.error.message,
}),
toolName: "invalid",
}
},
temperature: params.temperature,
topP: params.topP,
topK: params.topK,
providerOptions: ProviderTransform.providerOptions(input.model, params.options),
activeTools: Object.keys(sortedTools).filter((x) => x !== "invalid"),
tools: sortedTools,
toolChoice: input.toolChoice,
maxOutputTokens: params.maxOutputTokens,
abortSignal: input.abort,
headers: {
...(input.model.providerID.startsWith("opencode")
? {
"x-opencode-project": opencodeProjectID,
"x-opencode-session": input.sessionID,
"x-opencode-request": input.user.id,
"x-opencode-client": Flag.OPENCODE_CLIENT,
"User-Agent": `opencode/${InstallationVersion}`,
}
: {
"x-session-affinity": input.sessionID,
...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
"User-Agent": `opencode/${InstallationVersion}`,
input: JSON.stringify({
tool: failed.toolCall.toolName,
error: failed.error.message,
}),
...input.model.headers,
...headers,
},
maxRetries: input.retries ?? 0,
messages,
model: wrapLanguageModel({
model: language,
middleware: [
{
specificationVersion: "v3" as const,
async transformParams(args) {
if (args.type === "stream") {
// @ts-expect-error
args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
}
return args.params
},
},
],
}),
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
functionId: "session.llm",
tracer: telemetryTracer,
metadata: {
userId: cfg.username ?? "unknown",
sessionId: input.sessionID,
toolName: "invalid",
}
},
},
})
temperature: params.temperature,
topP: params.topP,
topK: params.topK,
providerOptions: ProviderTransform.providerOptions(input.model, params.options),
activeTools: Object.keys(sortedTools).filter((x) => x !== "invalid"),
tools: sortedTools,
toolChoice: input.toolChoice,
maxOutputTokens: params.maxOutputTokens,
abortSignal: input.abort,
headers: requestHeaders,
maxRetries: input.retries ?? 0,
messages,
model: wrapLanguageModel({
model: language,
middleware: [
{
specificationVersion: "v3" as const,
async transformParams(args) {
if (args.type === "stream") {
// @ts-expect-error
args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
}
return args.params
},
},
],
}),
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
functionId: "session.llm",
tracer: telemetryTracer,
metadata: {
userId: cfg.username ?? "unknown",
sessionId: input.sessionID,
},
},
}),
}
})
const stream: Interface["stream"] = (input) =>
@@ -427,7 +467,15 @@ const live: Layer.Layer<
const result = yield* run({ ...input, abort: ctrl.signal })
return Stream.fromAsyncIterable(result.fullStream, (e) => (e instanceof Error ? e : new Error(String(e))))
if (result.type === "native") return result.stream
const state = LLMAISDK.adapterState()
return Stream.fromAsyncIterable(result.result.fullStream, (e) =>
e instanceof Error ? e : new Error(String(e)),
).pipe(
Stream.mapEffect((event) => LLMAISDK.toLLMEvents(state, event)),
Stream.flatMap((events) => Stream.fromIterable(events)),
)
}),
),
)
@@ -455,6 +503,37 @@ function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission"
return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k))
}
function nativeSchema(value: unknown): JsonSchema {
if (!value || typeof value !== "object") return { type: "object", properties: {} }
if ("jsonSchema" in value && value.jsonSchema && typeof value.jsonSchema === "object")
return value.jsonSchema as JsonSchema
return asSchema(value as Parameters<typeof asSchema>[0]).jsonSchema as JsonSchema
}
function nativeTools(tools: Record<string, Tool>, input: StreamRequest) {
return Object.fromEntries(
Object.entries(tools).map(([name, item]) => [
name,
nativeTool({
description: item.description ?? "",
jsonSchema: nativeSchema(item.inputSchema),
execute: (args: unknown, ctx?: { readonly id: string; readonly name: string }) =>
Effect.tryPromise({
try: () => {
if (!item.execute) throw new Error(`Tool has no execute handler: ${name}`)
return item.execute(args, {
toolCallId: ctx?.id ?? name,
messages: input.messages,
abortSignal: input.abort,
})
},
catch: (error) => new ToolFailure({ message: errorMessage(error) }),
}),
}),
]),
)
}
// Check if messages contain any tool-call content
// Used to determine if a dummy tool should be added for LiteLLM proxy compatibility
export function hasToolCalls(messages: ModelMessage[]): boolean {
@@ -1,14 +0,0 @@
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"
+13 -8
View File
@@ -23,10 +23,8 @@ 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 {
@@ -38,12 +36,17 @@ interface FetchDecompressionError extends Error {
export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached media from tool result:"
export { isMedia }
export const AbortedError = NamedError.create("MessageAbortedError", { message: Schema.String })
export const StructuredOutputError = NamedError.create("StructuredOutputError", {
export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {})
export const AbortedError = namedSchemaError("MessageAbortedError", { message: Schema.String })
export const StructuredOutputError = namedSchemaError("StructuredOutputError", {
message: Schema.String,
retries: NonNegativeInt,
})
export const APIError = NamedError.create("APIError", {
export const AuthError = namedSchemaError("ProviderAuthError", {
providerID: Schema.String,
message: Schema.String,
})
export const APIError = namedSchemaError("APIError", {
message: Schema.String,
statusCode: Schema.optional(NonNegativeInt),
isRetryable: Schema.Boolean,
@@ -52,7 +55,7 @@ export const APIError = NamedError.create("APIError", {
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
})
export type APIError = Schema.Schema.Type<typeof APIError.Schema>
export const ContextOverflowError = NamedError.create("ContextOverflowError", {
export const ContextOverflowError = namedSchemaError("ContextOverflowError", {
message: Schema.String,
responseBody: Schema.optional(Schema.String),
})
@@ -378,7 +381,9 @@ export type Part =
| CompactionPart
const AssistantErrorSchema = Schema.Union([
...MessageError.Shared,
AuthError.EffectSchema,
NamedError.Unknown.EffectSchema,
OutputLengthError.EffectSchema,
AbortedError.EffectSchema,
StructuredOutputError.EffectSchema,
ContextOverflowError.EffectSchema,
+11 -4
View File
@@ -2,9 +2,14 @@ import { Schema } from "effect"
import { SessionID } from "./schema"
import { ModelID, ProviderID } from "../provider/schema"
import { NonNegativeInt } from "@opencode-ai/core/schema"
import { MessageError } from "./message-error"
import { AuthError, OutputLengthError } from "./message-error"
export { AuthError, OutputLengthError } from "./message-error"
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,
})
export const ToolCall = Schema.Struct({
state: Schema.Literal("call"),
@@ -100,7 +105,9 @@ export const Info = Schema.Struct({
created: NonNegativeInt,
completed: Schema.optional(NonNegativeInt),
}),
error: Schema.optional(MessageError.SharedSchema),
error: Schema.optional(
Schema.Union([AuthError.EffectSchema, NamedError.Unknown.EffectSchema, OutputLengthError.EffectSchema]),
),
sessionID: SessionID,
tool: Schema.Record(
Schema.String,
+200 -118
View File
@@ -1,4 +1,4 @@
import { Cause, Deferred, Effect, Exit, Layer, Context, Scope } from "effect"
import { Cause, Deferred, Effect, Exit, Layer, Context, Scope, Schema } from "effect"
import * as Stream from "effect/Stream"
import { Agent } from "@/agent/agent"
import { Bus } from "@/bus"
@@ -26,14 +26,13 @@ import { SessionEvent } from "@/v2/session-event"
import { Modelv2 } from "@/v2/model"
import * as DateTime from "effect/DateTime"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Usage, type LLMEvent } from "@opencode-ai/llm"
const DOOM_LOOP_THRESHOLD = 3
const log = Log.create({ service: "session.processor" })
export type Result = "compact" | "stop" | "continue"
export type Event = LLM.Event
export interface Handle {
readonly message: MessageV2.Assistant
readonly updateToolCall: (
@@ -67,6 +66,7 @@ type ToolCall = {
messageID: MessageV2.ToolPart["messageID"]
sessionID: MessageV2.ToolPart["sessionID"]
done: Deferred.Deferred<void>
inputEnded: boolean
}
interface ProcessorContext extends Input {
@@ -79,7 +79,7 @@ interface ProcessorContext extends Input {
reasoningMap: Record<string, MessageV2.ReasoningPart>
}
type StreamEvent = Event
type StreamEvent = LLMEvent
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionProcessor") {}
@@ -223,9 +223,85 @@ export const layer: Layer.Layer<
return true
})
const finishReasoning = Effect.fn("SessionProcessor.finishReasoning")(function* (reasoningID: string) {
if (!(reasoningID in ctx.reasoningMap)) return
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) {
yield* sync.run(SessionEvent.Reasoning.Ended.Sync, {
sessionID: ctx.sessionID,
reasoningID,
text: ctx.reasoningMap[reasoningID].text,
timestamp: DateTime.makeUnsafe(Date.now()),
})
}
// oxlint-disable-next-line no-self-assign -- reactivity trigger
ctx.reasoningMap[reasoningID].text = ctx.reasoningMap[reasoningID].text
ctx.reasoningMap[reasoningID].time = { ...ctx.reasoningMap[reasoningID].time, end: Date.now() }
yield* session.updatePart(ctx.reasoningMap[reasoningID])
delete ctx.reasoningMap[reasoningID]
})
const ensureToolCall = Effect.fn("SessionProcessor.ensureToolCall")(function* (input: {
id: string
name: string
providerExecuted?: boolean
}) {
const existing = yield* readToolCall(input.id)
if (existing) return existing
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) {
yield* sync.run(SessionEvent.Tool.Input.Started.Sync, {
sessionID: ctx.sessionID,
callID: input.id,
name: input.name,
timestamp: DateTime.makeUnsafe(Date.now()),
})
}
const part = yield* session.updatePart({
id: PartID.ascending(),
messageID: ctx.assistantMessage.id,
sessionID: ctx.assistantMessage.sessionID,
type: "tool",
tool: input.name,
callID: input.id,
state: { status: "pending", input: {}, raw: "" },
metadata: input.providerExecuted ? { providerExecuted: true } : undefined,
} satisfies MessageV2.ToolPart)
ctx.toolcalls[input.id] = {
done: yield* Deferred.make<void>(),
partID: part.id,
messageID: part.messageID,
sessionID: part.sessionID,
inputEnded: false,
}
return { call: ctx.toolcalls[input.id], part }
})
const isFilePart = Schema.is(MessageV2.FilePart)
const toolResultOutput = (value: Extract<StreamEvent, { type: "tool-result" }>) => {
if (isRecord(value.result.value) && typeof value.result.value.output === "string") {
return {
title: typeof value.result.value.title === "string" ? value.result.value.title : value.name,
metadata: isRecord(value.result.value.metadata) ? value.result.value.metadata : {},
output: value.result.value.output,
attachments: Array.isArray(value.result.value.attachments)
? value.result.value.attachments.filter(isFilePart)
: undefined,
}
}
return {
title: value.name,
metadata: value.result.type === "json" && isRecord(value.result.value) ? value.result.value : {},
output: typeof value.result.value === "string" ? value.result.value : (JSON.stringify(value.result.value) ?? ""),
}
}
const toolInput = (value: unknown): Record<string, any> => (isRecord(value) ? value : { value })
const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) {
switch (value.type) {
case "start":
case "request-start":
yield* status.set(ctx.sessionID, { type: "busy" })
return
@@ -251,116 +327,132 @@ export const layer: Layer.Layer<
yield* session.updatePart(ctx.reasoningMap[value.id])
return
case "reasoning-delta":
if (!(value.id in ctx.reasoningMap)) return
ctx.reasoningMap[value.id].text += value.text
if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
case "reasoning-delta": {
const reasoningID = value.id ?? "reasoning"
if (!(reasoningID in ctx.reasoningMap)) {
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) {
yield* sync.run(SessionEvent.Reasoning.Started.Sync, {
sessionID: ctx.sessionID,
reasoningID,
timestamp: DateTime.makeUnsafe(Date.now()),
})
}
ctx.reasoningMap[reasoningID] = {
id: PartID.ascending(),
messageID: ctx.assistantMessage.id,
sessionID: ctx.assistantMessage.sessionID,
type: "reasoning",
text: "",
time: { start: Date.now() },
}
yield* session.updatePart(ctx.reasoningMap[reasoningID])
}
ctx.reasoningMap[reasoningID].text += value.text
yield* session.updatePartDelta({
sessionID: ctx.reasoningMap[value.id].sessionID,
messageID: ctx.reasoningMap[value.id].messageID,
partID: ctx.reasoningMap[value.id].id,
sessionID: ctx.reasoningMap[reasoningID].sessionID,
messageID: ctx.reasoningMap[reasoningID].messageID,
partID: ctx.reasoningMap[reasoningID].id,
field: "text",
delta: value.text,
})
return
}
case "reasoning-end":
if (!(value.id in ctx.reasoningMap)) return
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) {
yield* sync.run(SessionEvent.Reasoning.Ended.Sync, {
sessionID: ctx.sessionID,
reasoningID: value.id,
text: ctx.reasoningMap[value.id].text,
timestamp: DateTime.makeUnsafe(Date.now()),
})
if (value.providerMetadata && value.id in ctx.reasoningMap) {
ctx.reasoningMap[value.id].metadata = value.providerMetadata
}
// oxlint-disable-next-line no-self-assign -- reactivity trigger
ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text
ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() }
if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
yield* session.updatePart(ctx.reasoningMap[value.id])
delete ctx.reasoningMap[value.id]
yield* finishReasoning(value.id)
return
case "tool-input-start":
if (ctx.assistantMessage.summary) {
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
}
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) {
yield* sync.run(SessionEvent.Tool.Input.Started.Sync, {
sessionID: ctx.sessionID,
callID: value.id,
name: value.toolName,
timestamp: DateTime.makeUnsafe(Date.now()),
})
}
const part = yield* session.updatePart({
id: ctx.toolcalls[value.id]?.partID ?? PartID.ascending(),
messageID: ctx.assistantMessage.id,
sessionID: ctx.assistantMessage.sessionID,
type: "tool",
tool: value.toolName,
callID: value.id,
state: { status: "pending", input: {}, raw: "" },
metadata: value.providerExecuted ? { providerExecuted: true } : undefined,
} satisfies MessageV2.ToolPart)
ctx.toolcalls[value.id] = {
done: yield* Deferred.make<void>(),
partID: part.id,
messageID: part.messageID,
sessionID: part.sessionID,
throw new Error(`Tool call not allowed while generating summary: ${value.name}`)
}
yield* ensureToolCall(value)
return
case "tool-input-delta":
case "tool-input-delta": {
if (ctx.assistantMessage.summary) {
throw new Error(`Tool call not allowed while generating summary: ${value.name}`)
}
yield* ensureToolCall(value)
if (value.text) {
yield* updateToolCall(value.id, (match) => ({
...match,
state:
match.state.status === "pending"
? { ...match.state, raw: match.state.raw + value.text }
: match.state,
}))
}
return
}
case "tool-input-end": {
const toolCall = yield* ensureToolCall(value)
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) {
yield* sync.run(SessionEvent.Tool.Input.Ended.Sync, {
sessionID: ctx.sessionID,
callID: value.id,
text: "",
text: toolCall.part.state.status === "pending" ? toolCall.part.state.raw : "",
timestamp: DateTime.makeUnsafe(Date.now()),
})
}
ctx.toolcalls[value.id] = { ...toolCall.call, inputEnded: true }
return
}
case "tool-call": {
if (ctx.assistantMessage.summary) {
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
throw new Error(`Tool call not allowed while generating summary: ${value.name}`)
}
const toolCall = yield* ensureToolCall(value)
const input = toolInput(value.input)
const raw = toolCall.part.state.status === "pending" ? toolCall.part.state.raw : ""
if (!toolCall.call.inputEnded) {
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) {
yield* sync.run(SessionEvent.Tool.Input.Ended.Sync, {
sessionID: ctx.sessionID,
callID: value.id,
text: raw,
timestamp: DateTime.makeUnsafe(Date.now()),
})
}
}
const toolCall = yield* readToolCall(value.toolCallId)
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) {
yield* sync.run(SessionEvent.Tool.Called.Sync, {
sessionID: ctx.sessionID,
callID: value.toolCallId,
tool: value.toolName,
input: value.input,
callID: value.id,
tool: value.name,
input,
provider: {
executed: toolCall?.part.metadata?.providerExecuted === true,
executed: toolCall.part.metadata?.providerExecuted === true,
...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),
},
timestamp: DateTime.makeUnsafe(Date.now()),
})
}
yield* updateToolCall(value.toolCallId, (match) => ({
yield* updateToolCall(value.id, (match) => ({
...match,
tool: value.toolName,
state: {
...match.state,
status: "running",
input: value.input,
time: { start: Date.now() },
tool: value.name,
state:
match.state.status === "running"
? { ...match.state, input }
: {
status: "running",
input,
time: { start: Date.now() },
},
metadata: {
...match.metadata,
...value.providerMetadata,
...(match.metadata?.providerExecuted ? { providerExecuted: true } : {}),
},
metadata: match.metadata?.providerExecuted
? { ...value.providerMetadata, providerExecuted: true }
: value.providerMetadata,
}))
const parts = MessageV2.parts(ctx.assistantMessage.id)
@@ -371,9 +463,9 @@ export const layer: Layer.Layer<
!recentParts.every(
(part) =>
part.type === "tool" &&
part.tool === value.toolName &&
part.tool === value.name &&
part.state.status !== "pending" &&
JSON.stringify(part.state.input) === JSON.stringify(value.input),
JSON.stringify(part.state.input) === JSON.stringify(input),
)
) {
return
@@ -382,27 +474,19 @@ export const layer: Layer.Layer<
const agent = yield* agents.get(ctx.assistantMessage.agent)
yield* permission.ask({
permission: "doom_loop",
patterns: [value.toolName],
patterns: [value.name],
sessionID: ctx.assistantMessage.sessionID,
metadata: { tool: value.toolName, input: value.input },
always: [value.toolName],
metadata: { tool: value.name, input },
always: [value.name],
ruleset: agent.permission,
})
return
}
case "tool-result": {
const toolCall = yield* readToolCall(value.toolCallId)
const toolAttachments: MessageV2.FilePart[] = (
Array.isArray(value.output.attachments) ? value.output.attachments : []
).filter(
(attachment: unknown): attachment is MessageV2.FilePart =>
isRecord(attachment) &&
attachment.type === "file" &&
typeof attachment.mime === "string" &&
typeof attachment.url === "string",
)
const normalized = yield* Effect.forEach(toolAttachments, (attachment) =>
const toolCall = yield* readToolCall(value.id)
const rawOutput = toolResultOutput(value)
const normalized = yield* Effect.forEach(rawOutput.attachments ?? [], (attachment) =>
attachment.mime.startsWith("image/")
? image.normalize(attachment).pipe(Effect.exit)
: Effect.succeed(Exit.succeed<MessageV2.FilePart>(attachment)),
@@ -410,18 +494,18 @@ export const layer: Layer.Layer<
const omitted = normalized.filter(Exit.isFailure).length
const attachments = normalized.filter(Exit.isSuccess).map((item) => item.value)
const output = {
...value.output,
...rawOutput,
output:
omitted === 0
? value.output.output
: `${value.output.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the inline image size limit.]`,
attachments: attachments?.length ? attachments : undefined,
? rawOutput.output
: `${rawOutput.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the inline image size limit.]`,
attachments: attachments.length ? attachments : undefined,
}
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) {
yield* sync.run(SessionEvent.Tool.Success.Sync, {
sessionID: ctx.sessionID,
callID: value.toolCallId,
callID: value.id,
structured: output.metadata,
content: [
{
@@ -429,32 +513,32 @@ export const layer: Layer.Layer<
text: output.output,
},
...(output.attachments?.map((item: MessageV2.FilePart) => ({
type: "file",
type: "file" as const,
uri: item.url,
mime: item.mime,
name: item.filename,
})) ?? []),
],
provider: {
executed: toolCall?.part.metadata?.providerExecuted === true,
executed: value.providerExecuted === true || toolCall?.part.metadata?.providerExecuted === true,
},
timestamp: DateTime.makeUnsafe(Date.now()),
})
}
yield* completeToolCall(value.toolCallId, output)
yield* completeToolCall(value.id, output)
return
}
case "tool-error": {
const toolCall = yield* readToolCall(value.toolCallId)
const toolCall = yield* readToolCall(value.id)
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) {
yield* sync.run(SessionEvent.Tool.Failed.Sync, {
sessionID: ctx.sessionID,
callID: value.toolCallId,
callID: value.id,
error: {
type: "unknown",
message: errorMessage(value.error),
message: value.message,
},
provider: {
executed: toolCall?.part.metadata?.providerExecuted === true,
@@ -462,14 +546,14 @@ export const layer: Layer.Layer<
timestamp: DateTime.makeUnsafe(Date.now()),
})
}
yield* failToolCall(value.toolCallId, value.error)
yield* failToolCall(value.id, new Error(value.message))
return
}
case "error":
throw value.error
case "provider-error":
throw new Error(value.message)
case "start-step":
case "step-start":
if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
if (!ctx.assistantMessage.summary) {
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
@@ -496,19 +580,20 @@ export const layer: Layer.Layer<
})
return
case "finish-step": {
case "step-finish": {
const completedSnapshot = yield* snapshot.track()
const usage = Session.getUsage({
model: ctx.model,
usage: value.usage,
metadata: value.providerMetadata,
})
yield* Effect.forEach(Object.keys(ctx.reasoningMap), finishReasoning)
const usage = Session.getUsage({
model: ctx.model,
usage: value.usage ?? new Usage({}),
metadata: value.providerMetadata,
})
if (!ctx.assistantMessage.summary) {
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) {
yield* sync.run(SessionEvent.Step.Ended.Sync, {
sessionID: ctx.sessionID,
finish: value.finishReason,
finish: value.reason,
cost: usage.cost,
tokens: usage.tokens,
snapshot: completedSnapshot,
@@ -516,12 +601,12 @@ export const layer: Layer.Layer<
})
}
}
ctx.assistantMessage.finish = value.finishReason
ctx.assistantMessage.finish = value.reason
ctx.assistantMessage.cost += usage.cost
ctx.assistantMessage.tokens = usage.tokens
yield* session.updatePart({
id: PartID.ascending(),
reason: value.finishReason,
reason: value.reason,
snapshot: completedSnapshot,
messageID: ctx.assistantMessage.id,
sessionID: ctx.assistantMessage.sessionID,
@@ -584,7 +669,6 @@ export const layer: Layer.Layer<
case "text-delta":
if (!ctx.currentText) return
ctx.currentText.text += value.text
if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata
yield* session.updatePartDelta({
sessionID: ctx.currentText.sessionID,
messageID: ctx.currentText.messageID,
@@ -626,12 +710,9 @@ export const layer: Layer.Layer<
ctx.currentText = undefined
return
case "finish":
case "request-finish":
return
default:
slog.info("unhandled", { event: value.type, value })
return
}
})
@@ -733,6 +814,7 @@ export const layer: Layer.Layer<
yield* Effect.gen(function* () {
ctx.currentText = undefined
ctx.reasoningMap = {}
yield* status.set(ctx.sessionID, { type: "busy" })
const stream = llm.stream(streamInput)
yield* stream.pipe(
@@ -816,12 +898,12 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(LLM.defaultLayer),
Layer.provide(Permission.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(Image.defaultLayer),
Layer.provide(SessionSummary.defaultLayer),
Layer.provide(SessionStatus.defaultLayer),
Layer.provide(Image.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(Config.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
),
)
+1 -2
View File
@@ -38,7 +38,6 @@ 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()
@@ -111,7 +110,7 @@ export default [
const info = data.info
const row = db
.update(SessionTable)
.set({ time_updated: sql`${SessionTable.time_updated}`, ...toPartialRow(info as Session.Patch) })
.set(toPartialRow(info as Session.Patch))
.where(eq(SessionTable.id, data.sessionID))
.returning()
.get()
+7 -6
View File
@@ -60,6 +60,7 @@ import * as DateTime from "effect/DateTime"
import { eq } from "@/storage/db"
import * as Database from "@/storage/db"
import { SessionTable } from "./session.sql"
import { LLMEvent } from "@opencode-ai/llm"
// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -359,7 +360,7 @@ export const layer = Layer.effect(
messages: [{ role: "user", content: "Generate a title for this conversation:\n" }, ...msgs],
})
.pipe(
Stream.filter((e): e is Extract<LLM.Event, { type: "text-delta" }> => e.type === "text-delta"),
Stream.filter(LLMEvent.is.textDelta),
Stream.map((e) => e.text),
Stream.mkString,
Effect.orDie,
@@ -1083,8 +1084,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
})
const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) {
const agentName = input.agent
const ag = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo()
const agentName = input.agent || (yield* agents.defaultAgent())
const ag = yield* agents.get(agentName)
if (!ag) {
const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
@@ -1875,7 +1876,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })
throw error
}
const agentName = cmd.agent ?? input.agent
const agentName = cmd.agent ?? input.agent ?? (yield* agents.defaultAgent())
const raw = input.arguments.match(argsRegex) ?? []
const args = raw.map((arg) => arg.replace(quoteTrimRegex, ""))
@@ -1928,7 +1929,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
yield* getModel(taskModel.providerID, taskModel.modelID, input.sessionID)
const agent = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo()
const agent = yield* agents.get(agentName)
if (!agent) {
const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
@@ -1952,7 +1953,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
]
: [...templateParts, ...(input.parts ?? [])]
const userAgent = isSubtask ? (input.agent ?? (yield* agents.defaultInfo()).name) : agent.name
const userAgent = isSubtask ? (input.agent ?? (yield* agents.defaultAgent())) : agentName
const userModel = isSubtask
? input.model
? Provider.parseModel(input.model)
+5 -7
View File
@@ -3,7 +3,7 @@ import path from "path"
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { Decimal } from "decimal.js"
import { type ProviderMetadata, type LanguageModelUsage } from "ai"
import type { ProviderMetadata, Usage } from "@opencode-ai/llm"
import { Flag } from "@opencode-ai/core/flag/flag"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
@@ -373,21 +373,19 @@ export function plan(input: { slug: string; time: { created: number } }, instanc
return path.join(base, [input.time.created, input.slug].join("-") + ".md")
}
export const getUsage = (input: { model: Provider.Model; usage: LanguageModelUsage; metadata?: ProviderMetadata }) => {
export const getUsage = (input: { model: Provider.Model; usage: Usage; metadata?: ProviderMetadata }) => {
const safe = (value: number) => {
if (!Number.isFinite(value)) return 0
return Math.max(0, value)
}
const inputTokens = safe(input.usage.inputTokens ?? 0)
const outputTokens = safe(input.usage.outputTokens ?? 0)
const reasoningTokens = safe(input.usage.outputTokenDetails?.reasoningTokens ?? input.usage.reasoningTokens ?? 0)
const reasoningTokens = safe(input.usage.reasoningTokens ?? 0)
const cacheReadInputTokens = safe(
input.usage.inputTokenDetails?.cacheReadTokens ?? input.usage.cachedInputTokens ?? 0,
)
const cacheReadInputTokens = safe(input.usage.cacheReadInputTokens ?? 0)
const cacheWriteInputTokens = safe(
Number(
input.usage.inputTokenDetails?.cacheWriteTokens ??
input.usage.cacheWriteInputTokens ??
input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
// google-vertex-anthropic returns metadata under "vertex" key
// (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages')
@@ -0,0 +1,9 @@
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,85 +18,110 @@
* permissions are passed through, and Plan Mode's restrictions live on the
* agent, not the session.
*/
import { expect } from "bun:test"
import { test, expect, afterEach } 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"
const it = testEffect(Agent.defaultLayer)
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)))
}
// `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.
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"))
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"))
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")
},
})
})
it.instance("[#26514] explore subagent launched from plan mode also stays read-only", () =>
test("[#26514] explore subagent launched from plan mode also stays read-only", async () => {
// 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.
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()
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()
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")
},
})
})
it.instance(
"[#26514] custom user subagent launched from plan mode bypasses Plan Mode read-only",
test("[#26514] custom user subagent launched from plan mode bypasses Plan Mode read-only", async () => {
// 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`.
() =>
Effect.gen(function* () {
const planAgent = yield* Agent.Service.use((svc) => svc.get("plan"))
const my = yield* Agent.Service.use((svc) => svc.get("my_subagent"))
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"))
expect(planAgent).toBeDefined()
expect(my).toBeDefined()
@@ -111,15 +136,6 @@ it.instance(
// 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",
},
},
},
},
)
})
})
@@ -1,88 +1,88 @@
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { afterEach, describe, expect } from "bun:test"
import { Deferred, Effect, Layer, Schema } from "effect"
import { afterEach, describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Bus } from "../../src/bus"
import { BusEvent } from "../../src/bus/bus-event"
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
const TestEvent = BusEvent.define("test.integration", Schema.Struct({ value: Schema.Number }))
const it = testEffect(Layer.mergeAll(Bus.layer, CrossSpawnSpawner.defaultLayer))
function withInstance(directory: string, fn: () => Promise<void>) {
return WithInstance.provide({ directory, fn })
}
describe("Bus integration: acquireRelease subscriber pattern", () => {
afterEach(() => disposeAllInstances())
it.instance("subscriber via callback facade receives events and cleans up on unsub", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const received: number[] = []
const receivedTwo = yield* Deferred.make<void>()
test("subscriber via callback facade receives events and cleans up on unsub", async () => {
await using tmp = await tmpdir()
const received: number[] = []
const unsub = yield* bus.subscribeCallback(TestEvent, (evt) => {
await withInstance(tmp.path, async () => {
const unsub = Bus.subscribe(TestEvent, (evt) => {
received.push(evt.properties.value)
if (received.length === 2) Deferred.doneUnsafe(receivedTwo, Effect.void)
})
yield* bus.publish(TestEvent, { value: 1 })
yield* bus.publish(TestEvent, { value: 2 })
yield* Deferred.await(receivedTwo).pipe(Effect.timeout("2 seconds"))
await Bun.sleep(10)
await Bus.publish(TestEvent, { value: 1 })
await Bus.publish(TestEvent, { value: 2 })
await Bun.sleep(10)
expect(received).toEqual([1, 2])
yield* Effect.sync(unsub)
yield* bus.publish(TestEvent, { value: 3 })
yield* Effect.sleep("10 millis")
unsub()
await Bun.sleep(10)
await Bus.publish(TestEvent, { value: 3 })
await Bun.sleep(10)
expect(received).toEqual([1, 2])
}),
)
})
})
it.instance("subscribeAll receives events from multiple types", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const received: Array<{ type: string; value?: number }> = []
const OtherEvent = BusEvent.define("test.other", Schema.Struct({ value: Schema.Number }))
const receivedTwo = yield* Deferred.make<void>()
test("subscribeAll receives events from multiple types", async () => {
await using tmp = await tmpdir()
const received: Array<{ type: string; value?: number }> = []
yield* bus.subscribeAllCallback((evt) => {
const OtherEvent = BusEvent.define("test.other", Schema.Struct({ value: Schema.Number }))
await withInstance(tmp.path, async () => {
Bus.subscribeAll((evt) => {
received.push({ type: evt.type, value: evt.properties.value })
if (received.length === 2) Deferred.doneUnsafe(receivedTwo, Effect.void)
})
yield* bus.publish(TestEvent, { value: 10 })
yield* bus.publish(OtherEvent, { value: 20 })
yield* Deferred.await(receivedTwo).pipe(Effect.timeout("2 seconds"))
await Bun.sleep(10)
await Bus.publish(TestEvent, { value: 10 })
await Bus.publish(OtherEvent, { value: 20 })
await Bun.sleep(10)
})
expect(received).toEqual([
{ type: "test.integration", value: 10 },
{ type: "test.other", value: 20 },
])
}),
)
expect(received).toEqual([
{ type: "test.integration", value: 10 },
{ type: "test.other", value: 20 },
])
})
it.live("subscriber cleanup on instance disposal interrupts the stream", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const received: number[] = []
const seen = yield* Deferred.make<void>()
const disposed = yield* Deferred.make<void>()
test("subscriber cleanup on instance disposal interrupts the stream", async () => {
await using tmp = await tmpdir()
const received: number[] = []
let disposed = false
yield* Effect.gen(function* () {
const bus = yield* Bus.Service
yield* bus.subscribeAllCallback((evt) => {
if (evt.type === Bus.InstanceDisposed.type) {
Deferred.doneUnsafe(disposed, Effect.void)
return
}
received.push(evt.properties.value)
Deferred.doneUnsafe(seen, Effect.void)
})
yield* bus.publish(TestEvent, { value: 1 })
yield* Deferred.await(seen).pipe(Effect.timeout("2 seconds"))
}).pipe(provideInstance(dir))
await withInstance(tmp.path, async () => {
Bus.subscribeAll((evt) => {
if (evt.type === Bus.InstanceDisposed.type) {
disposed = true
return
}
received.push(evt.properties.value)
})
await Bun.sleep(10)
await Bus.publish(TestEvent, { value: 1 })
await Bun.sleep(10)
})
yield* Effect.promise(() => disposeAllInstances())
yield* Deferred.await(disposed).pipe(Effect.timeout("2 seconds"))
await disposeAllInstances()
await Bun.sleep(50)
expect(received).toEqual([1])
}),
)
expect(received).toEqual([1])
expect(disposed).toBe(true)
})
})
+148 -168
View File
@@ -1,240 +1,220 @@
import { afterEach, describe, expect } from "bun:test"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Deferred, Effect, Layer, Schema } from "effect"
import { afterEach, describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Bus } from "../../src/bus"
import { BusEvent } from "../../src/bus/bus-event"
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
const TestEvent = {
Ping: BusEvent.define("test.ping", Schema.Struct({ value: Schema.Number })),
Pong: BusEvent.define("test.pong", Schema.Struct({ message: Schema.String })),
}
const it = testEffect(Layer.mergeAll(Bus.layer, CrossSpawnSpawner.defaultLayer))
function withInstance(directory: string, fn: () => Promise<void>) {
return WithInstance.provide({ directory, fn })
}
describe("Bus", () => {
afterEach(() => disposeAllInstances())
describe("publish + subscribe", () => {
it.instance("subscriber is live immediately after subscribe returns", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const received: number[] = []
const done = yield* Deferred.make<void>()
test("subscriber is live immediately after subscribe returns", async () => {
await using tmp = await tmpdir()
const received: number[] = []
yield* bus.subscribeCallback(TestEvent.Ping, (evt) => {
await withInstance(tmp.path, async () => {
Bus.subscribe(TestEvent.Ping, (evt) => {
received.push(evt.properties.value)
Deferred.doneUnsafe(done, Effect.void)
})
yield* bus.publish(TestEvent.Ping, { value: 42 })
yield* Deferred.await(done).pipe(Effect.timeout("2 seconds"))
await Bus.publish(TestEvent.Ping, { value: 42 })
await Bun.sleep(10)
})
expect(received).toEqual([42])
}),
)
expect(received).toEqual([42])
})
it.instance("subscriber receives matching events", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const received: number[] = []
const done = yield* Deferred.make<void>()
test("subscriber receives matching events", async () => {
await using tmp = await tmpdir()
const received: number[] = []
yield* bus.subscribeCallback(TestEvent.Ping, (evt) => {
await withInstance(tmp.path, async () => {
Bus.subscribe(TestEvent.Ping, (evt) => {
received.push(evt.properties.value)
if (received.length === 2) Deferred.doneUnsafe(done, Effect.void)
})
yield* bus.publish(TestEvent.Ping, { value: 42 })
yield* bus.publish(TestEvent.Ping, { value: 99 })
yield* Deferred.await(done).pipe(Effect.timeout("2 seconds"))
// Give the subscriber fiber time to start consuming
await Bun.sleep(10)
await Bus.publish(TestEvent.Ping, { value: 42 })
await Bus.publish(TestEvent.Ping, { value: 99 })
// Give subscriber time to process
await Bun.sleep(10)
})
expect(received).toEqual([42, 99])
}),
)
expect(received).toEqual([42, 99])
})
it.instance("subscriber does not receive events of other types", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const pings: number[] = []
const done = yield* Deferred.make<void>()
test("subscriber does not receive events of other types", async () => {
await using tmp = await tmpdir()
const pings: number[] = []
yield* bus.subscribeCallback(TestEvent.Ping, (evt) => {
await withInstance(tmp.path, async () => {
Bus.subscribe(TestEvent.Ping, (evt) => {
pings.push(evt.properties.value)
Deferred.doneUnsafe(done, Effect.void)
})
yield* bus.publish(TestEvent.Pong, { message: "hello" })
yield* bus.publish(TestEvent.Ping, { value: 1 })
yield* Deferred.await(done).pipe(Effect.timeout("2 seconds"))
await Bun.sleep(10)
await Bus.publish(TestEvent.Pong, { message: "hello" })
await Bus.publish(TestEvent.Ping, { value: 1 })
await Bun.sleep(10)
})
expect(pings).toEqual([1])
}),
)
expect(pings).toEqual([1])
})
it.instance("publish with no subscribers does not throw", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
yield* bus.publish(TestEvent.Ping, { value: 1 })
}),
)
test("publish with no subscribers does not throw", async () => {
await using tmp = await tmpdir()
await withInstance(tmp.path, async () => {
await Bus.publish(TestEvent.Ping, { value: 1 })
})
})
})
describe("unsubscribe", () => {
it.instance("unsubscribe stops delivery", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const received: number[] = []
const first = yield* Deferred.make<void>()
test("unsubscribe stops delivery", async () => {
await using tmp = await tmpdir()
const received: number[] = []
const unsub = yield* bus.subscribeCallback(TestEvent.Ping, (evt) => {
await withInstance(tmp.path, async () => {
const unsub = Bus.subscribe(TestEvent.Ping, (evt) => {
received.push(evt.properties.value)
if (evt.properties.value === 1) Deferred.doneUnsafe(first, Effect.void)
})
yield* bus.publish(TestEvent.Ping, { value: 1 })
yield* Deferred.await(first).pipe(Effect.timeout("2 seconds"))
yield* Effect.sync(unsub)
yield* bus.publish(TestEvent.Ping, { value: 2 })
yield* Effect.sleep("10 millis")
await Bun.sleep(10)
await Bus.publish(TestEvent.Ping, { value: 1 })
await Bun.sleep(10)
unsub()
await Bun.sleep(10)
await Bus.publish(TestEvent.Ping, { value: 2 })
await Bun.sleep(10)
})
expect(received).toEqual([1])
}),
)
expect(received).toEqual([1])
})
})
describe("subscribeAll", () => {
it.instance("subscribeAll is live immediately after subscribe returns", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const received: string[] = []
const done = yield* Deferred.make<void>()
test("subscribeAll is live immediately after subscribe returns", async () => {
await using tmp = await tmpdir()
const received: string[] = []
yield* bus.subscribeAllCallback((evt) => {
await withInstance(tmp.path, async () => {
Bus.subscribeAll((evt) => {
received.push(evt.type)
Deferred.doneUnsafe(done, Effect.void)
})
yield* bus.publish(TestEvent.Ping, { value: 1 })
yield* Deferred.await(done).pipe(Effect.timeout("2 seconds"))
await Bus.publish(TestEvent.Ping, { value: 1 })
await Bun.sleep(10)
})
expect(received).toEqual(["test.ping"])
}),
)
expect(received).toEqual(["test.ping"])
})
it.instance("receives all event types", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const received: string[] = []
const done = yield* Deferred.make<void>()
test("receives all event types", async () => {
await using tmp = await tmpdir()
const received: string[] = []
yield* bus.subscribeAllCallback((evt) => {
await withInstance(tmp.path, async () => {
Bus.subscribeAll((evt) => {
received.push(evt.type)
if (received.length === 2) Deferred.doneUnsafe(done, Effect.void)
})
yield* bus.publish(TestEvent.Ping, { value: 1 })
yield* bus.publish(TestEvent.Pong, { message: "hi" })
yield* Deferred.await(done).pipe(Effect.timeout("2 seconds"))
await Bun.sleep(10)
await Bus.publish(TestEvent.Ping, { value: 1 })
await Bus.publish(TestEvent.Pong, { message: "hi" })
await Bun.sleep(10)
})
expect(received).toContain("test.ping")
expect(received).toContain("test.pong")
}),
)
expect(received).toContain("test.ping")
expect(received).toContain("test.pong")
})
})
describe("multiple subscribers", () => {
it.instance("all subscribers for same event type are called", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const a: number[] = []
const b: number[] = []
const doneA = yield* Deferred.make<void>()
const doneB = yield* Deferred.make<void>()
test("all subscribers for same event type are called", async () => {
await using tmp = await tmpdir()
const a: number[] = []
const b: number[] = []
yield* bus.subscribeCallback(TestEvent.Ping, (evt) => {
await withInstance(tmp.path, async () => {
Bus.subscribe(TestEvent.Ping, (evt) => {
a.push(evt.properties.value)
Deferred.doneUnsafe(doneA, Effect.void)
})
yield* bus.subscribeCallback(TestEvent.Ping, (evt) => {
Bus.subscribe(TestEvent.Ping, (evt) => {
b.push(evt.properties.value)
Deferred.doneUnsafe(doneB, Effect.void)
})
yield* bus.publish(TestEvent.Ping, { value: 7 })
yield* Deferred.await(doneA).pipe(Effect.timeout("2 seconds"))
yield* Deferred.await(doneB).pipe(Effect.timeout("2 seconds"))
await Bun.sleep(10)
await Bus.publish(TestEvent.Ping, { value: 7 })
await Bun.sleep(10)
})
expect(a).toEqual([7])
expect(b).toEqual([7])
}),
)
expect(a).toEqual([7])
expect(b).toEqual([7])
})
})
describe("instance isolation", () => {
it.live("events in one directory do not reach subscribers in another", () =>
Effect.gen(function* () {
const tmpA = yield* tmpdirScoped()
const tmpB = yield* tmpdirScoped()
const receivedA: number[] = []
const receivedB: number[] = []
const doneA = yield* Deferred.make<void>()
const doneB = yield* Deferred.make<void>()
test("events in one directory do not reach subscribers in another", async () => {
await using tmpA = await tmpdir()
await using tmpB = await tmpdir()
const receivedA: number[] = []
const receivedB: number[] = []
yield* Effect.gen(function* () {
const bus = yield* Bus.Service
yield* bus.subscribeCallback(TestEvent.Ping, (evt) => {
receivedA.push(evt.properties.value)
Deferred.doneUnsafe(doneA, Effect.void)
})
}).pipe(provideInstance(tmpA))
await withInstance(tmpA.path, async () => {
Bus.subscribe(TestEvent.Ping, (evt) => {
receivedA.push(evt.properties.value)
})
await Bun.sleep(10)
})
yield* Effect.gen(function* () {
const bus = yield* Bus.Service
yield* bus.subscribeCallback(TestEvent.Ping, (evt) => {
receivedB.push(evt.properties.value)
Deferred.doneUnsafe(doneB, Effect.void)
})
}).pipe(provideInstance(tmpB))
await withInstance(tmpB.path, async () => {
Bus.subscribe(TestEvent.Ping, (evt) => {
receivedB.push(evt.properties.value)
})
await Bun.sleep(10)
})
yield* Effect.gen(function* () {
const bus = yield* Bus.Service
yield* bus.publish(TestEvent.Ping, { value: 1 })
}).pipe(provideInstance(tmpA))
await withInstance(tmpA.path, async () => {
await Bus.publish(TestEvent.Ping, { value: 1 })
await Bun.sleep(10)
})
yield* Effect.gen(function* () {
const bus = yield* Bus.Service
yield* bus.publish(TestEvent.Ping, { value: 2 })
}).pipe(provideInstance(tmpB))
await withInstance(tmpB.path, async () => {
await Bus.publish(TestEvent.Ping, { value: 2 })
await Bun.sleep(10)
})
yield* Deferred.await(doneA).pipe(Effect.timeout("2 seconds"))
yield* Deferred.await(doneB).pipe(Effect.timeout("2 seconds"))
expect(receivedA).toEqual([1])
expect(receivedB).toEqual([2])
}),
)
expect(receivedA).toEqual([1])
expect(receivedB).toEqual([2])
})
})
describe("instance disposal", () => {
it.live("InstanceDisposed is delivered to wildcard subscribers before stream ends", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const received: string[] = []
const seen = yield* Deferred.make<void>()
const disposed = yield* Deferred.make<void>()
test("InstanceDisposed is delivered to wildcard subscribers before stream ends", async () => {
await using tmp = await tmpdir()
const received: string[] = []
yield* Effect.gen(function* () {
const bus = yield* Bus.Service
yield* bus.subscribeAllCallback((evt) => {
received.push(evt.type)
if (evt.type === TestEvent.Ping.type) Deferred.doneUnsafe(seen, Effect.void)
if (evt.type === Bus.InstanceDisposed.type) Deferred.doneUnsafe(disposed, Effect.void)
})
yield* bus.publish(TestEvent.Ping, { value: 1 })
yield* Deferred.await(seen).pipe(Effect.timeout("2 seconds"))
}).pipe(provideInstance(tmp))
await withInstance(tmp.path, async () => {
Bus.subscribeAll((evt) => {
received.push(evt.type)
})
await Bun.sleep(10)
await Bus.publish(TestEvent.Ping, { value: 1 })
await Bun.sleep(10)
})
yield* Effect.promise(disposeAllInstances)
yield* Deferred.await(disposed).pipe(Effect.timeout("2 seconds"))
// disposeAllInstances triggers the finalizer which publishes InstanceDisposed
await disposeAllInstances()
await Bun.sleep(50)
expect(received).toContain("test.ping")
expect(received).toContain(Bus.InstanceDisposed.type)
}),
)
expect(received).toContain("test.ping")
expect(received).toContain(Bus.InstanceDisposed.type)
})
})
})
-5
View File
@@ -1,7 +1,6 @@
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", () => {
@@ -16,8 +15,4 @@ 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("")
})
})
+17 -22
View File
@@ -3,11 +3,6 @@ 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 ---
@@ -157,7 +152,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* waitForState(runner, "Running")
yield* Effect.sleep("10 millis")
yield* runner.cancel
@@ -174,9 +169,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* waitForState(runner, "Running")
yield* Effect.sleep("10 millis")
const b = yield* runner.ensureRunning(Effect.succeed("y")).pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* Effect.sleep("10 millis")
yield* runner.cancel
@@ -194,7 +189,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* waitForState(runner, "Running")
yield* Effect.sleep("10 millis")
yield* runner.cancel
yield* Fiber.await(fiber)
@@ -220,7 +215,7 @@ describe("Runner", () => {
)
const a = yield* runner.ensureRunning(first).pipe(Effect.exit, Effect.forkChild)
yield* waitForState(runner, "Running")
yield* Effect.sleep("10 millis")
const stop = yield* runner.cancel.pipe(Effect.forkChild)
yield* Deferred.await(hit).pipe(Effect.timeout("250 millis"))
@@ -298,7 +293,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* waitForState(runner, "Shell")
yield* Effect.sleep("10 millis")
const exit = yield* runner.startShell(Effect.succeed("second")).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
@@ -319,7 +314,7 @@ describe("Runner", () => {
})
const sh = yield* runner.startShell(Effect.never.pipe(Effect.as("aborted"))).pipe(Effect.forkChild)
yield* waitForState(runner, "Shell")
yield* Effect.sleep("10 millis")
const exit = yield* runner.startShell(Effect.succeed("second")).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
@@ -338,7 +333,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* waitForState(runner, "Shell")
yield* Effect.sleep("10 millis")
const stop = yield* runner.cancel.pipe(Effect.forkChild)
const stopExit = yield* Fiber.await(stop).pipe(Effect.timeout("250 millis"))
@@ -385,11 +380,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* waitForState(runner, "Shell")
yield* Effect.sleep("10 millis")
expect(runner.state._tag).toBe("Shell")
const run = yield* runner.ensureRunning(Effect.succeed("run-result")).pipe(Effect.forkChild)
yield* waitForState(runner, "ShellThenRun")
yield* Effect.sleep("10 millis")
expect(runner.state._tag).toBe("ShellThenRun")
yield* Deferred.succeed(gate, undefined)
@@ -411,7 +406,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* waitForState(runner, "Shell")
yield* Effect.sleep("10 millis")
const work = Effect.gen(function* () {
yield* Ref.update(calls, (n) => n + 1)
@@ -419,7 +414,7 @@ describe("Runner", () => {
})
const a = yield* runner.ensureRunning(work).pipe(Effect.forkChild)
const b = yield* runner.ensureRunning(work).pipe(Effect.forkChild)
yield* waitForState(runner, "ShellThenRun")
yield* Effect.sleep("10 millis")
yield* Deferred.succeed(gate, undefined)
yield* Fiber.await(sh)
@@ -438,10 +433,10 @@ describe("Runner", () => {
const runner = Runner.make<string>(s)
const sh = yield* runner.startShell(Effect.never.pipe(Effect.as("aborted"))).pipe(Effect.forkChild)
yield* waitForState(runner, "Shell")
yield* Effect.sleep("10 millis")
const run = yield* runner.ensureRunning(Effect.succeed("y")).pipe(Effect.forkChild)
yield* waitForState(runner, "ShellThenRun")
yield* Effect.sleep("10 millis")
expect(runner.state._tag).toBe("ShellThenRun")
yield* runner.cancel
@@ -477,7 +472,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* waitForState(runner, "Running")
yield* Effect.sleep("10 millis")
yield* runner.cancel
yield* Fiber.await(fiber)
expect(yield* Ref.get(count)).toBeGreaterThanOrEqual(1)
@@ -507,7 +502,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* waitForState(runner, "Running")
yield* Effect.sleep("10 millis")
expect(runner.busy).toBe(true)
yield* Deferred.succeed(gate, undefined)
@@ -524,7 +519,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* waitForState(runner, "Shell")
yield* Effect.sleep("10 millis")
expect(runner.busy).toBe(true)
yield* Deferred.succeed(gate, undefined)
+51 -55
View File
@@ -3,71 +3,67 @@ 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 it =
process.platform === "win32"
? (await import("../lib/effect")).testEffect((await import("../../src/file")).File.defaultLayer)
: undefined
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
describe("file fsmonitor", () => {
if (!it) {
test.skip("status does not start fsmonitor for readonly git checks", () => {})
test.skip("read does not start fsmonitor for git diffs", () => {})
return
}
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")
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")
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")
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"))
const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow()
expect(before.exitCode).not.toBe(0)
const before = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(directory).quiet().nothrow())
expect(before.exitCode).not.toBe(0)
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await status()
},
})
yield* File.Service.use((svc) => svc.status())
const after = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow()
expect(after.exitCode).not.toBe(0)
})
const after = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(directory).quiet().nothrow())
expect(after.exitCode).not.toBe(0)
}),
{ git: true },
)
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")
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")
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")
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"))
const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow()
expect(before.exitCode).not.toBe(0)
const before = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(directory).quiet().nothrow())
expect(before.exitCode).not.toBe(0)
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await read("tracked.txt")
},
})
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 },
)
const after = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow()
expect(after.exitCode).not.toBe(0)
})
})
+162 -141
View File
@@ -1,55 +1,42 @@
import { expect, describe } from "bun:test"
import { Cause, Effect, Exit } from "effect"
import { test, expect, describe } from "bun:test"
import { Effect } from "effect"
import path from "path"
import fs from "fs/promises"
import { Filesystem } from "@/util/filesystem"
import { File } from "../../src/file"
import { InstanceState } from "../../src/effect/instance-state"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { containsPath } from "../../src/project/instance-context"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { provideInstance, tmpdir } from "../fixture/fixture"
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")
})
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)))
describe("Filesystem.contains", () => {
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("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("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 ../ 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 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("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("handles prefix collision edge cases", () =>
Effect.sync(() => {
expect(Filesystem.contains("/project", "/project-other/file")).toBe(false)
expect(Filesystem.contains("/project", "/projectfile")).toBe(false)
}),
)
test("handles prefix collision edge cases", () => {
expect(Filesystem.contains("/project", "/project-other/file")).toBe(false)
expect(Filesystem.contains("/project", "/projectfile")).toBe(false)
})
})
/*
@@ -62,124 +49,158 @@ describe("Filesystem.contains", () => {
* This is a SEPARATE code path from ReadTool, which has its own checks.
*/
describe("File.read path traversal protection", () => {
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"))
}),
)
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 deeply nested traversal", () =>
Effect.gen(function* () {
yield* expectAccessDenied(read("src/nested/../../../../../../../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("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"))
test("rejects deeply nested traversal", async () => {
await using tmp = await tmpdir()
const result = yield* read("valid.txt")
expect(result.content).toBe("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")
},
})
})
})
describe("File.list path traversal protection", () => {
it.instance("rejects ../ traversal attempting to list /etc", () =>
Effect.gen(function* () {
yield* expectAccessDenied(list("../../../etc"))
}),
)
test("rejects ../ traversal attempting to list /etc", async () => {
await using tmp = await tmpdir()
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"))
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await expect(list("../../../etc")).rejects.toThrow("Access denied: path escapes project directory")
},
})
})
const result = yield* list("subdir")
expect(Array.isArray(result)).toBe(true)
}),
)
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)
},
})
})
})
describe("containsPath", () => {
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 },
)
test("returns true for path inside directory", async () => {
await using tmp = await tmpdir({ git: 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 }
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)
},
})
})
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(test.directory, ".opencode", "state"), ctx)).toBe(true)
expect(containsPath(path.join(tmp.path, ".opencode", "state"), Instance.current)).toBe(true)
// sibling package should also be accessible
expect(containsPath(path.join(test.directory, "packages", "other", "file.ts"), ctx)).toBe(true)
expect(containsPath(path.join(tmp.path, "packages", "other", "file.ts"), Instance.current)).toBe(true)
// worktree root itself
expect(containsPath(test.directory, ctx)).toBe(true)
}),
{ git: true },
)
expect(containsPath(tmp.path, Instance.current)).toBe(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 },
)
test("returns false for path outside both directory and worktree", async () => {
await using tmp = await tmpdir({ git: true })
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 },
)
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(
"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 },
)
test("returns false for path with .. escaping worktree", async () => {
await using tmp = await tmpdir({ git: true })
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)
}),
)
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)
},
})
})
})
+194 -200
View File
@@ -1,220 +1,214 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } 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 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
}),
)
const run = <A>(effect: Effect.Effect<A, unknown, Ripgrep.Service>) =>
effect.pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
describe("file.ripgrep", () => {
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"), "{}")
}),
)
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"), "{}")
},
})
const files = yield* collectFiles({ cwd: dir })
expect(files.includes("visible.txt")).toBe(true)
expect(files.includes(path.join(".opencode", "thing.json"))).toBe(true)
}),
)
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)
})
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"), "{}")
}),
)
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"), "{}")
},
})
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)
}),
)
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)
})
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"))
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")
},
})
const result = yield* Ripgrep.Service.use((rg) => rg.search({ cwd: dir, pattern: "needle" }))
expect(result.partial).toBe(false)
expect(result.items).toEqual([])
}),
)
const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle" })))
expect(result.partial).toBe(false)
expect(result.items).toEqual([])
})
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")
}),
)
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")
},
})
const result = yield* Ripgrep.Service.use((rg) => rg.search({ cwd: dir, pattern: "needle" }))
expect(result.partial).toBe(false)
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" })))
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")
}),
)
} finally {
if (prev === undefined) delete process.env["RIPGREP_CONFIG_PATH"]
else process.env["RIPGREP_CONFIG_PATH"] = prev
}
})
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")
}),
)
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")
},
})
const result = yield* Ripgrep.Service.use((rg) => rg.search({ cwd: dir, pattern: "needle", glob: ["*.ts"] }))
expect(result.partial).toBe(false)
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" })))
expect(result.items).toHaveLength(1)
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)
}),
)
} finally {
if (prev === undefined) delete process.env["RIPGREP_CONFIG_PATH"]
else process.env["RIPGREP_CONFIG_PATH"] = prev
}
})
})
+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 ${dir}`)
yield* git("commit", "--allow-empty", "-m", "root commit")
}
if (options?.config) {
+98 -101
View File
@@ -1,70 +1,71 @@
import { $ } from "bun"
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect } from "effect"
import { ManagedRuntime } 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)
const scopedTmpdir = (options?: Parameters<typeof tmpdir>[0]) =>
Effect.acquireRelease(
Effect.promise(() => tmpdir(options)),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
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()
}
}
describe("Git", () => {
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)
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)))
expect(branch).toBeDefined()
expect(typeof branch).toBe("string")
}),
)
})
})
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)
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)))
expect(branch).toBeUndefined()
}),
)
})
})
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)
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)))
expect(branch).toBeUndefined()
}),
)
})
})
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)
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)))
expect(branch?.name).toBe("trunk")
expect(branch?.ref).toBe("trunk")
}),
)
})
})
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)
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)))
expect(status).toEqual(
expect.arrayContaining([
expect.objectContaining({
@@ -73,24 +74,23 @@ describe("Git", () => {
}),
]),
)
}),
)
})
})
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"))
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")
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"),
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"))),
])
expect(base).toBeTruthy()
@@ -111,24 +111,23 @@ describe("Git", () => {
}),
]),
)
}),
)
})
})
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"))
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")
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 }),
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 }))),
])
expect(patch.truncated).toBe(false)
@@ -141,18 +140,17 @@ describe("Git", () => {
expect(all.text).toContain("+new")
expect(capped.truncated).toBe(true)
expect(capped.text).toBe("")
}),
)
})
})
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"))
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")
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),
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))),
])
expect(patch.truncated).toBe(false)
@@ -160,19 +158,18 @@ describe("Git", () => {
expect(patch.text).toContain("+one")
expect(patch.text).toContain("+two")
expect(stat).toEqual(expect.objectContaining({ file: weird, additions: 2, deletions: 0 }))
}),
)
})
})
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())
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()
const git = yield* Git.Service
const text = yield* git.show(tmp.path, "HEAD", "bin.dat")
await withGit(async (rt) => {
const text = await rt.runPromise(Git.Service.use((git) => git.show(tmp.path, "HEAD", "bin.dat")))
expect(text).toBe("")
}),
)
})
})
})
@@ -1,19 +1,15 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { pathToFileURL } from "url"
import { Effect, Layer } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture"
import { provideTestInstance, tmpdir } from "../fixture/fixture"
import { ProviderAuth } from "@/provider/auth"
import { ProviderID } from "../../src/provider/schema"
import { Plugin } from "@/plugin"
import { Auth } from "@/auth"
import { Bus } from "@/bus"
import { TestConfig } from "../fixture/config"
import { testEffect } from "../lib/effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer))
function layer(directory: string, plugins: string[]) {
return ProviderAuth.layer.pipe(
@@ -41,15 +37,13 @@ function layer(directory: string, plugins: string[]) {
}
describe("plugin.auth-override", () => {
it.instance(
"user plugin overrides built-in github-copilot auth",
() =>
Effect.gen(function* () {
const tmp = yield* TestInstance
const fs = yield* AppFileSystem.Service
const pluginDir = path.join(tmp.directory, ".opencode", "plugin")
test("user plugin overrides built-in github-copilot auth", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const pluginDir = path.join(dir, ".opencode", "plugin")
await fs.mkdir(pluginDir, { recursive: true })
yield* fs.writeWithDirs(
await Bun.write(
path.join(pluginDir, "custom-copilot-auth.ts"),
[
"export default {",
@@ -67,26 +61,37 @@ describe("plugin.auth-override", () => {
"",
].join("\n"),
)
},
})
const plain = yield* tmpdirScoped({ git: true })
const plugin = pathToFileURL(path.join(pluginDir, "custom-copilot-auth.ts")).href
const methods = yield* ProviderAuth.Service.use((svc) => svc.methods()).pipe(
Effect.provide(layer(tmp.directory, [plugin])),
)
const plainMethods = yield* ProviderAuth.Service.use((svc) => svc.methods()).pipe(
Effect.provide(layer(plain, [])),
provideInstance(plain),
)
await using plain = await tmpdir()
const copilot = methods[ProviderID.make("github-copilot")]
expect(copilot).toBeDefined()
expect(copilot.length).toBe(1)
expect(copilot[0].label).toBe("Test Override Auth")
expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth")
const plugin = pathToFileURL(path.join(tmp.path, ".opencode", "plugin", "custom-copilot-auth.ts")).href
const [methods, plainMethods] = await Promise.all([
provideTestInstance({
directory: tmp.path,
fn: async () => {
return Effect.runPromise(
ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(layer(tmp.path, [plugin]))),
)
},
}),
{ git: true },
30000,
)
provideTestInstance({
directory: plain.path,
fn: async () => {
return Effect.runPromise(
ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(layer(plain.path, []))),
)
},
}),
])
const copilot = methods[ProviderID.make("github-copilot")]
expect(copilot).toBeDefined()
expect(copilot.length).toBe(1)
expect(copilot[0].label).toBe("Test Override Auth")
expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth")
}, 30000)
})
const file = path.join(import.meta.dir, "../../src/plugin/index.ts")
File diff suppressed because it is too large Load Diff
+35 -34
View File
@@ -1,12 +1,13 @@
import { afterEach, describe, expect } from "bun:test"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Deferred, Effect, Fiber, Layer } from "effect"
import { Effect, Fiber, Layer } from "effect"
import { InstanceRef } from "../../src/effect/instance-ref"
import { registerDisposer } from "../../src/effect/instance-registry"
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { InstanceStore } from "../../src/project/instance-store"
import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture"
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
let bootstrapRun: Effect.Effect<void> = Effect.void
@@ -74,18 +75,18 @@ describe("InstanceStore", () => {
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore.Service
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const started = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
let initialized = 0
bootstrapRun = Effect.gen(function* () {
bootstrapRun = Effect.promise(async () => {
initialized++
yield* Deferred.succeed(started, undefined)
yield* Deferred.await(release)
started.resolve()
await release.promise
})
const first = yield* store.load({ directory: dir }).pipe(Effect.forkScoped)
yield* Deferred.await(started)
yield* Effect.promise(() => started.promise)
bootstrapRun = Effect.sync(() => {
initialized++
@@ -93,7 +94,7 @@ describe("InstanceStore", () => {
const second = yield* store.load({ directory: dir }).pipe(Effect.forkScoped)
expect(initialized).toBe(1)
yield* Deferred.succeed(release, undefined)
release.resolve()
const [firstCtx, secondCtx] = yield* Effect.all([Fiber.join(first), Fiber.join(second)])
expect(secondCtx).toBe(firstCtx)
@@ -146,8 +147,8 @@ describe("InstanceStore", () => {
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore.Service
const reloading = yield* Deferred.make<void>()
const releaseReload = yield* Deferred.make<void>()
const reloading = Promise.withResolvers<void>()
const releaseReload = Promise.withResolvers<void>()
const disposed: Array<string> = []
const off = registerDisposer(async (directory) => {
disposed.push(directory)
@@ -155,15 +156,15 @@ describe("InstanceStore", () => {
yield* Effect.addFinalizer(() => Effect.sync(off))
const first = yield* store.load({ directory: dir })
bootstrapRun = Effect.gen(function* () {
yield* Deferred.succeed(reloading, undefined)
yield* Deferred.await(releaseReload)
bootstrapRun = Effect.promise(async () => {
reloading.resolve()
await releaseReload.promise
})
const reload = yield* store.reload({ directory: dir }).pipe(Effect.forkScoped)
yield* Deferred.await(reloading)
yield* Effect.promise(() => reloading.promise)
const staleDispose = yield* store.dispose(first).pipe(Effect.forkScoped)
yield* Deferred.succeed(releaseReload, undefined)
releaseReload.resolve()
const second = yield* Fiber.join(reload)
yield* Fiber.join(staleDispose)
@@ -177,23 +178,23 @@ describe("InstanceStore", () => {
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore.Service
const disposing = yield* Deferred.make<void>()
const releaseDispose = yield* Deferred.make<void>()
const disposing = Promise.withResolvers<void>()
const releaseDispose = Promise.withResolvers<void>()
const disposed: Array<string> = []
const off = registerDisposer(async (directory) => {
disposed.push(directory)
Deferred.doneUnsafe(disposing, Effect.void)
await Effect.runPromise(Deferred.await(releaseDispose))
disposing.resolve()
await releaseDispose.promise
})
yield* Effect.addFinalizer(() => Effect.sync(off))
yield* store.load({ directory: dir })
const first = yield* store.disposeAll().pipe(Effect.forkScoped)
yield* Deferred.await(disposing)
yield* Effect.promise(() => disposing.promise)
const second = yield* store.disposeAll().pipe(Effect.forkScoped)
expect(disposed).toEqual([dir])
yield* Deferred.succeed(releaseDispose, undefined)
releaseDispose.resolve()
yield* Effect.all([Fiber.join(first), Fiber.join(second)])
expect(disposed).toEqual([dir])
}),
@@ -220,19 +221,19 @@ describe("InstanceStore", () => {
}),
)
it.instance(
"provides legacy Promise callers with instance ALS",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const ctx = yield* InstanceRef
if (!ctx) throw new Error("InstanceRef not provided")
it.live("provides legacy Promise callers with instance ALS", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const directory = yield* Effect.promise(() => Promise.resolve(Instance.restore(ctx, () => Instance.directory)))
const directory = yield* Effect.promise(() =>
WithInstance.provide({
directory: dir,
fn: () => Instance.directory,
}),
)
expect(directory).toBe(test.directory)
expect(() => Instance.current).toThrow()
}),
{ git: true },
expect(directory).toBe(dir)
expect(() => Instance.current).toThrow()
}),
)
})
File diff suppressed because it is too large Load Diff
@@ -582,52 +582,64 @@ it.instance(
},
)
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",
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",
},
},
},
},
},
},
},
}),
)
},
},
)
})
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")
},
})
})
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",
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"],
},
},
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({
@@ -2,6 +2,7 @@ import { afterEach, expect } from "bun:test"
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
import { Question } from "../../src/question"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { InstanceRuntime } from "../../src/project/instance-runtime"
import { QuestionID } from "../../src/question/schema"
import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture"
@@ -397,8 +398,9 @@ it.live("pending question rejects on instance dispose", () =>
}).pipe(provideInstance(dir), Effect.forkScoped)
expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1)
const ctx = yield* Effect.sync(() => Instance.current).pipe(provideInstance(dir))
yield* Effect.promise(() => InstanceRuntime.disposeInstance(ctx))
yield* Effect.promise(() =>
WithInstance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }),
)
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
+175 -161
View File
@@ -1,25 +1,33 @@
import { afterEach, describe, expect } from "bun:test"
import { afterEach, describe, expect, test } 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, provideInstance, TestInstance } from "../fixture/fixture"
import { disposeAllInstances, tmpdir } 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)
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)),
)
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)))
},
}
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
@@ -27,199 +35,205 @@ afterEach(async () => {
})
describe("session.list", () => {
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 }))
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 })
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")),
)
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const root = await svc.create({ title: "root" })
const ids = (yield* SessionNs.Service.use((session) => session.list())).map((session) => session.id)
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)
expect(ids).toContain(root.id)
expect(ids).toContain(parent.id)
expect(ids).toContain(current.id)
expect(ids).toContain(sibling.id)
}),
{ git: 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 }))
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 })
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")),
)
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const root = await svc.create({ title: "root" })
const ids = (yield* SessionNs.Service.use((session) =>
session.list({ directory: path.join(test.directory, "packages", "opencode") }),
)).map((session) => session.id)
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)
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 },
)
},
})
})
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 }))
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 })
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")),
)
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 pathIDs = (yield* SessionNs.Service.use((session) =>
session.list({
directory: path.join(test.directory, "packages", "app"),
const pathIDs = (
await svc.list({
directory: path.join(tmp.path, "packages", "app"),
path: "packages/opencode/src",
}),
)).map((session) => session.id)
})
).map((s) => s.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 },
)
},
})
})
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 }))
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 })
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")),
)
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" }),
})
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(),
),
)
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())
const pathIDs = (yield* SessionNs.Service.use((session) =>
session.list({
directory: path.join(test.directory, "packages", "opencode", "src"),
const pathIDs = (
await svc.list({
directory: path.join(tmp.path, "packages", "opencode", "src"),
path: "packages/opencode/src",
}),
)).map((session) => session.id)
})
).map((s) => s.id)
expect(pathIDs).toContain(current.id)
expect(pathIDs).not.toContain(sibling.id)
}),
{ git: true },
)
},
})
})
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 })
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 })
const sessions = yield* SessionNs.Service.use((session) => session.list({ roots: true }))
const ids = sessions.map((session) => session.id)
const sessions = await svc.list({ roots: true })
const ids = sessions.map((s) => s.id)
expect(ids).toContain(root.id)
expect(ids).not.toContain(child.id)
}),
{ git: true },
)
},
})
})
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 }))
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 })
expect(sessions.length).toBe(0)
}),
{ git: true },
)
},
})
})
it.instance(
"filters by search term",
() =>
Effect.gen(function* () {
yield* withSession({ title: "unique-search-term-abc" })
yield* withSession({ title: "other-session-xyz" })
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" })
const sessions = yield* SessionNs.Service.use((session) => session.list({ search: "unique-search" }))
const titles = sessions.map((session) => session.title)
const sessions = await svc.list({ search: "unique-search" })
const titles = sessions.map((s) => s.title)
expect(titles).toContain("unique-search-term-abc")
expect(titles).not.toContain("other-session-xyz")
}),
{ git: true },
)
},
})
})
it.instance(
"respects limit parameter",
() =>
Effect.gen(function* () {
yield* withSession({ title: "session-1" })
yield* withSession({ title: "session-2" })
yield* withSession({ title: "session-3" })
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" })
const sessions = yield* SessionNs.Service.use((session) => session.list({ limit: 2 }))
const sessions = await svc.list({ limit: 2 })
expect(sessions.length).toBe(2)
}),
{ git: true },
)
},
})
})
})
+45 -230
View File
@@ -28,6 +28,7 @@ import { testEffect } from "../lib/effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { TestConfig } from "../fixture/config"
import { SyncEvent } from "@/sync"
import { LLMEvent, Usage } from "@opencode-ai/llm"
void Log.init({ print: false })
@@ -45,6 +46,10 @@ const ref = {
modelID: ModelID.make("test-model"),
}
const usage = (input: ConstructorParameters<typeof Usage>[0]) => new Usage(input)
const basicUsage = () => usage({ inputTokens: 1, outputTokens: 1, totalTokens: 2 })
afterEach(() => {
mock.restore()
})
@@ -289,11 +294,11 @@ function readCompactionPart(sessionID: SessionID) {
function llm() {
const queue: Array<
Stream.Stream<LLM.Event, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown>)
Stream.Stream<LLMEvent, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLMEvent, unknown>)
> = []
return {
push(stream: Stream.Stream<LLM.Event, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown>)) {
push(stream: Stream.Stream<LLMEvent, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLMEvent, unknown>)) {
queue.push(stream)
},
layer: Layer.succeed(
@@ -312,54 +317,22 @@ function llm() {
function reply(
text: string,
capture?: (input: LLM.StreamInput) => void,
): (input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown> {
): (input: LLM.StreamInput) => Stream.Stream<LLMEvent, unknown> {
return (input) => {
capture?.(input)
return Stream.make(
{ type: "start" } satisfies LLM.Event,
{ type: "text-start", id: "txt-0" } satisfies LLM.Event,
{ type: "text-delta", id: "txt-0", delta: text, text } as LLM.Event,
{ type: "text-end", id: "txt-0" } satisfies LLM.Event,
{
type: "finish-step",
finishReason: "stop",
rawFinishReason: "stop",
response: { id: "res", modelId: "test-model", timestamp: new Date() },
providerMetadata: undefined,
usage: {
inputTokens: 1,
outputTokens: 1,
totalTokens: 2,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
} satisfies LLM.Event,
{
type: "finish",
finishReason: "stop",
rawFinishReason: "stop",
totalUsage: {
inputTokens: 1,
outputTokens: 1,
totalTokens: 2,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
} satisfies LLM.Event,
LLMEvent.textStart({ id: "txt-0" }),
LLMEvent.textDelta({ id: "txt-0", text }),
LLMEvent.textEnd({ id: "txt-0" }),
LLMEvent.stepFinish({
index: 0,
reason: "stop",
usage: basicUsage(),
}),
LLMEvent.requestFinish({
reason: "stop",
usage: basicUsage(),
}),
)
}
}
@@ -1198,7 +1171,7 @@ describe("session.compaction.process", () => {
Stream.fromAsyncIterable(
{
async *[Symbol.asyncIterator]() {
yield { type: "start" } as LLM.Event
yield LLMEvent.stepStart({ index: 0 })
throw new APICallError({
message: "boom",
url: "https://example.com/v1/chat/completions",
@@ -1290,49 +1263,16 @@ describe("session.compaction.process", () => {
const stub = llm()
stub.push(
Stream.make(
{ type: "start" } satisfies LLM.Event,
{ type: "tool-input-start", id: "call-1", toolName: "_noop" } satisfies LLM.Event,
{ type: "tool-call", toolCallId: "call-1", toolName: "_noop", input: {} } satisfies LLM.Event,
{
type: "finish-step",
finishReason: "tool-calls",
rawFinishReason: "tool_calls",
response: { id: "res", modelId: "test-model", timestamp: new Date() },
providerMetadata: undefined,
usage: {
inputTokens: 1,
outputTokens: 1,
totalTokens: 2,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
} satisfies LLM.Event,
{
type: "finish",
finishReason: "tool-calls",
rawFinishReason: "tool_calls",
totalUsage: {
inputTokens: 1,
outputTokens: 1,
totalTokens: 2,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
} satisfies LLM.Event,
LLMEvent.toolCall({ id: "call-1", name: "_noop", input: {} }),
LLMEvent.stepFinish({
index: 0,
reason: "tool-calls",
usage: basicUsage(),
}),
LLMEvent.requestFinish({
reason: "tool-calls",
usage: basicUsage(),
}),
),
)
return Effect.gen(function* () {
@@ -1543,20 +1483,7 @@ describe("SessionNs.getUsage", () => {
const model = createModel({ context: 100_000, output: 32_000 })
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500 }),
})
expect(result.tokens.input).toBe(1000)
@@ -1570,20 +1497,7 @@ describe("SessionNs.getUsage", () => {
const model = createModel({ context: 100_000, output: 32_000 })
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
inputTokenDetails: {
noCacheTokens: 800,
cacheReadTokens: 200,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cacheReadInputTokens: 200 }),
})
expect(result.tokens.input).toBe(800)
@@ -1594,20 +1508,7 @@ describe("SessionNs.getUsage", () => {
const model = createModel({ context: 100_000, output: 32_000 })
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500 }),
metadata: {
anthropic: {
cacheCreationInputTokens: 300,
@@ -1623,20 +1524,7 @@ describe("SessionNs.getUsage", () => {
// AI SDK v6 normalizes inputTokens to include cached tokens for all providers
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
inputTokenDetails: {
noCacheTokens: 800,
cacheReadTokens: 200,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cacheReadInputTokens: 200 }),
metadata: {
anthropic: {},
},
@@ -1650,20 +1538,7 @@ describe("SessionNs.getUsage", () => {
const model = createModel({ context: 100_000, output: 32_000 })
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: 400,
reasoningTokens: 100,
},
},
usage: usage({ inputTokens: 1000, outputTokens: 500, reasoningTokens: 100, totalTokens: 1500 }),
})
expect(result.tokens.input).toBe(1000)
@@ -1684,20 +1559,7 @@ describe("SessionNs.getUsage", () => {
})
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 0,
outputTokens: 1_000_000,
totalTokens: 1_000_000,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: 750_000,
reasoningTokens: 250_000,
},
},
usage: usage({ inputTokens: 0, outputTokens: 1_000_000, reasoningTokens: 250_000, totalTokens: 1_000_000 }),
})
expect(result.tokens.output).toBe(750_000)
@@ -1709,20 +1571,7 @@ describe("SessionNs.getUsage", () => {
const model = createModel({ context: 100_000, output: 32_000 })
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
usage: usage({ inputTokens: 0, outputTokens: 0, totalTokens: 0 }),
})
expect(result.tokens.input).toBe(0)
@@ -1745,20 +1594,7 @@ describe("SessionNs.getUsage", () => {
})
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 1_000_000,
outputTokens: 100_000,
totalTokens: 1_100_000,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
usage: usage({ inputTokens: 1_000_000, outputTokens: 100_000, totalTokens: 1_100_000 }),
})
expect(result.cost).toBe(3 + 1.5)
@@ -1769,24 +1605,16 @@ describe("SessionNs.getUsage", () => {
(npm) => {
const model = createModel({ context: 100_000, output: 32_000, npm })
// AI SDK v6: inputTokens includes cached tokens for all providers
const usage = {
const item = usage({
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
inputTokenDetails: {
noCacheTokens: 800,
cacheReadTokens: 200,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
}
cacheReadInputTokens: 200,
})
if (npm === "@ai-sdk/amazon-bedrock") {
const result = SessionNs.getUsage({
model,
usage,
usage: item,
metadata: {
bedrock: {
usage: {
@@ -1807,7 +1635,7 @@ describe("SessionNs.getUsage", () => {
const result = SessionNs.getUsage({
model,
usage,
usage: item,
metadata: {
anthropic: {
cacheCreationInputTokens: 300,
@@ -1828,20 +1656,7 @@ describe("SessionNs.getUsage", () => {
const model = createModel({ context: 100_000, output: 32_000, npm: "@ai-sdk/google-vertex/anthropic" })
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
inputTokenDetails: {
noCacheTokens: 800,
cacheReadTokens: 200,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cacheReadInputTokens: 200 }),
metadata: {
vertex: {
cacheCreationInputTokens: 300,
@@ -0,0 +1,262 @@
import { describe, expect, test } from "bun:test"
import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
import { jsonSchema, tool, type ModelMessage } from "ai"
import { Effect } from "effect"
import { LLMNative } from "@/session/llm-native"
import type { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"
const baseModel: Provider.Model = {
id: ModelID.make("gpt-5-mini"),
providerID: ProviderID.make("openai"),
api: {
id: "gpt-5-mini",
url: "https://api.openai.com/v1",
npm: "@ai-sdk/openai",
},
name: "GPT-5 Mini",
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: {
text: true,
audio: false,
image: true,
video: false,
pdf: false,
},
output: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
interleaved: false,
},
cost: {
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
},
limit: {
context: 128_000,
input: 128_000,
output: 32_000,
},
status: "active",
options: {},
headers: {
"x-model": "model-header",
},
release_date: "2026-01-01",
}
describe("session.llm-native.request", () => {
test("maps normalized stream inputs to a native LLM request", () => {
const messages: ModelMessage[] = [
{
role: "system",
content: "system from messages",
},
{
role: "user",
content: [
{ type: "text", text: "hello", providerOptions: { openai: { cacheControl: { type: "ephemeral" } } } },
{ type: "file", mediaType: "image/png", filename: "img.png", data: "data:image/png;base64,Zm9v" },
],
},
{
role: "assistant",
content: [
{ type: "reasoning", text: "thinking", providerOptions: { openai: { encryptedContent: "secret" } } },
{ type: "text", text: "I'll run it" },
{
type: "tool-call",
toolCallId: "call-1",
toolName: "bash",
input: { command: "ls" },
providerOptions: { openai: { itemId: "item-1" } },
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call-1",
toolName: "bash",
output: { type: "text", value: "ok" },
providerOptions: { openai: { outputId: "output-1" } },
},
],
},
]
const request = LLMNative.request({
model: baseModel,
system: ["agent system"],
messages,
tools: {
bash: tool({
description: "Run a shell command",
inputSchema: jsonSchema({
type: "object",
properties: {
command: { type: "string" },
},
required: ["command"],
}),
}),
},
toolChoice: "required",
temperature: 0.2,
topP: 0.9,
topK: 40,
maxOutputTokens: 1024,
providerOptions: { openai: { store: false } },
headers: { "x-request": "request-header" },
})
expect(request.model).toMatchObject({
id: "gpt-5-mini",
provider: "openai",
route: "openai-responses",
baseURL: "https://api.openai.com/v1",
headers: {
"x-model": "model-header",
"x-request": "request-header",
},
limits: {
context: 128_000,
output: 32_000,
},
})
expect(request.system).toEqual([
{ type: "text", text: "agent system" },
{ type: "text", text: "system from messages" },
])
expect(request.generation).toMatchObject({
temperature: 0.2,
topP: 0.9,
topK: 40,
maxTokens: 1024,
})
expect(request.providerOptions).toEqual({ openai: { store: false } })
expect(request.toolChoice).toMatchObject({ type: "required" })
expect(request.tools).toMatchObject([
{
name: "bash",
description: "Run a shell command",
inputSchema: {
type: "object",
properties: {
command: { type: "string" },
},
required: ["command"],
},
},
])
expect(request.messages).toMatchObject([
{
role: "user",
content: [
{ type: "text", text: "hello", providerMetadata: { openai: { cacheControl: { type: "ephemeral" } } } },
{ type: "media", mediaType: "image/png", filename: "img.png", data: "data:image/png;base64,Zm9v" },
],
},
{
role: "assistant",
content: [
{ type: "reasoning", text: "thinking", providerMetadata: { openai: { encryptedContent: "secret" } } },
{ type: "text", text: "I'll run it" },
{
type: "tool-call",
id: "call-1",
name: "bash",
input: { command: "ls" },
providerMetadata: { openai: { itemId: "item-1" } },
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
id: "call-1",
name: "bash",
result: { type: "text", value: "ok" },
providerMetadata: { openai: { outputId: "output-1" } },
},
],
},
])
})
test("selects native routes from existing provider packages", () => {
expect(
LLMNative.model({ ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/anthropic" } }),
).toMatchObject({
route: "anthropic-messages",
baseURL: "https://api.anthropic.com/v1",
})
expect(LLMNative.model({ ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/google" } })).toMatchObject({
route: "gemini",
baseURL: "https://generativelanguage.googleapis.com/v1beta",
})
expect(
LLMNative.model({ ...baseModel, api: { ...baseModel.api, npm: "@ai-sdk/openai-compatible" } }),
).toMatchObject({
route: "openai-compatible-chat",
baseURL: "https://api.openai.com/v1",
})
expect(
LLMNative.model({ ...baseModel, api: { ...baseModel.api, url: "", npm: "@openrouter/ai-sdk-provider" } }),
).toMatchObject({
route: "openrouter",
baseURL: "https://openrouter.ai/api/v1",
})
})
test("fails fast for unsupported provider packages", () => {
expect(() =>
LLMNative.request({
model: { ...baseModel, api: { ...baseModel.api, npm: "unknown-provider" } },
messages: [],
}),
).toThrow("Native LLM request adapter does not support provider package unknown-provider")
})
test("compiles through the native OpenAI Responses route", async () => {
const prepared = await Effect.runPromise(
LLMClient.prepare(
LLMNative.request({
model: baseModel,
messages: [{ role: "user", content: "hello" }],
providerOptions: { openai: { store: false } },
maxOutputTokens: 512,
headers: { "x-request": "request-header" },
}),
).pipe(Effect.provide(LLMClient.layer), Effect.provide(RequestExecutor.defaultLayer)),
)
expect(prepared).toMatchObject({
route: "openai-responses",
protocol: "openai-responses",
body: {
model: "gpt-5-mini",
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }],
max_output_tokens: 512,
store: false,
stream: true,
},
})
})
})
+267 -1
View File
@@ -5,7 +5,6 @@ import { Cause, Effect, Exit, Stream } from "effect"
import z from "zod"
import { makeRuntime } from "../../src/effect/run-service"
import { LLM } from "../../src/session/llm"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Provider } from "@/provider/provider"
import { ProviderTransform } from "@/provider/transform"
@@ -581,6 +580,18 @@ describe("session.llm.stream", () => {
service_tier: null,
},
},
{
type: "response.output_item.added",
output_index: 0,
item: { type: "message", id: "item-1", status: "in_progress", role: "assistant", content: [] },
},
{
type: "response.content_part.added",
item_id: "item-1",
output_index: 0,
content_index: 0,
part: { type: "output_text", text: "", annotations: [] },
},
{
type: "response.output_text.delta",
item_id: "item-1",
@@ -676,6 +687,249 @@ describe("session.llm.stream", () => {
})
})
test("streams OpenAI through native runtime when opted in", async () => {
const server = state.server
if (!server) {
throw new Error("Server not initialized")
}
const source = await loadFixture("openai", "gpt-5.2")
const model = source.model
const chunks = [
{
type: "response.created",
response: {
id: "resp-native",
},
},
{
type: "response.output_item.added",
item: { type: "message", id: "item-native", status: "in_progress" },
},
{
type: "response.output_text.delta",
item_id: "item-native",
delta: "Hello native",
},
{
type: "response.completed",
response: {
incomplete_details: null,
usage: {
input_tokens: 1,
input_tokens_details: null,
output_tokens: 1,
output_tokens_details: null,
},
},
},
]
const request = waitRequest("/responses", createEventResponse(chunks, true))
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
enabled_providers: ["openai"],
provider: {
openai: {
name: "OpenAI",
env: ["OPENAI_API_KEY"],
npm: "@ai-sdk/openai",
api: "https://api.openai.com/v1",
models: {
[model.id]: model,
},
options: {
apiKey: "test-openai-key",
baseURL: `${server.url.origin}/v1`,
},
},
},
}),
)
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const previous = process.env.OPENCODE_LLM_RUNTIME
process.env.OPENCODE_LLM_RUNTIME = "native"
try {
const resolved = await getModel(ProviderID.openai, ModelID.make(model.id))
const sessionID = SessionID.make("session-test-native")
const agent = {
name: "test",
mode: "primary",
options: {},
permission: [{ permission: "*", pattern: "*", action: "allow" }],
temperature: 0.2,
} satisfies Agent.Info
await drain({
user: {
id: MessageID.make("msg_user-native"),
sessionID,
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: ProviderID.make("openai"), modelID: resolved.id, variant: "high" },
} satisfies MessageV2.User,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
messages: [{ role: "user", content: "Hello" }],
tools: {},
})
} finally {
if (previous === undefined) delete process.env.OPENCODE_LLM_RUNTIME
else process.env.OPENCODE_LLM_RUNTIME = previous
}
const capture = await request
expect(capture.url.pathname.endsWith("/responses")).toBe(true)
expect(capture.headers.get("Authorization")).toBe("Bearer test-openai-key")
expect(capture.body.model).toBe(model.id)
expect(capture.body.stream).toBe(true)
expect((capture.body.reasoning as { effort?: string } | undefined)?.effort).toBe("high")
expect(JSON.stringify(capture.body.input)).toContain("You are a helpful assistant.")
expect(capture.body.input).toContainEqual({ role: "user", content: [{ type: "input_text", text: "Hello" }] })
},
})
})
test("executes OpenAI tool calls through native runtime", async () => {
const server = state.server
if (!server) {
throw new Error("Server not initialized")
}
const source = await loadFixture("openai", "gpt-5.2")
const model = source.model
const chunks = [
{
type: "response.output_item.added",
item: { type: "function_call", id: "item-native-tool", call_id: "call-native-tool", name: "lookup" },
},
{
type: "response.function_call_arguments.delta",
item_id: "item-native-tool",
delta: '{"query":"weather"}',
},
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "item-native-tool",
call_id: "call-native-tool",
name: "lookup",
arguments: '{"query":"weather"}',
},
},
{
type: "response.completed",
response: { incomplete_details: null, usage: { input_tokens: 1, output_tokens: 1 } },
},
]
const request = waitRequest("/responses", createEventResponse(chunks, true))
let executed: unknown
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
enabled_providers: ["openai"],
provider: {
openai: {
name: "OpenAI",
env: ["OPENAI_API_KEY"],
npm: "@ai-sdk/openai",
api: "https://api.openai.com/v1",
models: {
[model.id]: model,
},
options: {
apiKey: "test-openai-key",
baseURL: `${server.url.origin}/v1`,
},
},
},
}),
)
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const previous = process.env.OPENCODE_LLM_RUNTIME
process.env.OPENCODE_LLM_RUNTIME = "native"
try {
const resolved = await getModel(ProviderID.openai, ModelID.make(model.id))
const sessionID = SessionID.make("session-test-native-tool")
const agent = {
name: "test",
mode: "primary",
options: {},
permission: [{ permission: "*", pattern: "*", action: "allow" }],
} satisfies Agent.Info
await drain({
user: {
id: MessageID.make("msg_user-native-tool"),
sessionID,
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: ProviderID.make("openai"), modelID: resolved.id },
} satisfies MessageV2.User,
sessionID,
model: resolved,
agent,
system: [],
messages: [{ role: "user", content: "Use lookup" }],
tools: {
lookup: tool({
description: "Lookup data",
inputSchema: z.object({ query: z.string() }),
execute: async (args, options) => {
executed = { args, toolCallId: options.toolCallId }
return { output: "looked up" }
},
}),
},
})
} finally {
if (previous === undefined) delete process.env.OPENCODE_LLM_RUNTIME
else process.env.OPENCODE_LLM_RUNTIME = previous
}
const capture = await request
expect(capture.body.tools).toEqual([
{
type: "function",
name: "lookup",
description: "Lookup data",
parameters: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
additionalProperties: false,
$schema: "http://json-schema.org/draft-07/schema#",
},
},
])
expect(executed).toEqual({ args: { query: "weather" }, toolCallId: "call-native-tool" })
},
})
})
test("accepts user image attachments as data URLs for OpenAI models", async () => {
const server = state.server
if (!server) {
@@ -694,6 +948,18 @@ describe("session.llm.stream", () => {
service_tier: null,
},
},
{
type: "response.output_item.added",
output_index: 0,
item: { type: "message", id: "item-data-url", status: "in_progress", role: "assistant", content: [] },
},
{
type: "response.content_part.added",
item_id: "item-data-url",
output_index: 0,
content_index: 0,
part: { type: "output_text", text: "", annotations: [] },
},
{
type: "response.output_text.delta",
item_id: "item-data-url",
+144 -132
View File
@@ -1,174 +1,186 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Exit, Layer } from "effect"
import { describe, expect, test } from "bun:test"
import path from "path"
import { Session as SessionNs } from "@/session/session"
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
import { Bus } from "../../src/bus"
import * as Log from "@opencode-ai/core/util/log"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { AppRuntime } from "../../src/effect/app-runtime"
import { tmpdir } from "../fixture/fixture"
const projectRoot = path.join(__dirname, "../..")
void Log.init({ print: false })
const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, CrossSpawnSpawner.defaultLayer))
function create(input?: SessionNs.CreateInput) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create(input)))
}
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 get(id: SessionID) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.get(id)))
}
const remove = (id: SessionID) => SessionNs.Service.use((svc) => svc.remove(id))
function remove(id: SessionID) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.remove(id)))
}
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)
function updateMessage<T extends MessageV2.Info>(msg: T) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updateMessage(msg)))
}
function updatePart<T extends MessageV2.Part>(part: T) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updatePart(part)))
}
describe("session.created event", () => {
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>()
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
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 unsub = Bus.subscribe(SessionNs.Event.Created, (event) => {
eventReceived = true
receivedInfo = event.properties.info as SessionNs.Info
})
const info = yield* session.create({})
const receivedInfo = yield* awaitDeferred(received, "timed out waiting for session.created")
const info = await create({})
await new Promise((resolve) => setTimeout(resolve, 100))
unsub()
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(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)
yield* session.remove(info.id)
}),
)
await remove(info.id)
},
})
})
it.instance("session.created event should be emitted before session.updated", () =>
Effect.gen(function* () {
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return
test("session.created event should be emitted before session.updated", async () => {
await WithInstance.provide({
directory: projectRoot,
fn: async () => {
const events: string[] = []
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 unsubCreated = Bus.subscribe(SessionNs.Event.Created, () => {
events.push("created")
})
const unsubCreated = subscribeGlobal(SessionNs.Event.Created.type, () => {
push("created")
})
yield* Effect.addFinalizer(() => Effect.sync(unsubCreated))
const unsubUpdated = Bus.subscribe(SessionNs.Event.Updated, () => {
events.push("updated")
})
const unsubUpdated = subscribeGlobal(SessionNs.Event.Updated.type, () => {
push("updated")
})
yield* Effect.addFinalizer(() => Effect.sync(unsubUpdated))
const info = await create({})
await new Promise((resolve) => setTimeout(resolve, 100))
unsubCreated()
unsubUpdated()
const info = yield* session.create({})
const receivedEvents = yield* awaitDeferred(received, "timed out waiting for session created/updated events")
expect(events).toContain("created")
expect(events).toContain("updated")
expect(events.indexOf("created")).toBeLessThan(events.indexOf("updated"))
expect(receivedEvents).toContain("created")
expect(receivedEvents).toContain("updated")
expect(receivedEvents.indexOf("created")).toBeLessThan(receivedEvents.indexOf("updated"))
yield* session.remove(info.id)
}),
)
await remove(info.id)
},
})
})
})
describe("step-finish token propagation via Bus event", () => {
it.instance(
test(
"non-zero tokens propagate through PartUpdated event",
() =>
Effect.gen(function* () {
const session = yield* SessionNs.Service
const info = yield* session.create({})
async () => {
await WithInstance.provide({
directory: projectRoot,
fn: async () => {
const info = await create({})
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)
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)
// 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))
// 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
})
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,
}
yield* session.updatePart(partInput)
const receivedPart = yield* awaitDeferred(received, "timed out waiting for message.part.updated")
await updatePart(partInput)
await new Promise((resolve) => setTimeout(resolve, 100))
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)
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)
yield* session.remove(info.id)
}),
unsub()
await remove(info.id)
},
})
},
{ timeout: 30000 },
)
})
describe("Session", () => {
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" }))
test("remove works without an instance", async () => {
await using tmp = await tmpdir({ git: true })
const removeExit = yield* remove(info.id).pipe(Effect.exit)
expect(Exit.isSuccess(removeExit)).toBe(true)
const info = await WithInstance.provide({
directory: tmp.path,
fn: () => create({ title: "remove-without-instance" }),
})
const getExit = yield* session.get(info.id).pipe(Effect.exit)
expect(Exit.isFailure(getExit)).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)
})
})
+58 -75
View File
@@ -1,11 +1,10 @@
import { describe, expect, beforeAll, afterAll } from "bun:test"
import { describe, test, expect, beforeAll, afterAll } from "bun:test"
import { Effect } from "effect"
import { Discovery } from "../../src/skill/discovery"
import { Global } from "@opencode-ai/core/global"
import { Filesystem } from "@/util/filesystem"
import { rm } from "fs/promises"
import path from "path"
import { testEffect } from "../lib/effect"
let CLOUDFLARE_SKILLS_URL: string
let server: ReturnType<typeof Bun.serve>
@@ -13,7 +12,6 @@ let downloadCount = 0
const fixturePath = path.join(import.meta.dir, "../fixture/skills")
const cacheDir = path.join(Global.Path.cache, "skills")
const it = testEffect(Discovery.defaultLayer)
beforeAll(async () => {
await rm(cacheDir, { recursive: true, force: true })
@@ -49,85 +47,70 @@ afterAll(async () => {
})
describe("Discovery.pull", () => {
const pull = Effect.fn("DiscoveryTest.pull")(function* (url: string) {
return yield* Discovery.Service.use((s) => s.pull(url))
const pull = (url: string) =>
Effect.runPromise(Discovery.Service.use((s) => s.pull(url)).pipe(Effect.provide(Discovery.defaultLayer)))
test("downloads skills from cloudflare url", async () => {
const dirs = await pull(CLOUDFLARE_SKILLS_URL)
expect(dirs.length).toBeGreaterThan(0)
for (const dir of dirs) {
expect(dir).toStartWith(cacheDir)
const md = path.join(dir, "SKILL.md")
expect(await Filesystem.exists(md)).toBe(true)
}
})
it.live("downloads skills from cloudflare url", () =>
Effect.gen(function* () {
const dirs = yield* pull(CLOUDFLARE_SKILLS_URL)
expect(dirs.length).toBeGreaterThan(0)
for (const dir of dirs) {
expect(dir).toStartWith(cacheDir)
const md = path.join(dir, "SKILL.md")
expect(yield* Effect.promise(() => Filesystem.exists(md))).toBe(true)
}
}),
)
test("url without trailing slash works", async () => {
const dirs = await pull(CLOUDFLARE_SKILLS_URL.replace(/\/$/, ""))
expect(dirs.length).toBeGreaterThan(0)
for (const dir of dirs) {
const md = path.join(dir, "SKILL.md")
expect(await Filesystem.exists(md)).toBe(true)
}
})
it.live("url without trailing slash works", () =>
Effect.gen(function* () {
const dirs = yield* pull(CLOUDFLARE_SKILLS_URL.replace(/\/$/, ""))
expect(dirs.length).toBeGreaterThan(0)
for (const dir of dirs) {
const md = path.join(dir, "SKILL.md")
expect(yield* Effect.promise(() => Filesystem.exists(md))).toBe(true)
}
}),
)
test("returns empty array for invalid url", async () => {
const dirs = await pull(`http://localhost:${server.port}/invalid-url/`)
expect(dirs).toEqual([])
})
it.live("returns empty array for invalid url", () =>
Effect.gen(function* () {
const dirs = yield* pull(`http://localhost:${server.port}/invalid-url/`)
expect(dirs).toEqual([])
}),
)
test("returns empty array for non-json response", async () => {
// any url not explicitly handled in server returns 404 text "Not Found"
const dirs = await pull(`http://localhost:${server.port}/some-other-path/`)
expect(dirs).toEqual([])
})
it.live("returns empty array for non-json response", () =>
Effect.gen(function* () {
// any url not explicitly handled in server returns 404 text "Not Found"
const dirs = yield* pull(`http://localhost:${server.port}/some-other-path/`)
expect(dirs).toEqual([])
}),
)
test("downloads reference files alongside SKILL.md", async () => {
const dirs = await pull(CLOUDFLARE_SKILLS_URL)
// find a skill dir that should have reference files (e.g. agents-sdk)
const agentsSdk = dirs.find((d) => d.endsWith(path.sep + "agents-sdk"))
expect(agentsSdk).toBeDefined()
if (agentsSdk) {
const refs = path.join(agentsSdk, "references")
expect(await Filesystem.exists(path.join(agentsSdk, "SKILL.md"))).toBe(true)
// agents-sdk has reference files per the index
const refDir = await Array.fromAsync(new Bun.Glob("**/*.md").scan({ cwd: refs, onlyFiles: true }))
expect(refDir.length).toBeGreaterThan(0)
}
})
it.live("downloads reference files alongside SKILL.md", () =>
Effect.gen(function* () {
const dirs = yield* pull(CLOUDFLARE_SKILLS_URL)
// find a skill dir that should have reference files (e.g. agents-sdk)
const agentsSdk = dirs.find((d) => d.endsWith(path.sep + "agents-sdk"))
expect(agentsSdk).toBeDefined()
if (agentsSdk) {
const refs = path.join(agentsSdk, "references")
expect(yield* Effect.promise(() => Filesystem.exists(path.join(agentsSdk, "SKILL.md")))).toBe(true)
// agents-sdk has reference files per the index
const refDir = yield* Effect.promise(() =>
Array.fromAsync(new Bun.Glob("**/*.md").scan({ cwd: refs, onlyFiles: true })),
)
expect(refDir.length).toBeGreaterThan(0)
}
}),
)
test("caches downloaded files on second pull", async () => {
// clear dir and downloadCount
await rm(cacheDir, { recursive: true, force: true })
downloadCount = 0
it.live("caches downloaded files on second pull", () =>
Effect.gen(function* () {
// clear dir and downloadCount
yield* Effect.promise(() => rm(cacheDir, { recursive: true, force: true }))
downloadCount = 0
// first pull to populate cache
const first = await pull(CLOUDFLARE_SKILLS_URL)
expect(first.length).toBeGreaterThan(0)
const firstCount = downloadCount
expect(firstCount).toBeGreaterThan(0)
// first pull to populate cache
const first = yield* pull(CLOUDFLARE_SKILLS_URL)
expect(first.length).toBeGreaterThan(0)
const firstCount = downloadCount
expect(firstCount).toBeGreaterThan(0)
// second pull should return same results from cache
const second = await pull(CLOUDFLARE_SKILLS_URL)
expect(second.length).toBe(first.length)
expect(second.sort()).toEqual(first.sort())
// second pull should return same results from cache
const second = yield* pull(CLOUDFLARE_SKILLS_URL)
expect(second.length).toBe(first.length)
expect(second.sort()).toEqual(first.sort())
// second pull should NOT increment download count
expect(downloadCount).toBe(firstCount)
}),
)
// second pull should NOT increment download count
expect(downloadCount).toBe(firstCount)
})
})
+423 -341
View File
@@ -1,19 +1,20 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import path from "path"
import * as fs from "fs/promises"
import { Cause, Effect, Exit, Layer } from "effect"
import { Effect, ManagedRuntime, 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 { TestInstance } from "../fixture/fixture"
import { tmpdir } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema"
import { testEffect } from "../lib/effect"
const it = testEffect(
const runtime = ManagedRuntime.make(
Layer.mergeAll(
LSP.defaultLayer,
AppFileSystem.defaultLayer,
@@ -57,11 +58,11 @@ type ToolCtx = typeof baseCtx & {
ask: (input: AskInput) => Effect.Effect<void>
}
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 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 makeCtx = () => {
const calls: AskInput[] = []
@@ -76,56 +77,39 @@ 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)
describe("tool.apply_patch freeform", () => {
test("requires patchText", async () => {
const { ctx } = makeCtx()
await expect(execute({ patchText: "" }, ctx)).rejects.toThrow("patchText is required")
})
const expectReadFailure = (filepath: string) => expectFailure(readText(filepath))
test("rejects invalid patch format", async () => {
const { ctx } = makeCtx()
await expect(execute({ patchText: "invalid patch" }, ctx)).rejects.toThrow("apply_patch verification failed")
})
describe("tool.apply_patch freeform", () => {
it.live("requires patchText", () =>
Effect.gen(function* () {
const { ctx } = makeCtx()
yield* expectFailure(execute({ patchText: "" }, ctx), "patchText is required")
}),
)
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 invalid patch format", () =>
Effect.gen(function* () {
const { ctx } = makeCtx()
yield* expectFailure(execute({ patchText: "invalid patch" }, ctx), "apply_patch verification failed")
}),
)
test("applies add/update/delete in one patch", async () => {
await using fixture = await tmpdir({ git: true })
const { ctx, calls } = makeCtx()
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")
}),
)
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")
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")
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 = yield* execute({ patchText }, ctx)
const result = await execute({ patchText }, ctx)
expect(result.title).toContain("Success. Updated the following files")
expect(result.output).toContain("Success. Updated the following files")
@@ -145,34 +129,38 @@ 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?.relativePath).toBe("nested/new.txt")
expect(addFile?.patch).toContain("+created")
expect(addFile).toBeDefined()
expect(addFile!.relativePath).toBe("nested/new.txt")
expect(addFile!.patch).toContain("+created")
const updateFile = permissionCall.metadata.files.find((f) => f.type === "update")
expect(updateFile?.patch).toContain("-line2")
expect(updateFile?.patch).toContain("+changed")
expect(updateFile).toBeDefined()
expect(updateFile!.patch).toContain("-line2")
expect(updateFile!.patch).toContain("+changed")
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 },
)
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()
},
})
})
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")
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")
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)
await execute({ patchText }, ctx)
expect(calls.length).toBe(1)
const permissionCall = calls[0]
@@ -181,353 +169,447 @@ 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(test.directory, "renamed/dir/name.txt"))
expect(moveFile.movePath).toBe(path.join(fixture.path, "renamed/dir/name.txt"))
expect(moveFile.patch).toContain("-old content")
expect(moveFile.patch).toContain("+new content")
}),
{ 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")
test("applies multiple hunks to one file", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
const patchText =
"*** Begin Patch\n*** Update File: multi.txt\n@@\n-line2\n+changed2\n@@\n-line4\n+changed4\n*** End Patch"
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")
yield* execute({ patchText }, ctx)
const patchText =
"*** Begin Patch\n*** Update File: multi.txt\n@@\n-line2\n+changed2\n@@\n-line4\n+changed4\n*** End Patch"
expect(yield* readText(target)).toBe("line1\nchanged2\nline3\nchanged4\n")
}),
)
await execute({ patchText }, ctx)
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`)
expect(await fs.readFile(target, "utf-8")).toBe("line1\nchanged2\nline3\nchanged4\n")
},
})
})
const patchText =
"*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch"
test("does not invent a first-line diff for BOM files", async () => {
await using fixture = await tmpdir()
const { ctx, calls } = makeCtx()
yield* execute({ patchText }, ctx)
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")
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 patchText =
"*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch"
const content = yield* readText(target)
expect(content.charCodeAt(0)).toBe(0xfeff)
expect(content.slice(1)).toBe("using System;\n\nclass Test {}\nclass Next {}\n")
}),
)
await execute({ patchText }, ctx)
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")
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 patchText = "*** Begin Patch\n*** Update File: insert_only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch"
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")
},
})
})
yield* execute({ patchText }, ctx)
test("inserts lines with insert-only hunk", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
expect(yield* readText(target)).toBe("alpha\nbeta\nomega\n")
}),
)
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")
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: insert_only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch"
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)
yield* execute({ patchText }, ctx)
expect(await fs.readFile(target, "utf-8")).toBe("alpha\nbeta\nomega\n")
},
})
})
const contents = yield* readText(target)
expect(contents.endsWith("\n")).toBe(true)
expect(contents).toBe("first line\nsecond line\n")
}),
)
test("appends trailing newline on update", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
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")
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: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch"
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)
await execute({ patchText }, ctx)
const moved = path.join(test.directory, "renamed", "dir", "name.txt")
yield* expectReadFailure(original)
expect(yield* readText(moved)).toBe("new content\n")
}),
)
const contents = await fs.readFile(target, "utf-8")
expect(contents.endsWith("\n")).toBe(true)
expect(contents).toBe("first line\nsecond line\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")
test("moves file to a new directory", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
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 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")
yield* execute({ patchText }, ctx)
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* expectReadFailure(original)
expect(yield* readText(destination)).toBe("new\n")
}),
)
await execute({ patchText }, ctx)
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 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")
},
})
})
const patchText = "*** Begin Patch\n*** Add File: duplicate.txt\n+new content\n*** End Patch"
test("moves file overwriting existing destination", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
yield* execute({ patchText }, ctx)
expect(yield* readText(target)).toBe("new content\n")
}),
)
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")
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"
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* expectFailure(
execute({ patchText }, ctx),
"apply_patch verification failed: Failed to read file to update",
)
}),
)
await execute({ patchText }, ctx)
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"
await expect(fs.readFile(original, "utf-8")).rejects.toThrow()
expect(await fs.readFile(destination, "utf-8")).toBe("new\n")
},
})
})
yield* expectFailure(execute({ patchText }, ctx))
}),
)
test("adds file overwriting existing file", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
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)
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*** Delete File: dir\n*** End Patch"
const patchText = "*** Begin Patch\n*** Add File: duplicate.txt\n+new content\n*** End Patch"
yield* expectFailure(execute({ patchText }, ctx))
}),
)
await execute({ patchText }, ctx)
expect(await fs.readFile(target, "utf-8")).toBe("new content\n")
},
})
})
it.instance("rejects invalid hunk header", () =>
Effect.gen(function* () {
const { ctx } = makeCtx()
const patchText = "*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"
test("rejects update when target file is missing", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
yield* expectFailure(execute({ patchText }, ctx), "apply_patch verification failed")
}),
)
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"
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")
await expect(execute({ patchText }, ctx)).rejects.toThrow(
"apply_patch verification failed: Failed to read file to update",
)
},
})
})
const patchText = "*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch"
test("rejects delete when file is missing", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
yield* expectFailure(execute({ patchText }, ctx), "apply_patch verification failed")
expect(yield* readText(target)).toBe("line1\nline2\n")
}),
)
await WithInstance.provide({
directory: fixture.path,
fn: async () => {
const patchText = "*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch"
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"
await expect(execute({ patchText }, ctx)).rejects.toThrow()
},
})
})
yield* expectFailure(execute({ patchText }, ctx))
yield* expectReadFailure(path.join(test.directory, "created.txt"))
}),
)
test("rejects delete when target is a directory", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
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")
await WithInstance.provide({
directory: fixture.path,
fn: async () => {
const dirPath = path.join(fixture.path, "dir")
await fs.mkdir(dirPath)
const patchText = "*** Begin Patch\n*** Update File: tail.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch"
const patchText = "*** Begin Patch\n*** Delete File: dir\n*** End Patch"
yield* execute({ patchText }, ctx)
expect(yield* readText(target)).toBe("alpha\nend\n")
}),
)
await expect(execute({ patchText }, ctx)).rejects.toThrow()
},
})
})
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")
test("rejects invalid hunk header", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
const patchText = "*** Begin Patch\n*** Update File: two_chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch"
await WithInstance.provide({
directory: fixture.path,
fn: async () => {
const patchText = "*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"
yield* expectFailure(execute({ patchText }, ctx))
expect(yield* readText(target)).toBe("a\nb\nc\nd\n")
}),
)
await expect(execute({ patchText }, ctx)).rejects.toThrow("apply_patch verification failed")
},
})
})
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")
test("rejects update with missing context", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
const patchText = "*** Begin Patch\n*** Update File: multi_ctx.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch"
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")
yield* execute({ patchText }, ctx)
expect(yield* readText(target)).toBe("fn a\nx=10\ny=2\nfn b\nx=11\ny=20\n")
}),
)
const patchText = "*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch"
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")
await expect(execute({ patchText }, ctx)).rejects.toThrow("apply_patch verification failed")
expect(await fs.readFile(target, "utf-8")).toBe("line1\nline2\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"
test("verification failure leaves no side effects", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
yield* execute({ patchText }, ctx)
// First marker unchanged, second marker changed
expect(yield* readText(target)).toBe("start\nmarker\nmiddle\nmarker-changed\nend\n")
}),
)
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"
it.instance("parses heredoc-wrapped patch", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const patchText = `cat <<'EOF'
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'
*** Begin Patch
*** Add File: heredoc_test.txt
+heredoc content
*** End Patch
EOF`
yield* execute({ patchText }, ctx)
expect(yield* readText(path.join(test.directory, "heredoc_test.txt"))).toBe("heredoc content\n")
}),
)
await execute({ patchText }, ctx)
const content = await fs.readFile(path.join(fixture.path, "heredoc_test.txt"), "utf-8")
expect(content).toBe("heredoc content\n")
},
})
})
it.instance("parses heredoc-wrapped patch without cat", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const patchText = `<<EOF
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
*** Begin Patch
*** Add File: heredoc_no_cat.txt
+no cat prefix
*** End Patch
EOF`
yield* execute({ patchText }, ctx)
expect(yield* readText(path.join(test.directory, "heredoc_no_cat.txt"))).toBe("no cat prefix\n")
}),
)
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")
},
})
})
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")
test("matches with trailing whitespace differences", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
// 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"
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")
yield* execute({ patchText }, ctx)
expect(yield* readText(target)).toBe("line1 \nchanged\nline3 \n")
}),
)
// 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"
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")
await execute({ patchText }, ctx)
expect(await fs.readFile(target, "utf-8")).toBe("line1 \nchanged\nline3 \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"
test("matches with leading whitespace differences", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
yield* execute({ patchText }, ctx)
expect(yield* readText(target)).toBe(" line1\nchanged\n line3\n")
}),
)
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")
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`)
// 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"
// 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)
expect(await fs.readFile(target, "utf-8")).toBe(" line1\nchanged\n line3\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`)
}),
)
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`)
},
})
})
})
+67 -43
View File
@@ -1,9 +1,10 @@
import { afterEach, describe, expect } from "bun:test"
import { afterAll, afterEach, describe, test, expect } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { Cause, Deferred, Effect, Exit, Layer, ManagedRuntime } from "effect"
import { EditTool } from "../../src/tool/edit"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { WithInstance } from "../../src/project/with-instance"
import { disposeAllInstances, TestInstance, tmpdir } from "../fixture/fixture"
import { LSP } from "@/lsp/lsp"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Format } from "../../src/format"
@@ -41,6 +42,20 @@ 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()
@@ -485,49 +500,58 @@ describe("tool.edit", () => {
})
describe("concurrent editing", () => {
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")
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")
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))
}),
}
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 first = yield* run(
{
filePath: filepath,
oldString: "top = 0",
newString: "top = 1",
},
delayedCtx,
).pipe(Effect.forkScoped)
const promise1 = Effect.runPromise(
edit.execute(
{
filePath: filepath,
oldString: "top = 0",
newString: "top = 1",
},
delayedCtx,
),
)
yield* Deferred.await(firstAsk)
yield* Effect.all([
Fiber.join(first),
run(
{
filePath: filepath,
oldString: "bottom = 0",
newString: "bottom = 2",
},
delayedCtx,
),
])
await firstAsk.promise
expect(yield* load(filepath)).toBe("top = 1\nmiddle = keep\nbottom = 2\n")
}),
)
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")
},
})
})
})
})
@@ -1,16 +1,14 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } 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 { assertExternalDirectoryEffect } from "../../src/tool/external-directory"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { assertExternalDirectory } from "../../src/tool/external-directory"
import { Filesystem } from "@/util/filesystem"
import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture"
import { tmpdir } 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"),
@@ -38,120 +36,135 @@ function makeCtx() {
}
describe("tool.assertExternalDirectory", () => {
it.live("no-ops for empty target", () =>
Effect.gen(function* () {
const { requests, ctx } = makeCtx()
test("no-ops for empty target", async () => {
const { requests, ctx } = makeCtx()
yield* assertExternalDirectoryEffect(ctx)
await WithInstance.provide({
directory: "/tmp",
fn: async () => {
await assertExternalDirectory(ctx)
},
})
expect(requests.length).toBe(0)
}),
)
expect(requests.length).toBe(0)
})
it.live("no-ops for paths inside Instance.directory", () =>
provideInstance("/tmp/project")(
Effect.gen(function* () {
const { requests, ctx } = makeCtx()
test("no-ops for paths inside Instance.directory", async () => {
const { requests, ctx } = makeCtx()
yield* assertExternalDirectoryEffect(ctx, path.join("/tmp/project", "file.txt"))
await WithInstance.provide({
directory: "/tmp/project",
fn: async () => {
await assertExternalDirectory(ctx, path.join("/tmp/project", "file.txt"))
},
})
expect(requests.length).toBe(0)
}),
),
)
expect(requests.length).toBe(0)
})
it.live("asks with a single canonical glob", () =>
Effect.gen(function* () {
const { requests, ctx } = makeCtx()
test("asks with a single canonical glob", async () => {
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), "*"))
yield* provideInstance(directory)(assertExternalDirectoryEffect(ctx, target))
await WithInstance.provide({
directory,
fn: async () => {
await assertExternalDirectory(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])
})
it.live("uses target directory when kind=directory", () =>
Effect.gen(function* () {
const { requests, ctx } = makeCtx()
test("uses target directory when kind=directory", async () => {
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, "*"))
yield* provideInstance(directory)(assertExternalDirectoryEffect(ctx, target, { kind: "directory" }))
await WithInstance.provide({
directory,
fn: async () => {
await assertExternalDirectory(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])
})
it.live("skips prompting when bypass=true", () =>
provideInstance("/tmp/project")(
Effect.gen(function* () {
const { requests, ctx } = makeCtx()
test("skips prompting when bypass=true", async () => {
const { requests, ctx } = makeCtx()
yield* assertExternalDirectoryEffect(ctx, "/tmp/outside/file.txt", { bypass: true })
await WithInstance.provide({
directory: "/tmp/project",
fn: async () => {
await assertExternalDirectory(ctx, "/tmp/outside/file.txt", { bypass: true })
},
})
expect(requests.length).toBe(0)
}),
),
)
expect(requests.length).toBe(0)
})
if (process.platform === "win32") {
it.instance(
"normalizes Windows path variants to one glob",
() =>
Effect.gen(function* () {
const { requests, ctx } = makeCtx()
test("normalizes Windows path variants to one glob", async () => {
const { requests, ctx } = makeCtx()
const outerTmp = yield* tmpdirScoped()
yield* Effect.promise(() => Bun.write(path.join(outerTmp, "outside.txt"), "x"))
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 target = path.join(outerTmp, "outside.txt")
const alt = target
.replace(/^[A-Za-z]:/, "")
.replaceAll("\\", "/")
.toLowerCase()
const target = path.join(outerTmp.path, "outside.txt")
const alt = target
.replace(/^[A-Za-z]:/, "")
.replaceAll("\\", "/")
.toLowerCase()
yield* assertExternalDirectoryEffect(ctx, alt)
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await assertExternalDirectory(ctx, alt)
},
})
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 },
)
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])
})
it.instance(
"uses drive root glob for root files",
() =>
Effect.gen(function* () {
const { requests, ctx } = makeCtx()
test("uses drive root glob for root files", async () => {
const { requests, ctx } = makeCtx()
const tmp = yield* TestInstance
const root = path.parse(tmp.directory).root
const target = path.join(root, "boot.ini")
await using tmp = await tmpdir({ git: true })
const root = path.parse(tmp.path).root
const target = path.join(root, "boot.ini")
yield* assertExternalDirectoryEffect(ctx, target)
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await assertExternalDirectory(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])
}),
{ git: true },
)
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])
})
}
})
+1 -1
View File
@@ -180,7 +180,7 @@ describe("tool.registry", () => {
const promptTools = yield* registry.tools({
providerID: ProviderID.opencode,
modelID: ModelID.make("test"),
agent: yield* agents.defaultInfo(),
agent: yield* agents.get(yield* agents.defaultAgent()),
})
const promptTool = promptTools.find((tool) => tool.id === "sql")
if (!promptTool) throw new Error("custom sql tool was not returned for prompts")
File diff suppressed because it is too large Load Diff
+55 -60
View File
@@ -1,12 +1,11 @@
import { describe, expect } from "bun:test"
import { Effect, Layer, Schema } from "effect"
import { describe, test, expect } from "bun:test"
import { Effect, Layer, ManagedRuntime, 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 it = testEffect(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer))
const runtime = ManagedRuntime.make(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer))
const params = Schema.Struct({ input: Schema.String })
@@ -22,53 +21,49 @@ function makeTool(id: string, executeFn?: () => void) {
}
describe("Tool.define", () => {
it.effect("object-defined tool does not mutate the original init object", () =>
Effect.gen(function* () {
const original = makeTool("test")
const originalExecute = original.execute
test("object-defined tool does not mutate the original init object", async () => {
const original = makeTool("test")
const originalExecute = original.execute
const info = yield* Tool.define("test-tool", Effect.succeed(original))
const info = await runtime.runPromise(Tool.define("test-tool", Effect.succeed(original)))
yield* info.init()
yield* info.init()
yield* info.init()
await Effect.runPromise(info.init())
await Effect.runPromise(info.init())
await Effect.runPromise(info.init())
expect(original.execute).toBe(originalExecute)
}),
)
expect(original.execute).toBe(originalExecute)
})
it.effect("effect-defined tool returns fresh objects and is unaffected", () =>
Effect.gen(function* () {
const info = yield* Tool.define(
test("effect-defined tool returns fresh objects and is unaffected", async () => {
const info = await runtime.runPromise(
Tool.define(
"test-fn-tool",
Effect.succeed(() => Effect.succeed(makeTool("test"))),
)
),
)
const first = yield* info.init()
const second = yield* info.init()
const first = await Effect.runPromise(info.init())
const second = await Effect.runPromise(info.init())
expect(first).not.toBe(second)
}),
)
expect(first).not.toBe(second)
})
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")))
test("object-defined tool returns distinct objects per init() call", async () => {
const info = await runtime.runPromise(Tool.define("test-copy", Effect.succeed(makeTool("test"))))
const first = yield* info.init()
const second = yield* info.init()
const first = await Effect.runPromise(info.init())
const second = await Effect.runPromise(info.init())
expect(first).not.toBe(second)
}),
)
expect(first).not.toBe(second)
})
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("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(
"test-decoded",
Effect.succeed({
description: "test tool",
@@ -78,27 +73,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 = yield* 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 = await Effect.runPromise(info.init())
const execute = tool.execute as unknown as (args: unknown, ctx: Tool.Context) => ReturnType<typeof tool.execute>
yield* execute({}, ctx)
yield* execute({ count: "7" }, ctx)
await Effect.runPromise(execute({}, ctx))
await Effect.runPromise(execute({ count: "7" }, ctx))
expect(calls).toEqual([{ count: 5 }, { count: 7 }])
}),
)
expect(calls).toEqual([{ count: 5 }, { count: 7 }])
})
})
+11 -6
View File
@@ -1,7 +1,9 @@
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 { MessageError } from "../../src/session/message-error"
import { namedSchemaError } from "../../src/util/named-schema-error"
import { UI } from "../../src/cli/ui"
describe("util.error", () => {
test("formats native Error instances", () => {
@@ -51,14 +53,17 @@ describe("util.error", () => {
expect(String(data.formatted)).toContain("ResolveMessage")
})
test("schema-backed named errors are real NamedError instances", () => {
const error = new MessageError.AuthError({ providerID: "anthropic", message: "boom" })
test("named schema errors are real NamedError instances", () => {
const ExampleError = namedSchemaError("ExampleError", { message: Schema.String })
const error = new ExampleError({ message: "boom" })
expect(error).toBeInstanceOf(NamedError)
expect(error.toObject()).toEqual({ name: "ProviderAuthError", data: { providerID: "anthropic", message: "boom" } })
expect(error.toObject()).toEqual({ name: "ExampleError", data: { message: "boom" } })
})
test("named errors without fields serialize data", () => {
expect(new MessageError.OutputLengthError({}).toObject()).toEqual({ name: "MessageOutputLengthError", data: {} })
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")
})
})
@@ -40,16 +40,3 @@ 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,
}),
}
}