Compare commits
67
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98aa4623c7 | ||
|
|
f1e66751d5 | ||
|
|
937a3c10b3 | ||
|
|
72ce24c200 | ||
|
|
3301fad8cd | ||
|
|
9c54255aeb | ||
|
|
b0674b473f | ||
|
|
ded4da735b | ||
|
|
2c334d9242 | ||
|
|
81dd46abec | ||
|
|
baef5cd43b | ||
|
|
65368f609d | ||
|
|
2cb697b720 | ||
|
|
cb511f78ff | ||
|
|
159964b172 | ||
|
|
d9f9f1553b | ||
|
|
0fb55b4f1a | ||
|
|
3c34f6704b | ||
|
|
4cf088ae84 | ||
|
|
ca28dd02ec | ||
|
|
2017dc165c | ||
|
|
dc9d6a08cb | ||
|
|
af4ab017cb | ||
|
|
dd14413a64 | ||
|
|
b9e7cbf13c | ||
|
|
3f74abc6cd | ||
|
|
e0d0fe1ff7 | ||
|
|
f7dbb4dac4 | ||
|
|
c5849e56cc | ||
|
|
e46ab34d27 | ||
|
|
1d4613006a | ||
|
|
71040c54aa | ||
|
|
fec78154b5 | ||
|
|
3e2ec192cf | ||
|
|
ec960da42a | ||
|
|
45de4975de | ||
|
|
e540daabc4 | ||
|
|
f3c91c5f96 | ||
|
|
549b146ea6 | ||
|
|
3c7569d852 | ||
|
|
8f1ded9e08 | ||
|
|
b668af29dd | ||
|
|
ec30ff9120 | ||
|
|
a3714d4399 | ||
|
|
822eec0d62 | ||
|
|
3974520742 | ||
|
|
fda37b3609 | ||
|
|
6b950b666a | ||
|
|
bc4fdb8370 | ||
|
|
2b9af91568 | ||
|
|
53a3f95088 | ||
|
|
5a4596c879 | ||
|
|
0ce614a280 | ||
|
|
e8125e9b42 | ||
|
|
a7b5041674 | ||
|
|
a16789dfdd | ||
|
|
8115004c73 | ||
|
|
ec4fdaf8e9 | ||
|
|
3dc2c1d81c | ||
|
|
d658e1e350 | ||
|
|
30e3fa1de9 | ||
|
|
23f8b3eb3e | ||
|
|
c7d8b0d565 | ||
|
|
257fcafc83 | ||
|
|
04aafe2bfc | ||
|
|
0fd0facc44 | ||
|
|
0de3b67cc0 |
@@ -65,7 +65,6 @@
|
||||
"solid-list": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"virtua": "catalog:",
|
||||
"zod": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@happy-dom/global-registrator": "20.0.11",
|
||||
@@ -214,7 +213,6 @@
|
||||
"npm-package-arg": "13.0.2",
|
||||
"semver": "^7.6.3",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"zod": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
@@ -396,7 +394,6 @@
|
||||
"@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:*",
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
[install]
|
||||
exact = true
|
||||
# Only install newly resolved package versions published at least 3 days ago.
|
||||
minimumReleaseAge = 259200
|
||||
|
||||
[test]
|
||||
root = "./do-not-run-tests-from-root"
|
||||
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-Q9r1S15YL9LQK7DRhuOpw3Fxi24BPovEM995GZJayKw=",
|
||||
"aarch64-linux": "sha256-C0rRTLnxxuuEkCBc3JZbkR66TUVwpcPFif3BU9GRAuA=",
|
||||
"aarch64-darwin": "sha256-1HvalOO/pOkRlYH8CZ93psapt90C+pYzui1JCadBE1Q=",
|
||||
"x86_64-darwin": "sha256-RrndyLWfhWm4mZ88XytFF2NI+ly8la550Z5LBN/g5u4="
|
||||
"x86_64-linux": "sha256-MUHog06sZEi6bXR1m8exdkjSNW9bHEv9bPQXACJ7SFw=",
|
||||
"aarch64-linux": "sha256-3dwdZ3It++OsdGT8xMOQ10Arz8eeODp/LXOrI4DLEhY=",
|
||||
"aarch64-darwin": "sha256-TmUPGDCewjsrT13npVH6B55J43NKKut67p/HgPJpQNM=",
|
||||
"x86_64-darwin": "sha256-j8I7t3MZoUQUMFRWyaFO75TRbAw5TauSZAa4yKOHFMA="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
"solid-js": "catalog:",
|
||||
"solid-list": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"virtua": "catalog:",
|
||||
"zod": "catalog:"
|
||||
"virtua": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,13 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { batch, onCleanup, onMount } from "solid-js"
|
||||
import z from "zod"
|
||||
import { createSdkForServer } from "@/utils/server"
|
||||
import { useLanguage } from "./language"
|
||||
import { usePlatform } from "./platform"
|
||||
import { useServer } from "./server"
|
||||
|
||||
const abortError = z.object({
|
||||
name: z.literal("AbortError"),
|
||||
})
|
||||
const isAbortError = (error: unknown) =>
|
||||
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
||||
|
||||
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
|
||||
name: "GlobalSDK",
|
||||
@@ -103,7 +101,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
|
||||
let streamErrorLogged = false
|
||||
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
const aborted = (error: unknown) => abortError.safeParse(error).success
|
||||
const aborted = isAbortError
|
||||
|
||||
let attempt: AbortController | undefined
|
||||
let run: Promise<void> | undefined
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import z from "zod"
|
||||
|
||||
const prefixes = {
|
||||
session: "ses",
|
||||
message: "msg",
|
||||
@@ -15,10 +13,6 @@ let counter = 0
|
||||
|
||||
type Prefix = keyof typeof prefixes
|
||||
export namespace Identifier {
|
||||
export function schema(prefix: Prefix) {
|
||||
return z.string().startsWith(prefixes[prefix])
|
||||
}
|
||||
|
||||
export function ascending(prefix: Prefix, given?: string) {
|
||||
return generateID(prefix, false, given)
|
||||
}
|
||||
|
||||
@@ -299,7 +299,6 @@ export async function handler(
|
||||
let buffer = ""
|
||||
let responseLength = 0
|
||||
let timestampFirstByte = 0
|
||||
let timestampLastByte = 0
|
||||
|
||||
function pump(): Promise<void> {
|
||||
return (
|
||||
|
||||
@@ -41,8 +41,7 @@
|
||||
"minimatch": "10.2.5",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"semver": "^7.6.3",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"zod": "catalog:"
|
||||
"xdg-basedir": "5.1.0"
|
||||
},
|
||||
"overrides": {
|
||||
"drizzle-orm": "catalog:"
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export function fn<T extends z.ZodType, Result>(schema: T, cb: (input: z.infer<T>) => Result) {
|
||||
const result = (input: z.infer<T>) => {
|
||||
const parsed = schema.parse(input)
|
||||
return cb(parsed)
|
||||
}
|
||||
result.force = (input: z.infer<T>) => cb(input)
|
||||
result.schema = schema
|
||||
return result
|
||||
}
|
||||
@@ -4,11 +4,14 @@ import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { createWriteStream } from "fs"
|
||||
import * as Global from "../global"
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import { Glob } from "./glob"
|
||||
|
||||
export const Level = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).meta({ ref: "LogLevel", description: "Log level" })
|
||||
export type Level = z.infer<typeof Level>
|
||||
export const Level = Schema.Literals(["DEBUG", "INFO", "WARN", "ERROR"]).annotate({
|
||||
identifier: "LogLevel",
|
||||
description: "Log level",
|
||||
})
|
||||
export type Level = Schema.Schema.Type<typeof Level>
|
||||
|
||||
const levelPriority: Record<Level, number> = {
|
||||
DEBUG: 0,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Message, Model, Part, Session, SnapshotFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { fn } from "@opencode-ai/core/util/fn"
|
||||
import { iife } from "@opencode-ai/core/util/iife"
|
||||
import z from "zod"
|
||||
import { Storage } from "./storage"
|
||||
|
||||
function fn<T extends z.ZodType, Result>(schema: T, cb: (input: z.infer<T>) => Result) {
|
||||
return (input: z.infer<T>) => cb(schema.parse(input))
|
||||
}
|
||||
|
||||
export namespace Share {
|
||||
export const Info = z.object({
|
||||
id: z.string(),
|
||||
|
||||
@@ -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 === "request-finish") process.stdout.write(`\nfinish: ${event.reason}\n`)
|
||||
if (event.type === "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: "request-finish", reason: "stop" }],
|
||||
onHalt: () => [{ type: "finish", reason: "stop" }],
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ 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
|
||||
// `request-finish` event; `response.failed` is a hard failure that emits a
|
||||
// `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.requestFinish(input),
|
||||
LLMEvent.finish(input),
|
||||
)
|
||||
return { ...stepped, stepStarted: false }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Schema } from "effect"
|
||||
import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, ResponseID, RouteID, ToolCallID } from "./ids"
|
||||
import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids"
|
||||
import { ModelRef } from "./options"
|
||||
import { ToolResultValue } from "./messages"
|
||||
|
||||
@@ -66,14 +66,13 @@ 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 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 type UsageInput = Usage | ConstructorParameters<typeof Usage>[0]
|
||||
|
||||
export const StepStart = Schema.Struct({
|
||||
type: Schema.tag("step-start"),
|
||||
@@ -185,13 +184,13 @@ export const StepFinish = Schema.Struct({
|
||||
}).annotate({ identifier: "LLM.Event.StepFinish" })
|
||||
export type StepFinish = Schema.Schema.Type<typeof StepFinish>
|
||||
|
||||
export const RequestFinish = Schema.Struct({
|
||||
type: Schema.tag("request-finish"),
|
||||
export const Finish = Schema.Struct({
|
||||
type: Schema.tag("finish"),
|
||||
reason: FinishReason,
|
||||
usage: Schema.optional(Usage),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.RequestFinish" })
|
||||
export type RequestFinish = Schema.Schema.Type<typeof RequestFinish>
|
||||
}).annotate({ identifier: "LLM.Event.Finish" })
|
||||
export type Finish = Schema.Schema.Type<typeof Finish>
|
||||
|
||||
export const ProviderErrorEvent = Schema.Struct({
|
||||
type: Schema.tag("provider-error"),
|
||||
@@ -202,7 +201,6 @@ export const ProviderErrorEvent = Schema.Struct({
|
||||
export type ProviderErrorEvent = Schema.Schema.Type<typeof ProviderErrorEvent>
|
||||
|
||||
const llmEventTagged = Schema.Union([
|
||||
RequestStart,
|
||||
StepStart,
|
||||
TextStart,
|
||||
TextDelta,
|
||||
@@ -217,16 +215,15 @@ const llmEventTagged = Schema.Union([
|
||||
ToolResult,
|
||||
ToolError,
|
||||
StepFinish,
|
||||
RequestFinish,
|
||||
Finish,
|
||||
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?: Usage | ConstructorParameters<typeof Usage>[0]
|
||||
readonly usage?: UsageInput
|
||||
}
|
||||
|
||||
const responseID = (value: ResponseID | string) => ResponseID.make(value)
|
||||
const contentBlockID = (value: ContentBlockID | string) => ContentBlockID.make(value)
|
||||
const toolCallID = (value: ToolCallID | string) => ToolCallID.make(value)
|
||||
|
||||
@@ -236,7 +233,6 @@ 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) }),
|
||||
@@ -258,16 +254,15 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
||||
stepFinish: (input: WithUsage<StepFinish>) =>
|
||||
StepFinish.make({
|
||||
...input,
|
||||
usage: input.usage === undefined ? undefined : input.usage instanceof Usage ? input.usage : new Usage(input.usage),
|
||||
usage: input.usage === undefined ? undefined : Usage.from(input.usage),
|
||||
}),
|
||||
requestFinish: (input: WithUsage<RequestFinish>) =>
|
||||
RequestFinish.make({
|
||||
finish: (input: WithUsage<Finish>) =>
|
||||
Finish.make({
|
||||
...input,
|
||||
usage: input.usage === undefined ? undefined : input.usage instanceof Usage ? input.usage : new Usage(input.usage),
|
||||
usage: input.usage === undefined ? undefined : Usage.from(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"],
|
||||
@@ -282,7 +277,7 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
||||
toolResult: llmEventTagged.guards["tool-result"],
|
||||
toolError: llmEventTagged.guards["tool-error"],
|
||||
stepFinish: llmEventTagged.guards["step-finish"],
|
||||
requestFinish: llmEventTagged.guards["request-finish"],
|
||||
finish: llmEventTagged.guards.finish,
|
||||
providerError: llmEventTagged.guards["provider-error"],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ToolFailure,
|
||||
ToolResultPart,
|
||||
type ToolResultValue,
|
||||
Usage,
|
||||
} from "./schema"
|
||||
import { type AnyTool, type ExecutableTools, type Tools, toDefinitions } from "./tool"
|
||||
|
||||
@@ -72,19 +73,42 @@ 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): Stream.Stream<LLMEvent, LLMError> =>
|
||||
const loop = (
|
||||
request: LLMRequest,
|
||||
step: number,
|
||||
usage: Usage | undefined,
|
||||
providerMetadata: ProviderMetadata | undefined,
|
||||
): Stream.Stream<LLMEvent, LLMError> =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const state: StepState = { assistantContent: [], toolCalls: [], finishReason: undefined }
|
||||
const state: StepState = {
|
||||
assistantContent: [],
|
||||
toolCalls: [],
|
||||
finishReason: undefined,
|
||||
usage: undefined,
|
||||
providerMetadata: 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* () {
|
||||
if (state.finishReason !== "tool-calls" || state.toolCalls.length === 0) return Stream.empty
|
||||
if (options.toolExecution === "none") return Stream.empty
|
||||
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
|
||||
|
||||
const dispatched = yield* Effect.forEach(
|
||||
state.toolCalls,
|
||||
@@ -93,10 +117,14 @@ 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
|
||||
if (options.stopWhen({ step, request })) return resultStream
|
||||
if (!options.stopWhen) return resultStream.pipe(Stream.concat(finishStream))
|
||||
if (options.stopWhen({ step, request })) return resultStream.pipe(Stream.concat(finishStream))
|
||||
|
||||
return resultStream.pipe(Stream.concat(loop(followUpRequest(request, state, dispatched), step + 1)))
|
||||
return resultStream.pipe(
|
||||
Stream.concat(
|
||||
loop(followUpRequest(request, state, dispatched), step + 1, totalUsage, totalProviderMetadata),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -104,13 +132,21 @@ export const stream = <T extends Tools>(options: StreamOptions<T>): Stream.Strea
|
||||
}),
|
||||
)
|
||||
|
||||
return loop(initialRequest, 0)
|
||||
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
|
||||
}
|
||||
|
||||
interface StepState {
|
||||
assistantContent: ContentPart[]
|
||||
toolCalls: ToolCallPart[]
|
||||
finishReason: FinishReason | undefined
|
||||
usage: Usage | undefined
|
||||
providerMetadata: ProviderMetadata | undefined
|
||||
}
|
||||
|
||||
const accumulate = (state: StepState, event: LLMEvent) => {
|
||||
@@ -154,9 +190,43 @@ const accumulate = (state: StepState, event: LLMEvent) => {
|
||||
)
|
||||
return
|
||||
}
|
||||
if (event.type === "step-finish" || event.type === "request-finish") {
|
||||
if (event.type === "step-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) =>
|
||||
@@ -200,17 +270,17 @@ const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<ToolResultVal
|
||||
if (!tool.execute)
|
||||
return Effect.succeed({ type: "error" as const, value: `Tool has no execute handler: ${call.name}` })
|
||||
|
||||
return decodeAndExecute(tool, call.input).pipe(
|
||||
return decodeAndExecute(tool, call).pipe(
|
||||
Effect.catchTag("LLM.ToolFailure", (failure) =>
|
||||
Effect.succeed({ type: "error" as const, value: failure.message } satisfies ToolResultValue),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const decodeAndExecute = (tool: AnyTool, input: unknown): Effect.Effect<ToolResultValue, ToolFailure> =>
|
||||
tool._decode(input).pipe(
|
||||
const decodeAndExecute = (tool: AnyTool, call: ToolCallPart): Effect.Effect<ToolResultValue, ToolFailure> =>
|
||||
tool._decode(call.input).pipe(
|
||||
Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })),
|
||||
Effect.flatMap((decoded) => tool.execute!(decoded)),
|
||||
Effect.flatMap((decoded) => tool.execute!(decoded, { id: call.id, name: call.name })),
|
||||
Effect.flatMap((value) =>
|
||||
tool._encode(value).pipe(
|
||||
Effect.mapError(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect, JsonSchema, Schema } from "effect"
|
||||
import type { ToolDefinition as ToolDefinitionClass } from "./schema"
|
||||
import type { ToolCallPart, ToolDefinition as ToolDefinitionClass } from "./schema"
|
||||
import { ToolDefinition, ToolFailure } from "./schema"
|
||||
|
||||
/**
|
||||
@@ -8,9 +8,14 @@ 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,
|
||||
) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
|
||||
|
||||
/**
|
||||
@@ -61,7 +66,7 @@ type TypedToolConfig = {
|
||||
type DynamicToolConfig = {
|
||||
readonly description: string
|
||||
readonly jsonSchema: JsonSchema.JsonSchema
|
||||
readonly execute?: (params: unknown) => Effect.Effect<unknown, ToolFailure>
|
||||
readonly execute?: (params: unknown, context?: ToolExecuteContext) => Effect.Effect<unknown, ToolFailure>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,7 +115,7 @@ 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) => Effect.Effect<unknown, ToolFailure>
|
||||
readonly execute: (params: unknown, context?: ToolExecuteContext) => Effect.Effect<unknown, ToolFailure>
|
||||
}): AnyExecutableTool
|
||||
export function make(config: {
|
||||
readonly description: string
|
||||
|
||||
@@ -51,7 +51,7 @@ const request = LLM.request({
|
||||
|
||||
const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent =>
|
||||
event.type === "finish"
|
||||
? { type: "request-finish", reason: event.reason }
|
||||
? { type: "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", "request-finish"])
|
||||
expect(response.events.map((event) => event.type)).toEqual(["text-delta", "request-finish"])
|
||||
expect(events.map((event) => event.type)).toEqual(["text-delta", "finish"])
|
||||
expect(response.events.map((event) => event.type)).toEqual(["text-delta", "finish"])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ describe("llm constructors", () => {
|
||||
LLMResponse.text({
|
||||
events: [
|
||||
{ type: "text-delta", id: "text-0", text: "hi" },
|
||||
{ type: "request-finish", reason: "stop" },
|
||||
{ type: "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: "request-finish",
|
||||
type: "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: "request-finish",
|
||||
type: "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: "request-finish", reason: "stop" })
|
||||
expect(response.events.at(-1)).toMatchObject({ type: "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 === "request-finish")
|
||||
const finishes = response.events.filter((event) => event.type === "finish")
|
||||
// Bedrock splits the finish across `messageStop` (carries reason) and
|
||||
// `metadata` (carries usage). We consolidate them into a single
|
||||
// terminal `request-finish` event with both.
|
||||
// terminal `finish` event with both.
|
||||
expect(finishes).toHaveLength(1)
|
||||
expect(finishes[0]).toMatchObject({ type: "request-finish", reason: "stop" })
|
||||
expect(finishes[0]).toMatchObject({ type: "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: "request-finish", reason: "tool-calls" })
|
||||
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -232,7 +232,7 @@ describe("Gemini route", () => {
|
||||
{ type: "text-end", id: "text-0" },
|
||||
{ type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined },
|
||||
{
|
||||
type: "request-finish",
|
||||
type: "finish",
|
||||
reason: "stop",
|
||||
usage,
|
||||
},
|
||||
@@ -291,7 +291,7 @@ describe("Gemini route", () => {
|
||||
},
|
||||
{ type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
|
||||
{
|
||||
type: "request-finish",
|
||||
type: "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: "request-finish", reason: "tool-calls" })
|
||||
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -344,10 +344,10 @@ describe("Gemini route", () => {
|
||||
),
|
||||
)
|
||||
|
||||
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" })
|
||||
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" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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: "request-finish",
|
||||
type: "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: "request-finish", reason: "tool-calls", usage: undefined },
|
||||
{ type: "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: "request-finish", reason: "stop" })
|
||||
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -366,7 +366,7 @@ describe("OpenAI Responses route", () => {
|
||||
usage,
|
||||
},
|
||||
{
|
||||
type: "request-finish",
|
||||
type: "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: "request-finish",
|
||||
type: "finish",
|
||||
reason: "tool-calls",
|
||||
providerMetadata: undefined,
|
||||
usage,
|
||||
|
||||
@@ -120,8 +120,8 @@ export const runWeatherToolLoop = (request: LLMRequest) =>
|
||||
|
||||
export const expectFinish = (
|
||||
events: ReadonlyArray<LLMEvent>,
|
||||
reason: Extract<LLMEvent, { readonly type: "request-finish" }>["reason"],
|
||||
) => expect(events.at(-1)).toMatchObject({ type: "request-finish", reason })
|
||||
reason: Extract<LLMEvent, { readonly type: "finish" }>["reason"],
|
||||
) => expect(events.at(-1)).toMatchObject({ type: "finish", reason })
|
||||
|
||||
export const expectWeatherToolCall = (response: LLMResponse) =>
|
||||
expect(response.toolCalls).toMatchObject([
|
||||
@@ -129,10 +129,12 @@ export const expectWeatherToolCall = (response: LLMResponse) =>
|
||||
])
|
||||
|
||||
export const expectWeatherToolLoop = (events: ReadonlyArray<LLMEvent>) => {
|
||||
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 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 toolCalls = events.filter(LLMEvent.is.toolCall)
|
||||
expect(toolCalls).toHaveLength(1)
|
||||
@@ -272,7 +274,7 @@ export const eventSummary = (events: ReadonlyArray<LLMEvent>) => {
|
||||
summary.push({ type: "tool-error", name: event.name, message: event.message })
|
||||
continue
|
||||
}
|
||||
if (event.type === "request-finish") {
|
||||
if (event.type === "finish") {
|
||||
summary.push({ type: "finish", reason: event.reason, usage: usageSummary(event.usage) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,11 @@ 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)
|
||||
|
||||
@@ -4,7 +4,8 @@ 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 } from "../src/tool"
|
||||
import { tool, ToolFailure, type ToolExecuteContext } from "../src/tool"
|
||||
import { ToolRuntime } from "../src/tool-runtime"
|
||||
import { it } from "./lib/effect"
|
||||
import * as TestToolRuntime from "./lib/tool-runtime"
|
||||
import { dynamicResponse, scriptedResponses } from "./lib/http"
|
||||
@@ -129,7 +130,7 @@ describe("LLMClient tools", () => {
|
||||
name: "get_weather",
|
||||
result: { type: "json", value: { temperature: 22, condition: "sunny" } },
|
||||
})
|
||||
expect(events.at(-1)?.type).toBe("request-finish")
|
||||
expect(events.at(-1)?.type).toBe("finish")
|
||||
expect(LLMResponse.text({ events })).toBe("It's sunny in Paris.")
|
||||
}),
|
||||
)
|
||||
@@ -148,11 +149,40 @@ describe("LLMClient tools", () => {
|
||||
),
|
||||
)
|
||||
|
||||
expect(events.filter(LLMEvent.is.requestFinish)).toHaveLength(1)
|
||||
expect(events.filter(LLMEvent.is.finish)).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([
|
||||
@@ -319,7 +349,7 @@ describe("LLMClient tools", () => {
|
||||
"text-delta",
|
||||
"text-end",
|
||||
"step-finish",
|
||||
"request-finish",
|
||||
"finish",
|
||||
])
|
||||
expect(LLMResponse.text({ events })).toBe("Done.")
|
||||
}),
|
||||
@@ -343,7 +373,57 @@ describe("LLMClient tools", () => {
|
||||
),
|
||||
)
|
||||
|
||||
expect(events.filter(LLMEvent.is.requestFinish)).toHaveLength(2)
|
||||
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,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -362,7 +442,7 @@ describe("LLMClient tools", () => {
|
||||
}).pipe(Stream.runCollect, Effect.provide(layer)),
|
||||
)
|
||||
|
||||
expect(events.filter(LLMEvent.is.requestFinish)).toHaveLength(1)
|
||||
expect(events.filter(LLMEvent.is.finish)).toHaveLength(1)
|
||||
expect(events.find(LLMEvent.is.toolResult)).toMatchObject({ type: "tool-result", id: "call_1" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -105,7 +105,6 @@
|
||||
"@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:*",
|
||||
|
||||
@@ -1094,8 +1094,8 @@ export class Agent implements ACPAgent {
|
||||
|
||||
const currentModeId = await (async () => {
|
||||
if (!availableModes.length) return undefined
|
||||
const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent()))
|
||||
const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id
|
||||
const defaultAgent = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultInfo()))
|
||||
const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgent.name)?.id ?? availableModes[0].id
|
||||
this.sessionManager.setMode(sessionId, resolvedModeId)
|
||||
return resolvedModeId
|
||||
})()
|
||||
@@ -1328,7 +1328,8 @@ 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.defaultAgent())))
|
||||
const agent =
|
||||
session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultInfo()))).name
|
||||
|
||||
const parts: Array<
|
||||
| { type: "text"; text: string; synthetic?: boolean; ignored?: boolean }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigPermission } from "@/config/permission"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "../provider/schema"
|
||||
import { generateObject, streamObject, type ModelMessage } from "ai"
|
||||
@@ -57,6 +58,7 @@ 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
|
||||
@@ -116,7 +118,10 @@ export const layer = Layer.effect(
|
||||
},
|
||||
})
|
||||
|
||||
const user = Permission.fromConfig(cfg.permission ?? {})
|
||||
// Convert permission layers to rulesets and merge them
|
||||
// Each layer's rules come after the previous, so later configs override earlier ones
|
||||
const layers = ConfigPermission.toLayers(cfg.permission)
|
||||
const user = Permission.merge(...layers.map((p) => Permission.fromConfig(p)))
|
||||
|
||||
const agents: Record<string, Info> = {
|
||||
build: {
|
||||
@@ -333,23 +338,28 @@ export const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
const defaultAgent = Effect.fnUntraced(function* () {
|
||||
const defaultInfo = 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.name
|
||||
return agent
|
||||
}
|
||||
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.name
|
||||
return visible
|
||||
})
|
||||
|
||||
const defaultAgent = Effect.fnUntraced(function* () {
|
||||
return (yield* defaultInfo()).name
|
||||
})
|
||||
|
||||
return {
|
||||
get,
|
||||
list,
|
||||
defaultInfo,
|
||||
defaultAgent,
|
||||
} satisfies State
|
||||
}),
|
||||
@@ -362,6 +372,9 @@ 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())
|
||||
}),
|
||||
|
||||
@@ -124,6 +124,7 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* (
|
||||
yield* put(saveProvider, {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
...(result.metadata ? { metadata: result.metadata } : {}),
|
||||
})
|
||||
}
|
||||
yield* spinner.stop("Login successful")
|
||||
@@ -156,6 +157,7 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* (
|
||||
yield* put(saveProvider, {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
...(result.metadata ? { metadata: result.metadata } : {}),
|
||||
})
|
||||
}
|
||||
yield* Prompt.log.success("Login successful")
|
||||
@@ -191,10 +193,11 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* (
|
||||
}
|
||||
if (result.type === "success") {
|
||||
const saveProvider = result.provider ?? provider
|
||||
const merged = { ...(metadata.metadata ?? {}), ...(result.metadata ?? {}) }
|
||||
yield* put(saveProvider, {
|
||||
type: "api",
|
||||
key: result.key ?? apiKey,
|
||||
...metadata,
|
||||
...(Object.keys(merged).length ? { metadata: merged } : {}),
|
||||
})
|
||||
yield* Prompt.log.success("Login successful")
|
||||
}
|
||||
|
||||
@@ -78,8 +78,8 @@ export function FormatError(input: unknown) {
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
// UICancelledError: void (no data)
|
||||
if (NamedError.hasName(input, "UICancelledError")) {
|
||||
// UICancelledError: user cancelled an interactive CLI prompt
|
||||
if (isTaggedError(input, "UICancelledError") || NamedError.hasName(input, "UICancelledError")) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { EOL } from "os"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { Schema } from "effect"
|
||||
import { logo as glyphs } from "./logo"
|
||||
|
||||
@@ -10,7 +9,7 @@ const wordmark = [
|
||||
`▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`,
|
||||
]
|
||||
|
||||
export const CancelledError = NamedError.create("UICancelledError", Schema.optional(Schema.Void))
|
||||
export class CancelledError extends Schema.TaggedErrorClass<CancelledError>()("UICancelledError", {}) {}
|
||||
|
||||
export const Style = {
|
||||
TEXT_HIGHLIGHT: "\x1b[96m",
|
||||
|
||||
@@ -55,6 +55,16 @@ function mergeConfigConcatArrays(target: Info, source: Info): Info {
|
||||
if (target.instructions && source.instructions) {
|
||||
merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions]))
|
||||
}
|
||||
// Accumulate permission layers for later merging as rulesets.
|
||||
// This preserves the ordering semantics: later rules override earlier rules.
|
||||
// Each layer keeps the raw shape the user wrote on disk; consumers should use
|
||||
// ConfigPermission.toLayers to normalise.
|
||||
if (source.permission) {
|
||||
merged.permission = [
|
||||
...ConfigPermission.toLayers(target.permission),
|
||||
...ConfigPermission.toLayers(source.permission),
|
||||
]
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
@@ -228,7 +238,12 @@ export const Info = Schema.Struct({
|
||||
description: "Additional instruction files or patterns to include",
|
||||
}),
|
||||
layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
|
||||
permission: Schema.optional(ConfigPermission.Info),
|
||||
permission: Schema.optional(
|
||||
Schema.Union([ConfigPermission.Info, Schema.mutable(Schema.Array(ConfigPermission.Info))]),
|
||||
).annotate({
|
||||
description:
|
||||
"Permission configuration. Accepts a single object (per-tool action map) or an array of layered configs; arrays are merged in order so later layers override earlier ones.",
|
||||
}),
|
||||
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
|
||||
attachment: Schema.optional(ConfigAttachment.Info).annotate({
|
||||
description: "Attachment processing configuration, including image size limits and resizing behavior",
|
||||
@@ -261,10 +276,10 @@ export const Info = Schema.Struct({
|
||||
}),
|
||||
tail_turns: Schema.optional(NonNegativeInt).annotate({
|
||||
description:
|
||||
"Number of recent user turns, including their following assistant/tool responses, to serialize into the compaction summary (default: 2)",
|
||||
"Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2)",
|
||||
}),
|
||||
preserve_recent_tokens: Schema.optional(NonNegativeInt).annotate({
|
||||
description: "Maximum number of tokens from recent turns to serialize into the compaction summary",
|
||||
description: "Maximum number of tokens from recent turns to preserve verbatim after compaction",
|
||||
}),
|
||||
reserved: Schema.optional(NonNegativeInt).annotate({
|
||||
description: "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.",
|
||||
@@ -704,11 +719,12 @@ export const layer = Layer.effect(
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_PERMISSION) {
|
||||
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
|
||||
const envPermission = JSON.parse(Flag.OPENCODE_PERMISSION) as ConfigPermission.Info
|
||||
result.permission = [...ConfigPermission.toLayers(result.permission), envPermission]
|
||||
}
|
||||
|
||||
if (result.tools) {
|
||||
const perms: Record<string, ConfigPermission.Action> = {}
|
||||
const perms: ConfigPermission.Info = {}
|
||||
for (const [tool, enabled] of Object.entries(result.tools)) {
|
||||
const action: ConfigPermission.Action = enabled ? "allow" : "deny"
|
||||
if (tool === "write" || tool === "edit" || tool === "patch") {
|
||||
@@ -717,7 +733,8 @@ export const layer = Layer.effect(
|
||||
}
|
||||
perms[tool] = action
|
||||
}
|
||||
result.permission = mergeDeep(perms, result.permission ?? {})
|
||||
// Tools permissions come before other permissions (they can be overridden)
|
||||
result.permission = [perms, ...ConfigPermission.toLayers(result.permission)]
|
||||
}
|
||||
|
||||
if (!result.username) result.username = os.userInfo().username
|
||||
|
||||
@@ -56,3 +56,11 @@ export const Info = InputSchema.pipe(
|
||||
).annotate({ identifier: "PermissionConfig" })
|
||||
type _Info = Schema.Schema.Type<typeof InputObject>
|
||||
export type Info = { -readonly [K in keyof _Info]: _Info[K] }
|
||||
|
||||
// Top-level config accepts either a single permission object or an array of
|
||||
// layered configs. Internal merging produces arrays; this helper normalises
|
||||
// either shape into the array form expected by consumers.
|
||||
export function toLayers(value: Info | Info[] | undefined): Info[] {
|
||||
if (!value) return []
|
||||
return Array.isArray(value) ? value : [value]
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ export const layer = Layer.effect(
|
||||
tokens_reasoning: value.tokens.reasoning,
|
||||
tokens_cache_read: value.tokens.cache.read,
|
||||
tokens_cache_write: value.tokens.cache.write,
|
||||
time_updated: sql`${SessionTable.time_updated}`,
|
||||
})
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.run()
|
||||
@@ -145,7 +146,9 @@ export const layer = Layer.effect(
|
||||
)
|
||||
}
|
||||
}).pipe(
|
||||
Effect.tapCause((cause) => Effect.logError("failed to run data migrations", { cause })),
|
||||
Effect.tapCause((cause) =>
|
||||
Effect.logError("failed to run data migrations").pipe(Effect.annotateLogs("cause", cause)),
|
||||
),
|
||||
Effect.ignore,
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
@@ -181,7 +181,7 @@ export const make = <A, E = never>(
|
||||
return [
|
||||
Effect.gen(function* () {
|
||||
yield* Fiber.interrupt(st.run.fiber)
|
||||
yield* Deferred.await(st.run.done).pipe(Effect.exit, Effect.asVoid)
|
||||
yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid)
|
||||
yield* idleIfCurrent()
|
||||
}),
|
||||
{ _tag: "Idle" } as const,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
||||
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import {
|
||||
CallToolResultSchema,
|
||||
ListToolsResultSchema,
|
||||
ToolSchema,
|
||||
type Tool as MCPToolDef,
|
||||
ToolListChangedNotificationSchema,
|
||||
@@ -14,7 +15,6 @@ import { Config } from "@/config/config"
|
||||
import { ConfigMCP } from "../config/mcp"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import z from "zod/v4"
|
||||
import { Installation } from "../installation"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { withTimeout } from "@/util/timeout"
|
||||
@@ -35,13 +35,8 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
const log = Log.create({ service: "mcp" })
|
||||
const DEFAULT_TIMEOUT = 30_000
|
||||
|
||||
const TolerantToolSchema = ToolSchema.extend({
|
||||
outputSchema: z.unknown().optional(),
|
||||
})
|
||||
|
||||
const TolerantListToolsResultSchema = z.looseObject({
|
||||
tools: z.array(TolerantToolSchema),
|
||||
nextCursor: z.string().optional(),
|
||||
const TolerantListToolsResultSchema = ListToolsResultSchema.extend({
|
||||
tools: ToolSchema.omit({ outputSchema: true }).array(),
|
||||
})
|
||||
|
||||
export const Resource = Schema.Struct({
|
||||
@@ -137,7 +132,10 @@ function listTools(key: string, client: MCPClient, timeout: number) {
|
||||
|
||||
log.warn("failed to validate MCP tool output schemas, retrying without output schema validation", { key, error })
|
||||
return Effect.tryPromise({
|
||||
try: () => client.request({ method: "tools/list" }, TolerantListToolsResultSchema, { timeout }),
|
||||
try: () =>
|
||||
client.request({ method: "tools/list" }, TolerantListToolsResultSchema, {
|
||||
timeout,
|
||||
}),
|
||||
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
|
||||
}).pipe(
|
||||
Effect.map((result) =>
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
import type { Model } from "@opencode-ai/sdk/v2"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { createServer } from "http"
|
||||
|
||||
const log = Log.create({ service: "plugin.digitalocean" })
|
||||
|
||||
const DO_OAUTH_CLIENT_ID = "b1a6c5158156caac821fd1b30253ca8acb52454a48fa744420e41889cb589f82"
|
||||
const DO_AUTHORIZE_URL = "https://cloud.digitalocean.com/v1/oauth/authorize"
|
||||
const DO_API_BASE = "https://api.digitalocean.com"
|
||||
const DO_INFERENCE_BASE = "https://inference.do-ai.run/v1"
|
||||
const OAUTH_PORT = 1456
|
||||
const OAUTH_REDIRECT_PATH = "/auth/callback"
|
||||
const OAUTH_TOKEN_PATH = "/auth/token"
|
||||
const ROUTER_REFRESH_INTERVAL_MS = 5 * 60 * 1000
|
||||
const MAK_NAME_PREFIX = "opencode-oauth"
|
||||
|
||||
interface ImplicitTokenPayload {
|
||||
access_token: string
|
||||
expires_in: number
|
||||
state: string
|
||||
}
|
||||
|
||||
interface PendingOAuth {
|
||||
state: string
|
||||
resolve: (tokens: ImplicitTokenPayload) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
interface ApiKeyInfo {
|
||||
uuid: string
|
||||
name: string
|
||||
secret_key: string
|
||||
}
|
||||
|
||||
interface RouterEntry {
|
||||
name: string
|
||||
uuid?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
let oauthServer: ReturnType<typeof createServer> | undefined
|
||||
let pendingOAuth: PendingOAuth | undefined
|
||||
|
||||
function generateState(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(32))
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
}
|
||||
|
||||
function redirectUri(): string {
|
||||
return `http://localhost:${OAUTH_PORT}${OAUTH_REDIRECT_PATH}`
|
||||
}
|
||||
|
||||
function buildAuthorizeUrl(state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
response_type: "token",
|
||||
client_id: DO_OAUTH_CLIENT_ID,
|
||||
redirect_uri: redirectUri(),
|
||||
scope: "read write",
|
||||
state,
|
||||
})
|
||||
return `${DO_AUTHORIZE_URL}?${params.toString()}`
|
||||
}
|
||||
|
||||
const HTML_CALLBACK = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>OpenCode - DigitalOcean Authorization</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #0b1220; color: #e8eef9; }
|
||||
.container { text-align: center; padding: 2rem; max-width: 32rem; }
|
||||
h1 { color: #e8eef9; margin-bottom: 1rem; }
|
||||
p { color: #9aa9c0; }
|
||||
.error { color: #ff917b; font-family: monospace; margin-top: 1rem; padding: 1rem; background: #3c140d; border-radius: 0.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1 id="title">Finishing sign-in...</h1>
|
||||
<p id="msg">You can close this window once it says you're signed in.</p>
|
||||
</div>
|
||||
<script>
|
||||
(async function() {
|
||||
const params = new URLSearchParams((window.location.hash || "").slice(1))
|
||||
const search = new URLSearchParams(window.location.search)
|
||||
const error = params.get("error") || search.get("error")
|
||||
const errorDescription = params.get("error_description") || search.get("error_description")
|
||||
const titleEl = document.getElementById("title")
|
||||
const msgEl = document.getElementById("msg")
|
||||
try {
|
||||
const body = error
|
||||
? { error, error_description: errorDescription || "" }
|
||||
: { access_token: params.get("access_token") || "", expires_in: params.get("expires_in") || "0", state: params.get("state") || "" }
|
||||
await fetch(${JSON.stringify(OAUTH_TOKEN_PATH)}, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (error) {
|
||||
titleEl.textContent = "Authorization Failed"
|
||||
msgEl.textContent = errorDescription || error
|
||||
msgEl.className = "error"
|
||||
return
|
||||
}
|
||||
titleEl.textContent = "Authorization Successful"
|
||||
msgEl.textContent = "You can close this window and return to OpenCode."
|
||||
setTimeout(function () { window.close() }, 2000)
|
||||
} catch (e) {
|
||||
titleEl.textContent = "Authorization Failed"
|
||||
msgEl.textContent = String(e && e.message ? e.message : e)
|
||||
msgEl.className = "error"
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
async function startOAuthServer(): Promise<void> {
|
||||
if (oauthServer) return
|
||||
oauthServer = createServer((req, res) => {
|
||||
const url = new URL(req.url || "/", `http://localhost:${OAUTH_PORT}`)
|
||||
|
||||
if (req.method === "GET" && url.pathname === OAUTH_REDIRECT_PATH) {
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_CALLBACK)
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === OAUTH_TOKEN_PATH) {
|
||||
const chunks: Buffer[] = []
|
||||
req.on("data", (chunk: Buffer) => chunks.push(chunk))
|
||||
req.on("end", () => {
|
||||
const raw = Buffer.concat(chunks).toString("utf8")
|
||||
let body: Record<string, string> = {}
|
||||
try {
|
||||
body = raw ? JSON.parse(raw) : {}
|
||||
} catch {
|
||||
body = {}
|
||||
}
|
||||
if (!pendingOAuth) {
|
||||
res.writeHead(409, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ error: "no_pending_oauth" }))
|
||||
return
|
||||
}
|
||||
if (body.error) {
|
||||
const message = body.error_description || body.error || "OAuth error"
|
||||
pendingOAuth.reject(new Error(String(message)))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(200, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ ok: true }))
|
||||
return
|
||||
}
|
||||
if (!body.access_token) {
|
||||
pendingOAuth.reject(new Error("Missing access_token in callback"))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(400, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ error: "missing_access_token" }))
|
||||
return
|
||||
}
|
||||
if (body.state !== pendingOAuth.state) {
|
||||
pendingOAuth.reject(new Error("Invalid state - potential CSRF attack"))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(400, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ error: "invalid_state" }))
|
||||
return
|
||||
}
|
||||
const expires = parseInt(body.expires_in || "0", 10)
|
||||
pendingOAuth.resolve({
|
||||
access_token: body.access_token,
|
||||
expires_in: Number.isFinite(expires) && expires > 0 ? expires : 60 * 60 * 24 * 30,
|
||||
state: body.state,
|
||||
})
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(200, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ ok: true }))
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
res.writeHead(404)
|
||||
res.end("Not found")
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
oauthServer!.listen(OAUTH_PORT, () => {
|
||||
log.info("digitalocean oauth server started", { port: OAUTH_PORT })
|
||||
resolve()
|
||||
})
|
||||
oauthServer!.on("error", reject)
|
||||
})
|
||||
}
|
||||
|
||||
function stopOAuthServer() {
|
||||
if (!oauthServer) return
|
||||
oauthServer.close(() => log.info("digitalocean oauth server stopped"))
|
||||
oauthServer = undefined
|
||||
}
|
||||
|
||||
function waitForOAuthCallback(state: string): Promise<ImplicitTokenPayload> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => {
|
||||
if (pendingOAuth) {
|
||||
pendingOAuth = undefined
|
||||
reject(new Error("OAuth callback timeout - authorization took too long"))
|
||||
}
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
)
|
||||
pendingOAuth = {
|
||||
state,
|
||||
resolve: (tokens) => {
|
||||
clearTimeout(timeout)
|
||||
resolve(tokens)
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function createModelAccessKey(bearer: string): Promise<ApiKeyInfo> {
|
||||
// Suffix-on-collision strategy keeps re-`/connect` non-destructive.
|
||||
const name = `${MAK_NAME_PREFIX}-${Math.floor(Date.now() / 1000)}`
|
||||
const res = await fetch(`${DO_API_BASE}/v2/gen-ai/models/api_keys`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${bearer}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
},
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "")
|
||||
throw new Error(`Failed to create Model Access Key (${res.status}): ${body}`)
|
||||
}
|
||||
const data = (await res.json()) as { api_key_info?: ApiKeyInfo }
|
||||
if (!data.api_key_info?.secret_key) throw new Error("Model Access Key response missing secret_key")
|
||||
return data.api_key_info
|
||||
}
|
||||
|
||||
async function listRouters(
|
||||
bearer: string,
|
||||
): Promise<{ ok: true; routers: RouterEntry[] } | { ok: false; status: number }> {
|
||||
const res = await fetch(`${DO_API_BASE}/v2/gen-ai/models/routers`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${bearer}`,
|
||||
Accept: "application/json",
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
}).catch(() => undefined)
|
||||
if (!res) return { ok: false, status: 0 }
|
||||
if (!res.ok) return { ok: false, status: res.status }
|
||||
const body = (await res.json().catch(() => undefined)) as { model_routers?: RouterEntry[] } | undefined
|
||||
return { ok: true, routers: body?.model_routers ?? [] }
|
||||
}
|
||||
|
||||
function routerModel(router: RouterEntry, providerID: string): Model {
|
||||
const id = `router:${router.name}`
|
||||
return {
|
||||
id,
|
||||
providerID,
|
||||
name: router.name,
|
||||
family: "digitalocean-inference-routers",
|
||||
api: { id, url: DO_INFERENCE_BASE, npm: "@ai-sdk/openai-compatible" },
|
||||
status: "active",
|
||||
headers: {},
|
||||
options: {},
|
||||
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||
limit: { context: 128_000, output: 8_192 },
|
||||
capabilities: {
|
||||
temperature: true,
|
||||
reasoning: false,
|
||||
attachment: false,
|
||||
toolcall: true,
|
||||
input: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
interleaved: false,
|
||||
},
|
||||
release_date: "",
|
||||
variants: {},
|
||||
}
|
||||
}
|
||||
|
||||
function parseRoutersJSON(raw: string | undefined): RouterEntry[] {
|
||||
if (!raw) return []
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed.flatMap((r) =>
|
||||
r && typeof r.name === "string" ? [{ name: r.name, uuid: r.uuid, description: r.description }] : [],
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function DigitalOceanAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
return {
|
||||
provider: {
|
||||
id: "digitalocean",
|
||||
async models(provider, ctx) {
|
||||
const baseModels = provider.models
|
||||
if (ctx.auth?.type !== "api") return baseModels
|
||||
|
||||
const metadata = ctx.auth.metadata ?? {}
|
||||
const oauthAccess = metadata["oauth_access"]
|
||||
const oauthExpires = parseInt(metadata["oauth_expires"] || "0", 10)
|
||||
const fetchedAt = parseInt(metadata["routers_fetched_at"] || "0", 10)
|
||||
const cached = parseRoutersJSON(metadata["routers"])
|
||||
|
||||
let routers = cached
|
||||
const stale = Date.now() - fetchedAt > ROUTER_REFRESH_INTERVAL_MS
|
||||
const bearerValid = oauthAccess && oauthExpires > Date.now()
|
||||
|
||||
if (bearerValid && stale) {
|
||||
const result = await listRouters(oauthAccess)
|
||||
if (result.ok) {
|
||||
routers = result.routers
|
||||
const updated: Record<string, string> = {
|
||||
...metadata,
|
||||
routers: JSON.stringify(routers.map((r) => ({ name: r.name, uuid: r.uuid, description: r.description }))),
|
||||
routers_fetched_at: String(Date.now()),
|
||||
}
|
||||
await input.client.auth
|
||||
.set({
|
||||
path: { id: "digitalocean" },
|
||||
body: { type: "api", key: ctx.auth.key, metadata: updated },
|
||||
})
|
||||
.catch((err) => log.warn("failed to persist refreshed routers", { error: err }))
|
||||
} else if (result.status === 401 || result.status === 403) {
|
||||
log.warn("digitalocean oauth bearer rejected; using cached routers", { status: result.status })
|
||||
} else if (result.status !== 0) {
|
||||
log.warn("digitalocean router refresh failed", { status: result.status })
|
||||
}
|
||||
}
|
||||
|
||||
const merged: Record<string, Model> = { ...baseModels }
|
||||
for (const router of routers) {
|
||||
const id = `router:${router.name}`
|
||||
if (merged[id]) continue
|
||||
merged[id] = routerModel(router, "digitalocean")
|
||||
}
|
||||
return merged
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
provider: "digitalocean",
|
||||
methods: [
|
||||
{
|
||||
type: "oauth",
|
||||
label: "Login with DigitalOcean",
|
||||
async authorize() {
|
||||
await startOAuthServer()
|
||||
const state = generateState()
|
||||
const callbackPromise = waitForOAuthCallback(state)
|
||||
return {
|
||||
url: buildAuthorizeUrl(state),
|
||||
instructions:
|
||||
"Sign in to DigitalOcean in your browser. OpenCode will create a Model Access Key named opencode-oauth-* and load your Inference Routers. Re-run /connect to refresh routers later.",
|
||||
method: "auto" as const,
|
||||
async callback() {
|
||||
try {
|
||||
const tokens = await callbackPromise
|
||||
const apiKeyInfo = await createModelAccessKey(tokens.access_token)
|
||||
const routerResult = await listRouters(tokens.access_token)
|
||||
const routers = routerResult.ok ? routerResult.routers : []
|
||||
if (!routerResult.ok) {
|
||||
log.warn("digitalocean initial router fetch failed", { status: routerResult.status })
|
||||
}
|
||||
return {
|
||||
type: "success" as const,
|
||||
provider: "digitalocean",
|
||||
key: apiKeyInfo.secret_key,
|
||||
metadata: {
|
||||
mak_uuid: apiKeyInfo.uuid,
|
||||
mak_name: apiKeyInfo.name,
|
||||
oauth_access: tokens.access_token,
|
||||
oauth_expires: String(Date.now() + tokens.expires_in * 1000),
|
||||
routers: JSON.stringify(
|
||||
routers.map((r) => ({ name: r.name, uuid: r.uuid, description: r.description })),
|
||||
),
|
||||
routers_fetched_at: String(Date.now()),
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
log.error("digitalocean oauth callback failed", { error: err })
|
||||
return { type: "failed" as const }
|
||||
} finally {
|
||||
stopOAuthServer()
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "api",
|
||||
label: "Paste Model Access Key",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
|
||||
import { PoeAuthPlugin } from "opencode-poe-auth"
|
||||
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
|
||||
import { AzureAuthPlugin } from "./azure"
|
||||
import { DigitalOceanAuthPlugin } from "./digitalocean"
|
||||
import { Effect, Layer, Context, Stream } from "effect"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
@@ -64,6 +65,7 @@ const INTERNAL_PLUGINS: PluginInstance[] = [
|
||||
CloudflareWorkersAuthPlugin,
|
||||
CloudflareAIGatewayAuthPlugin,
|
||||
AzureAuthPlugin,
|
||||
DigitalOceanAuthPlugin,
|
||||
]
|
||||
|
||||
function isServerPlugin(value: unknown): value is PluginInstance {
|
||||
|
||||
@@ -37,7 +37,7 @@ export const layer = Layer.effect(
|
||||
|
||||
const run = Effect.gen(function* () {
|
||||
const ctx = yield* InstanceState.context
|
||||
yield* Effect.logInfo("bootstrapping", { directory: ctx.directory })
|
||||
yield* Effect.logInfo("bootstrapping").pipe(Effect.annotateLogs("directory", ctx.directory))
|
||||
// everything depends on config so eager load it for nice traces
|
||||
yield* config.get()
|
||||
// Plugin can mutate config so it has to be initialized before anything else.
|
||||
|
||||
@@ -156,7 +156,9 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Deferred.await(item[1].deferred).pipe(Effect.exit)
|
||||
if (Exit.isFailure(exit)) {
|
||||
yield* Effect.logWarning("instance dispose failed", { key: item[0], cause: exit.cause })
|
||||
yield* Effect.logWarning("instance dispose failed").pipe(
|
||||
Effect.annotateLogs({ key: item[0], cause: exit.cause }),
|
||||
)
|
||||
yield* removeEntry(item[0], item[1])
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin"
|
||||
import { Auth } from "@/auth"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { namedSchemaError } from "@/util/named-schema-error"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { optionalOmitUndefined } from "@opencode-ai/core/schema"
|
||||
import { Plugin } from "../plugin"
|
||||
import { ProviderID } from "./schema"
|
||||
@@ -64,13 +64,13 @@ export const CallbackInput = Schema.Struct({
|
||||
})
|
||||
export type CallbackInput = Schema.Schema.Type<typeof CallbackInput>
|
||||
|
||||
export const OauthMissing = namedSchemaError("ProviderAuthOauthMissing", { providerID: ProviderID })
|
||||
export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", { providerID: ProviderID })
|
||||
|
||||
export const OauthCodeMissing = namedSchemaError("ProviderAuthOauthCodeMissing", { providerID: ProviderID })
|
||||
export const OauthCodeMissing = NamedError.create("ProviderAuthOauthCodeMissing", { providerID: ProviderID })
|
||||
|
||||
export const OauthCallbackFailed = namedSchemaError("ProviderAuthOauthCallbackFailed", {})
|
||||
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", {})
|
||||
|
||||
export const ValidationFailed = namedSchemaError("ProviderAuthValidationFailed", {
|
||||
export const ValidationFailed = NamedError.create("ProviderAuthValidationFailed", {
|
||||
field: Schema.String,
|
||||
message: Schema.String,
|
||||
})
|
||||
@@ -197,6 +197,7 @@ export const layer: Layer.Layer<Service, never, Auth.Service | Plugin.Service> =
|
||||
yield* auth.set(input.providerID, {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
...(result.metadata ? { metadata: result.metadata } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -177,7 +177,9 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | HttpClie
|
||||
yield* invalidate
|
||||
}),
|
||||
).pipe(
|
||||
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause })),
|
||||
Effect.tapCause((cause) =>
|
||||
Effect.logError("Failed to fetch models.dev").pipe(Effect.annotateLogs("cause", cause)),
|
||||
),
|
||||
Effect.ignore,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Auth } from "../auth"
|
||||
import { Env } from "../env"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { namedSchemaError } from "@/util/named-schema-error"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import path from "path"
|
||||
@@ -1749,13 +1749,13 @@ export function parseModel(model: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export const ModelNotFoundError = namedSchemaError("ProviderModelNotFoundError", {
|
||||
export const ModelNotFoundError = NamedError.create("ProviderModelNotFoundError", {
|
||||
providerID: ProviderID,
|
||||
modelID: ModelID,
|
||||
suggestions: Schema.optional(Schema.Array(Schema.String)),
|
||||
})
|
||||
|
||||
export const InitError = namedSchemaError("ProviderInitError", {
|
||||
export const InitError = NamedError.create("ProviderInitError", {
|
||||
providerID: ProviderID,
|
||||
})
|
||||
|
||||
|
||||
@@ -169,7 +169,9 @@ export const layer = Layer.effect(
|
||||
).pipe(
|
||||
Effect.asVoid,
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to materialize reference repository", { name: reference.name, cause }),
|
||||
Effect.logWarning("failed to materialize reference repository").pipe(
|
||||
Effect.annotateLogs({ name: reference.name, cause }),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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.get(yield* agents.defaultAgent()),
|
||||
agent: yield* agents.defaultInfo(),
|
||||
})
|
||||
return list.map((item) => ({
|
||||
id: item.id,
|
||||
|
||||
@@ -299,7 +299,9 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
||||
yield* promptSvc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID }).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.logError("prompt_async failed", { sessionID: ctx.params.sessionID, cause })
|
||||
yield* Effect.logError("prompt_async failed").pipe(
|
||||
Effect.annotateLogs({ sessionID: ctx.params.sessionID, cause }),
|
||||
)
|
||||
yield* bus.publish(Session.Event.Error, {
|
||||
sessionID: ctx.params.sessionID,
|
||||
error: new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(),
|
||||
|
||||
@@ -79,10 +79,12 @@ Rules:
|
||||
type Turn = {
|
||||
start: number
|
||||
end: number
|
||||
id: MessageID
|
||||
}
|
||||
|
||||
type Tail = {
|
||||
start: number
|
||||
id: MessageID
|
||||
}
|
||||
|
||||
type CompletedCompaction = {
|
||||
@@ -119,41 +121,19 @@ function completedCompactions(messages: MessageV2.WithParts[]) {
|
||||
})
|
||||
}
|
||||
|
||||
function buildPrompt(input: { previousSummary?: string; context: string[]; tail?: string }) {
|
||||
const source = input.tail
|
||||
? "the conversation history above and the serialized recent conversation tail below"
|
||||
: "the conversation history above"
|
||||
function buildPrompt(input: { previousSummary?: string; context: string[] }) {
|
||||
const anchor = input.previousSummary
|
||||
? [
|
||||
`Update the anchored summary below using ${source}.`,
|
||||
"Update the anchored summary below using the conversation history above.",
|
||||
"Preserve still-true details, remove stale details, and merge in the new facts.",
|
||||
"<previous-summary>",
|
||||
input.previousSummary,
|
||||
"</previous-summary>",
|
||||
].join("\n")
|
||||
: `Create a new anchored summary from ${source}.`
|
||||
const tail = input.tail
|
||||
? [
|
||||
"Fold this serialized recent conversation tail into the summary; it is not provider message history.",
|
||||
"<recent-conversation-tail>",
|
||||
input.tail,
|
||||
"</recent-conversation-tail>",
|
||||
].join("\n")
|
||||
: undefined
|
||||
return [anchor, ...(tail ? [tail] : []), SUMMARY_TEMPLATE, ...input.context].join("\n\n")
|
||||
: "Create a new anchored summary from the conversation history above."
|
||||
return [anchor, SUMMARY_TEMPLATE, ...input.context].join("\n\n")
|
||||
}
|
||||
|
||||
const serialize = Effect.fn("SessionCompaction.serialize")(function* (input: {
|
||||
messages: MessageV2.WithParts[]
|
||||
model: Provider.Model
|
||||
}) {
|
||||
const messages = yield* MessageV2.toModelMessagesEffect(input.messages, input.model, {
|
||||
stripMedia: true,
|
||||
toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS,
|
||||
})
|
||||
return messages.length ? JSON.stringify(messages, null, 2) : undefined
|
||||
})
|
||||
|
||||
function preserveRecentBudget(input: { cfg: Config.Info; model: Provider.Model }) {
|
||||
return (
|
||||
input.cfg.compaction?.preserve_recent_tokens ??
|
||||
@@ -170,6 +150,7 @@ function turns(messages: MessageV2.WithParts[]) {
|
||||
result.push({
|
||||
start: i,
|
||||
end: messages.length,
|
||||
id: msg.info.id,
|
||||
})
|
||||
}
|
||||
for (let i = 0; i < result.length - 1; i++) {
|
||||
@@ -196,6 +177,7 @@ function splitTurn(input: {
|
||||
if (size > input.budget) continue
|
||||
return {
|
||||
start,
|
||||
id: input.messages[start]!.info.id,
|
||||
} satisfies Tail
|
||||
}
|
||||
return undefined
|
||||
@@ -262,7 +244,8 @@ export const layer: Layer.Layer<
|
||||
messages: MessageV2.WithParts[]
|
||||
model: Provider.Model
|
||||
}) {
|
||||
return Token.estimate((yield* serialize(input)) ?? "")
|
||||
const msgs = yield* MessageV2.toModelMessagesEffect(input.messages, input.model)
|
||||
return Token.estimate(JSON.stringify(msgs))
|
||||
})
|
||||
|
||||
const select = Effect.fn("SessionCompaction.select")(function* (input: {
|
||||
@@ -271,10 +254,10 @@ export const layer: Layer.Layer<
|
||||
model: Provider.Model
|
||||
}) {
|
||||
const limit = input.cfg.compaction?.tail_turns ?? DEFAULT_TAIL_TURNS
|
||||
if (limit <= 0) return { head: input.messages, tail: [] }
|
||||
if (limit <= 0) return { head: input.messages, tail_start_id: undefined }
|
||||
const budget = preserveRecentBudget({ cfg: input.cfg, model: input.model })
|
||||
const all = turns(input.messages)
|
||||
if (!all.length) return { head: input.messages, tail: [] }
|
||||
if (!all.length) return { head: input.messages, tail_start_id: undefined }
|
||||
const recent = all.slice(-limit)
|
||||
const sizes = yield* Effect.forEach(
|
||||
recent,
|
||||
@@ -293,7 +276,7 @@ export const layer: Layer.Layer<
|
||||
const size = sizes[i]
|
||||
if (total + size <= budget) {
|
||||
total += size
|
||||
keep = { start: turn.start }
|
||||
keep = { start: turn.start, id: turn.id }
|
||||
continue
|
||||
}
|
||||
const remaining = budget - total
|
||||
@@ -309,10 +292,10 @@ export const layer: Layer.Layer<
|
||||
break
|
||||
}
|
||||
|
||||
if (!keep) return { head: input.messages, tail: [] }
|
||||
if (!keep || keep.start === 0) return { head: input.messages, tail_start_id: undefined }
|
||||
return {
|
||||
head: input.messages.slice(0, keep.start),
|
||||
tail: input.messages.slice(keep.start),
|
||||
tail_start_id: keep.id,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -423,10 +406,7 @@ export const layer: Layer.Layer<
|
||||
{ sessionID: input.sessionID },
|
||||
{ context: [], prompt: undefined },
|
||||
)
|
||||
const tailMessages = structuredClone(selected.tail)
|
||||
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: tailMessages })
|
||||
const tail = yield* serialize({ messages: tailMessages, model })
|
||||
const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context, tail })
|
||||
const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context })
|
||||
const msgs = structuredClone(selected.head)
|
||||
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
||||
const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, {
|
||||
@@ -493,6 +473,13 @@ export const layer: Layer.Layer<
|
||||
return "stop"
|
||||
}
|
||||
|
||||
if (compactionPart && selected.tail_start_id && compactionPart.tail_start_id !== selected.tail_start_id) {
|
||||
yield* session.updatePart({
|
||||
...compactionPart,
|
||||
tail_start_id: selected.tail_start_id,
|
||||
})
|
||||
}
|
||||
|
||||
if (result === "continue" && input.auto) {
|
||||
if (replay) {
|
||||
const original = replay.info
|
||||
@@ -588,6 +575,7 @@ export const layer: Layer.Layer<
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
text: summary ?? "",
|
||||
include: selected.tail_start_id,
|
||||
})
|
||||
}
|
||||
yield* bus.publish(Event.Compacted, { sessionID: input.sessionID })
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
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"
|
||||
@@ -3,7 +3,6 @@ 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 type { LLMEvent } from "@opencode-ai/llm"
|
||||
import { mergeDeep } from "remeda"
|
||||
import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
@@ -25,10 +24,10 @@ 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"
|
||||
|
||||
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> =>
|
||||
@@ -53,8 +52,10 @@ 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<LLMEvent, unknown>
|
||||
readonly stream: (input: StreamInput) => Stream.Stream<Event, unknown>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {}
|
||||
@@ -426,11 +427,7 @@ const live: Layer.Layer<
|
||||
|
||||
const result = yield* run({ ...input, abort: ctrl.signal })
|
||||
|
||||
const state = LLMAISDK.adapterState()
|
||||
return Stream.fromAsyncIterable(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)),
|
||||
)
|
||||
return Stream.fromAsyncIterable(result.fullStream, (e) => (e instanceof Error ? e : new Error(String(e))))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Schema } from "effect"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
|
||||
export const OutputLengthError = NamedError.create("MessageOutputLengthError", {})
|
||||
|
||||
export const AuthError = NamedError.create("ProviderAuthError", {
|
||||
providerID: Schema.String,
|
||||
message: Schema.String,
|
||||
})
|
||||
|
||||
export const Shared = [AuthError.EffectSchema, NamedError.Unknown.EffectSchema, OutputLengthError.EffectSchema] as const
|
||||
export const SharedSchema = Schema.Union(Shared)
|
||||
|
||||
export * as MessageError from "./message-error"
|
||||
@@ -23,8 +23,10 @@ import type { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { Effect, Schema, Types } from "effect"
|
||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||
import { namedSchemaError } from "@/util/named-schema-error"
|
||||
import * as EffectLogger from "@opencode-ai/core/effect/logger"
|
||||
import { MessageError } from "./message-error"
|
||||
import { AuthError, OutputLengthError } from "./message-error"
|
||||
export { AuthError, OutputLengthError } from "./message-error"
|
||||
|
||||
/** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */
|
||||
interface FetchDecompressionError extends Error {
|
||||
@@ -36,17 +38,12 @@ interface FetchDecompressionError extends Error {
|
||||
export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached media from tool result:"
|
||||
export { isMedia }
|
||||
|
||||
export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {})
|
||||
export const AbortedError = namedSchemaError("MessageAbortedError", { message: Schema.String })
|
||||
export const StructuredOutputError = namedSchemaError("StructuredOutputError", {
|
||||
export const AbortedError = NamedError.create("MessageAbortedError", { message: Schema.String })
|
||||
export const StructuredOutputError = NamedError.create("StructuredOutputError", {
|
||||
message: Schema.String,
|
||||
retries: NonNegativeInt,
|
||||
})
|
||||
export const AuthError = namedSchemaError("ProviderAuthError", {
|
||||
providerID: Schema.String,
|
||||
message: Schema.String,
|
||||
})
|
||||
export const APIError = namedSchemaError("APIError", {
|
||||
export const APIError = NamedError.create("APIError", {
|
||||
message: Schema.String,
|
||||
statusCode: Schema.optional(NonNegativeInt),
|
||||
isRetryable: Schema.Boolean,
|
||||
@@ -55,7 +52,7 @@ export const APIError = namedSchemaError("APIError", {
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
})
|
||||
export type APIError = Schema.Schema.Type<typeof APIError.Schema>
|
||||
export const ContextOverflowError = namedSchemaError("ContextOverflowError", {
|
||||
export const ContextOverflowError = NamedError.create("ContextOverflowError", {
|
||||
message: Schema.String,
|
||||
responseBody: Schema.optional(Schema.String),
|
||||
})
|
||||
@@ -381,9 +378,7 @@ export type Part =
|
||||
| CompactionPart
|
||||
|
||||
const AssistantErrorSchema = Schema.Union([
|
||||
AuthError.EffectSchema,
|
||||
NamedError.Unknown.EffectSchema,
|
||||
OutputLengthError.EffectSchema,
|
||||
...MessageError.Shared,
|
||||
AbortedError.EffectSchema,
|
||||
StructuredOutputError.EffectSchema,
|
||||
ContextOverflowError.EffectSchema,
|
||||
@@ -777,13 +772,12 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
|
||||
return part.metadata?.anthropic?.signature != null
|
||||
})
|
||||
for (const part of msg.parts) {
|
||||
if (msg.info.summary && part.type !== "text") continue
|
||||
if (part.type === "text") {
|
||||
const text = part.text === "" && hasSignedReasoning ? " " : part.text
|
||||
assistantMessage.parts.push({
|
||||
type: "text",
|
||||
text,
|
||||
...(differentModel || msg.info.summary ? {} : { providerMetadata: part.metadata }),
|
||||
...(differentModel ? {} : { providerMetadata: part.metadata }),
|
||||
})
|
||||
}
|
||||
if (part.type === "step-start")
|
||||
@@ -1009,16 +1003,53 @@ export function get(input: { sessionID: SessionID; messageID: MessageID }): With
|
||||
export function filterCompacted(msgs: Iterable<WithParts>) {
|
||||
const result = [] as WithParts[]
|
||||
const completed = new Set<string>()
|
||||
let retain: MessageID | undefined
|
||||
for (const msg of msgs) {
|
||||
result.push(msg)
|
||||
if (msg.info.role === "user" && completed.has(msg.info.id)) {
|
||||
if (msg.parts.some((item): item is CompactionPart => item.type === "compaction")) break
|
||||
if (retain) {
|
||||
if (msg.info.id === retain) break
|
||||
continue
|
||||
}
|
||||
if (msg.info.role === "user" && completed.has(msg.info.id)) {
|
||||
const part = msg.parts.find((item): item is CompactionPart => item.type === "compaction")
|
||||
if (!part) continue
|
||||
if (!part.tail_start_id) break
|
||||
retain = part.tail_start_id
|
||||
if (msg.info.id === retain) break
|
||||
continue
|
||||
}
|
||||
if (msg.info.role === "user" && completed.has(msg.info.id) && msg.parts.some((part) => part.type === "compaction"))
|
||||
break
|
||||
if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish && !msg.info.error)
|
||||
completed.add(msg.info.parentID)
|
||||
}
|
||||
result.reverse()
|
||||
const compactionIndex = result.findLastIndex(
|
||||
(msg) =>
|
||||
msg.info.role === "user" &&
|
||||
msg.parts.some((item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined),
|
||||
)
|
||||
const compaction = result[compactionIndex]
|
||||
const part = compaction?.parts.find(
|
||||
(item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined,
|
||||
)
|
||||
const summaryIndex = compaction
|
||||
? result.findIndex(
|
||||
(msg, index) =>
|
||||
index > compactionIndex &&
|
||||
msg.info.role === "assistant" &&
|
||||
msg.info.summary &&
|
||||
msg.info.parentID === compaction.info.id,
|
||||
)
|
||||
: -1
|
||||
const tailIndex = part?.tail_start_id ? result.findIndex((msg) => msg.info.id === part.tail_start_id) : -1
|
||||
if (tailIndex >= 0 && tailIndex < compactionIndex && summaryIndex > compactionIndex) {
|
||||
return [
|
||||
...result.slice(compactionIndex, summaryIndex + 1),
|
||||
...result.slice(tailIndex, compactionIndex),
|
||||
...result.slice(summaryIndex + 1),
|
||||
]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,9 @@ import { Schema } from "effect"
|
||||
import { SessionID } from "./schema"
|
||||
import { ModelID, ProviderID } from "../provider/schema"
|
||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||
import { namedSchemaError } from "@/util/named-schema-error"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
|
||||
export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {})
|
||||
export const AuthError = namedSchemaError("ProviderAuthError", {
|
||||
providerID: Schema.String,
|
||||
message: Schema.String,
|
||||
})
|
||||
import { MessageError } from "./message-error"
|
||||
import { AuthError, OutputLengthError } from "./message-error"
|
||||
export { AuthError, OutputLengthError } from "./message-error"
|
||||
|
||||
export const ToolCall = Schema.Struct({
|
||||
state: Schema.Literal("call"),
|
||||
@@ -105,9 +100,7 @@ export const Info = Schema.Struct({
|
||||
created: NonNegativeInt,
|
||||
completed: Schema.optional(NonNegativeInt),
|
||||
}),
|
||||
error: Schema.optional(
|
||||
Schema.Union([AuthError.EffectSchema, NamedError.Unknown.EffectSchema, OutputLengthError.EffectSchema]),
|
||||
),
|
||||
error: Schema.optional(MessageError.SharedSchema),
|
||||
sessionID: SessionID,
|
||||
tool: Schema.Record(
|
||||
Schema.String,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Cause, Deferred, Effect, Exit, Layer, Context, Scope, Schema } from "effect"
|
||||
import { Cause, Deferred, Effect, Exit, Layer, Context, Scope } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Bus } from "@/bus"
|
||||
@@ -26,13 +26,14 @@ 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: (
|
||||
@@ -66,7 +67,6 @@ 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 = LLMEvent
|
||||
type StreamEvent = Event
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionProcessor") {}
|
||||
|
||||
@@ -223,85 +223,9 @@ 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 "request-start":
|
||||
case "start":
|
||||
yield* status.set(ctx.sessionID, { type: "busy" })
|
||||
return
|
||||
|
||||
@@ -327,132 +251,116 @@ export const layer: Layer.Layer<
|
||||
yield* session.updatePart(ctx.reasoningMap[value.id])
|
||||
return
|
||||
|
||||
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
|
||||
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
|
||||
yield* session.updatePartDelta({
|
||||
sessionID: ctx.reasoningMap[reasoningID].sessionID,
|
||||
messageID: ctx.reasoningMap[reasoningID].messageID,
|
||||
partID: ctx.reasoningMap[reasoningID].id,
|
||||
sessionID: ctx.reasoningMap[value.id].sessionID,
|
||||
messageID: ctx.reasoningMap[value.id].messageID,
|
||||
partID: ctx.reasoningMap[value.id].id,
|
||||
field: "text",
|
||||
delta: value.text,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
case "reasoning-end":
|
||||
if (value.providerMetadata && value.id in ctx.reasoningMap) {
|
||||
ctx.reasoningMap[value.id].metadata = value.providerMetadata
|
||||
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()),
|
||||
})
|
||||
}
|
||||
yield* finishReasoning(value.id)
|
||||
// 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]
|
||||
return
|
||||
|
||||
case "tool-input-start":
|
||||
if (ctx.assistantMessage.summary) {
|
||||
throw new Error(`Tool call not allowed while generating summary: ${value.name}`)
|
||||
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,
|
||||
}
|
||||
yield* ensureToolCall(value)
|
||||
return
|
||||
|
||||
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,
|
||||
}))
|
||||
}
|
||||
case "tool-input-delta":
|
||||
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: toolCall.part.state.status === "pending" ? toolCall.part.state.raw : "",
|
||||
text: "",
|
||||
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.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()),
|
||||
})
|
||||
}
|
||||
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
|
||||
}
|
||||
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.id,
|
||||
tool: value.name,
|
||||
input,
|
||||
callID: value.toolCallId,
|
||||
tool: value.toolName,
|
||||
input: value.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.id, (match) => ({
|
||||
yield* updateToolCall(value.toolCallId, (match) => ({
|
||||
...match,
|
||||
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 } : {}),
|
||||
tool: value.toolName,
|
||||
state: {
|
||||
...match.state,
|
||||
status: "running",
|
||||
input: value.input,
|
||||
time: { start: Date.now() },
|
||||
},
|
||||
metadata: match.metadata?.providerExecuted
|
||||
? { ...value.providerMetadata, providerExecuted: true }
|
||||
: value.providerMetadata,
|
||||
}))
|
||||
|
||||
const parts = MessageV2.parts(ctx.assistantMessage.id)
|
||||
@@ -463,9 +371,9 @@ export const layer: Layer.Layer<
|
||||
!recentParts.every(
|
||||
(part) =>
|
||||
part.type === "tool" &&
|
||||
part.tool === value.name &&
|
||||
part.tool === value.toolName &&
|
||||
part.state.status !== "pending" &&
|
||||
JSON.stringify(part.state.input) === JSON.stringify(input),
|
||||
JSON.stringify(part.state.input) === JSON.stringify(value.input),
|
||||
)
|
||||
) {
|
||||
return
|
||||
@@ -474,19 +382,27 @@ export const layer: Layer.Layer<
|
||||
const agent = yield* agents.get(ctx.assistantMessage.agent)
|
||||
yield* permission.ask({
|
||||
permission: "doom_loop",
|
||||
patterns: [value.name],
|
||||
patterns: [value.toolName],
|
||||
sessionID: ctx.assistantMessage.sessionID,
|
||||
metadata: { tool: value.name, input },
|
||||
always: [value.name],
|
||||
metadata: { tool: value.toolName, input: value.input },
|
||||
always: [value.toolName],
|
||||
ruleset: agent.permission,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
case "tool-result": {
|
||||
const toolCall = yield* readToolCall(value.id)
|
||||
const rawOutput = toolResultOutput(value)
|
||||
const normalized = yield* Effect.forEach(rawOutput.attachments ?? [], (attachment) =>
|
||||
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) =>
|
||||
attachment.mime.startsWith("image/")
|
||||
? image.normalize(attachment).pipe(Effect.exit)
|
||||
: Effect.succeed(Exit.succeed<MessageV2.FilePart>(attachment)),
|
||||
@@ -494,18 +410,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 = {
|
||||
...rawOutput,
|
||||
...value.output,
|
||||
output:
|
||||
omitted === 0
|
||||
? 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,
|
||||
? 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,
|
||||
}
|
||||
// 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.id,
|
||||
callID: value.toolCallId,
|
||||
structured: output.metadata,
|
||||
content: [
|
||||
{
|
||||
@@ -513,32 +429,32 @@ export const layer: Layer.Layer<
|
||||
text: output.output,
|
||||
},
|
||||
...(output.attachments?.map((item: MessageV2.FilePart) => ({
|
||||
type: "file" as const,
|
||||
type: "file",
|
||||
uri: item.url,
|
||||
mime: item.mime,
|
||||
name: item.filename,
|
||||
})) ?? []),
|
||||
],
|
||||
provider: {
|
||||
executed: value.providerExecuted === true || toolCall?.part.metadata?.providerExecuted === true,
|
||||
executed: toolCall?.part.metadata?.providerExecuted === true,
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
yield* completeToolCall(value.id, output)
|
||||
yield* completeToolCall(value.toolCallId, output)
|
||||
return
|
||||
}
|
||||
|
||||
case "tool-error": {
|
||||
const toolCall = yield* readToolCall(value.id)
|
||||
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.Failed.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.id,
|
||||
callID: value.toolCallId,
|
||||
error: {
|
||||
type: "unknown",
|
||||
message: value.message,
|
||||
message: errorMessage(value.error),
|
||||
},
|
||||
provider: {
|
||||
executed: toolCall?.part.metadata?.providerExecuted === true,
|
||||
@@ -546,14 +462,14 @@ export const layer: Layer.Layer<
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
yield* failToolCall(value.id, new Error(value.message))
|
||||
yield* failToolCall(value.toolCallId, value.error)
|
||||
return
|
||||
}
|
||||
|
||||
case "provider-error":
|
||||
throw new Error(value.message)
|
||||
case "error":
|
||||
throw value.error
|
||||
|
||||
case "step-start":
|
||||
case "start-step":
|
||||
if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
@@ -580,20 +496,19 @@ export const layer: Layer.Layer<
|
||||
})
|
||||
return
|
||||
|
||||
case "step-finish": {
|
||||
case "finish-step": {
|
||||
const completedSnapshot = yield* snapshot.track()
|
||||
yield* Effect.forEach(Object.keys(ctx.reasoningMap), finishReasoning)
|
||||
const usage = Session.getUsage({
|
||||
model: ctx.model,
|
||||
usage: value.usage ?? new Usage({}),
|
||||
metadata: value.providerMetadata,
|
||||
})
|
||||
const usage = Session.getUsage({
|
||||
model: ctx.model,
|
||||
usage: value.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.reason,
|
||||
finish: value.finishReason,
|
||||
cost: usage.cost,
|
||||
tokens: usage.tokens,
|
||||
snapshot: completedSnapshot,
|
||||
@@ -601,12 +516,12 @@ export const layer: Layer.Layer<
|
||||
})
|
||||
}
|
||||
}
|
||||
ctx.assistantMessage.finish = value.reason
|
||||
ctx.assistantMessage.finish = value.finishReason
|
||||
ctx.assistantMessage.cost += usage.cost
|
||||
ctx.assistantMessage.tokens = usage.tokens
|
||||
yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
reason: value.reason,
|
||||
reason: value.finishReason,
|
||||
snapshot: completedSnapshot,
|
||||
messageID: ctx.assistantMessage.id,
|
||||
sessionID: ctx.assistantMessage.sessionID,
|
||||
@@ -669,6 +584,7 @@ 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,
|
||||
@@ -710,9 +626,12 @@ export const layer: Layer.Layer<
|
||||
ctx.currentText = undefined
|
||||
return
|
||||
|
||||
case "request-finish":
|
||||
case "finish":
|
||||
return
|
||||
|
||||
default:
|
||||
slog.info("unhandled", { event: value.type, value })
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
@@ -814,7 +733,6 @@ 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(
|
||||
@@ -898,12 +816,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(SyncEvent.defaultLayer),
|
||||
Layer.provide(Image.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(SyncEvent.defaultLayer),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ function applyUsage(db: TxOrDb, sessionID: Session.Info["id"], value: Usage, sig
|
||||
tokens_reasoning: sql`${SessionTable.tokens_reasoning} + ${value.tokens.reasoning * sign}`,
|
||||
tokens_cache_read: sql`${SessionTable.tokens_cache_read} + ${value.tokens.cache.read * sign}`,
|
||||
tokens_cache_write: sql`${SessionTable.tokens_cache_write} + ${value.tokens.cache.write * sign}`,
|
||||
time_updated: sql`${SessionTable.time_updated}`,
|
||||
})
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.run()
|
||||
@@ -110,7 +111,7 @@ export default [
|
||||
const info = data.info
|
||||
const row = db
|
||||
.update(SessionTable)
|
||||
.set(toPartialRow(info as Session.Patch))
|
||||
.set({ time_updated: sql`${SessionTable.time_updated}`, ...toPartialRow(info as Session.Patch) })
|
||||
.where(eq(SessionTable.id, data.sessionID))
|
||||
.returning()
|
||||
.get()
|
||||
|
||||
@@ -60,7 +60,6 @@ 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
|
||||
@@ -360,7 +359,7 @@ export const layer = Layer.effect(
|
||||
messages: [{ role: "user", content: "Generate a title for this conversation:\n" }, ...msgs],
|
||||
})
|
||||
.pipe(
|
||||
Stream.filter(LLMEvent.is.textDelta),
|
||||
Stream.filter((e): e is Extract<LLM.Event, { type: "text-delta" }> => e.type === "text-delta"),
|
||||
Stream.map((e) => e.text),
|
||||
Stream.mkString,
|
||||
Effect.orDie,
|
||||
@@ -1084,8 +1083,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 || (yield* agents.defaultAgent())
|
||||
const ag = yield* agents.get(agentName)
|
||||
const agentName = input.agent
|
||||
const ag = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo()
|
||||
if (!ag) {
|
||||
const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)
|
||||
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
|
||||
@@ -1876,7 +1875,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 ?? (yield* agents.defaultAgent())
|
||||
const agentName = cmd.agent ?? input.agent
|
||||
|
||||
const raw = input.arguments.match(argsRegex) ?? []
|
||||
const args = raw.map((arg) => arg.replace(quoteTrimRegex, ""))
|
||||
@@ -1929,7 +1928,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 = yield* agents.get(agentName)
|
||||
const agent = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo()
|
||||
if (!agent) {
|
||||
const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)
|
||||
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
|
||||
@@ -1953,7 +1952,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.defaultAgent())) : agentName
|
||||
const userAgent = isSubtask ? (input.agent ?? (yield* agents.defaultInfo()).name) : agent.name
|
||||
const userModel = isSubtask
|
||||
? input.model
|
||||
? Provider.parseModel(input.model)
|
||||
|
||||
@@ -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, Usage } from "@opencode-ai/llm"
|
||||
import { type ProviderMetadata, type LanguageModelUsage } from "ai"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
|
||||
@@ -373,19 +373,21 @@ 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: Usage; metadata?: ProviderMetadata }) => {
|
||||
export const getUsage = (input: { model: Provider.Model; usage: LanguageModelUsage; 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.reasoningTokens ?? 0)
|
||||
const reasoningTokens = safe(input.usage.outputTokenDetails?.reasoningTokens ?? input.usage.reasoningTokens ?? 0)
|
||||
|
||||
const cacheReadInputTokens = safe(input.usage.cacheReadInputTokens ?? 0)
|
||||
const cacheReadInputTokens = safe(
|
||||
input.usage.inputTokenDetails?.cacheReadTokens ?? input.usage.cachedInputTokens ?? 0,
|
||||
)
|
||||
const cacheWriteInputTokens = safe(
|
||||
Number(
|
||||
input.usage.cacheWriteInputTokens ??
|
||||
input.usage.inputTokenDetails?.cacheWriteTokens ??
|
||||
input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
|
||||
// google-vertex-anthropic returns metadata under "vertex" key
|
||||
// (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages')
|
||||
|
||||
@@ -160,11 +160,13 @@ export const layer: Layer.Layer<
|
||||
const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx))
|
||||
const output = typeof result === "string" ? result : result.output
|
||||
const metadata = typeof result === "string" ? {} : (result.metadata ?? {})
|
||||
const attachments = typeof result === "string" ? undefined : result.attachments
|
||||
const info = yield* agent.get(toolCtx.agent)
|
||||
const out = yield* truncate.output(output, {}, info)
|
||||
return {
|
||||
title: "",
|
||||
title: typeof result === "string" ? "" : (result.title ?? ""),
|
||||
output: out.truncated ? out.content : output,
|
||||
attachments,
|
||||
metadata: {
|
||||
...metadata,
|
||||
truncated: out.truncated,
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Schema } from "effect"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
|
||||
/**
|
||||
* Create a Schema-backed NamedError-shaped class.
|
||||
*/
|
||||
export function namedSchemaError<Tag extends string, Fields extends Schema.Struct.Fields>(tag: Tag, fields: Fields) {
|
||||
return NamedError.create(tag, fields)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,110 +18,85 @@
|
||||
* permissions are passed through, and Plan Mode's restrictions live on the
|
||||
* agent, not the session.
|
||||
*/
|
||||
import { test, expect, afterEach } from "bun:test"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { deriveSubagentSessionPermission } from "../../src/agent/subagent-permissions"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
function load<A>(dir: string, fn: (svc: Agent.Interface) => Effect.Effect<A>) {
|
||||
return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer)))
|
||||
}
|
||||
const it = testEffect(Agent.defaultLayer)
|
||||
|
||||
// `deriveSubagentSessionPermission` is imported from production. The test
|
||||
// exercises the actual helper that task.ts uses to build the subagent's
|
||||
// session permission, so any regression in that helper trips this test.
|
||||
|
||||
test("[#26514] subagent spawned from plan mode inherits read-only restriction (edit denied)", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const planAgent = await load(tmp.path, (svc) => svc.get("plan"))
|
||||
const generalAgent = await load(tmp.path, (svc) => svc.get("general"))
|
||||
it.instance("[#26514] subagent spawned from plan mode inherits read-only restriction (edit denied)", () =>
|
||||
Effect.gen(function* () {
|
||||
const planAgent = yield* Agent.Service.use((svc) => svc.get("plan"))
|
||||
const generalAgent = yield* Agent.Service.use((svc) => svc.get("general"))
|
||||
|
||||
expect(planAgent).toBeDefined()
|
||||
expect(generalAgent).toBeDefined()
|
||||
// Sanity: the plan agent itself blocks edit. (Note: `write` and
|
||||
// `apply_patch` route through the `edit` permission at the runtime
|
||||
// tool layer — see Permission.disabled / EDIT_TOOLS.)
|
||||
expect(Permission.evaluate("edit", "/some/file.ts", planAgent!.permission).action).toBe("deny")
|
||||
expect(planAgent).toBeDefined()
|
||||
expect(generalAgent).toBeDefined()
|
||||
// Sanity: the plan agent itself blocks edit. (Note: `write` and
|
||||
// `apply_patch` route through the `edit` permission at the runtime
|
||||
// tool layer — see Permission.disabled / EDIT_TOOLS.)
|
||||
expect(Permission.evaluate("edit", "/some/file.ts", planAgent!.permission).action).toBe("deny")
|
||||
|
||||
// Simulate the plan-mode parent session: in real flow the plan
|
||||
// session's `permission` field is empty (Plan Mode lives on the agent
|
||||
// ruleset, not the session). So we pass [] through as the parent
|
||||
// session permission, exactly like the actual code path.
|
||||
const parentSessionPermission: Permission.Ruleset = []
|
||||
// Simulate the plan-mode parent session: in real flow the plan
|
||||
// session's `permission` field is empty (Plan Mode lives on the agent
|
||||
// ruleset, not the session). So we pass [] through as the parent
|
||||
// session permission, exactly like the actual code path.
|
||||
const parentSessionPermission: Permission.Ruleset = []
|
||||
|
||||
const subagentSessionPermission = deriveSubagentSessionPermission({
|
||||
parentSessionPermission,
|
||||
parentAgent: planAgent,
|
||||
subagent: generalAgent!,
|
||||
})
|
||||
const subagentSessionPermission = deriveSubagentSessionPermission({
|
||||
parentSessionPermission,
|
||||
parentAgent: planAgent,
|
||||
subagent: generalAgent!,
|
||||
})
|
||||
|
||||
// Mirror the runtime evaluation in session/prompt.ts (~line 410, 639):
|
||||
// ruleset: Permission.merge(agent.permission, session.permission ?? [])
|
||||
const effective = Permission.merge(generalAgent!.permission, subagentSessionPermission)
|
||||
// Mirror the runtime evaluation in session/prompt.ts (~line 410, 639):
|
||||
// ruleset: Permission.merge(agent.permission, session.permission ?? [])
|
||||
const effective = Permission.merge(generalAgent!.permission, subagentSessionPermission)
|
||||
|
||||
expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny")
|
||||
expect(Permission.evaluate("edit", "/another/path/index.tsx", effective).action).toBe("deny")
|
||||
},
|
||||
})
|
||||
})
|
||||
expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny")
|
||||
expect(Permission.evaluate("edit", "/another/path/index.tsx", effective).action).toBe("deny")
|
||||
}),
|
||||
)
|
||||
|
||||
test("[#26514] explore subagent launched from plan mode also stays read-only", async () => {
|
||||
it.instance("[#26514] explore subagent launched from plan mode also stays read-only", () =>
|
||||
// Sibling check: even though `explore` is intrinsically read-only, the
|
||||
// bug surface is the same. Including this case to document that the fix
|
||||
// should propagate the parent **agent** permissions, not just deny edit
|
||||
// when the subagent happens to already deny it.
|
||||
await using tmp = await tmpdir()
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const planAgent = await load(tmp.path, (svc) => svc.get("plan"))
|
||||
const explore = await load(tmp.path, (svc) => svc.get("explore"))
|
||||
expect(planAgent).toBeDefined()
|
||||
expect(explore).toBeDefined()
|
||||
Effect.gen(function* () {
|
||||
const planAgent = yield* Agent.Service.use((svc) => svc.get("plan"))
|
||||
const explore = yield* Agent.Service.use((svc) => svc.get("explore"))
|
||||
expect(planAgent).toBeDefined()
|
||||
expect(explore).toBeDefined()
|
||||
|
||||
const parentSessionPermission: Permission.Ruleset = []
|
||||
const subagentSessionPermission = deriveSubagentSessionPermission({
|
||||
parentSessionPermission,
|
||||
parentAgent: planAgent,
|
||||
subagent: explore!,
|
||||
})
|
||||
const effective = Permission.merge(explore!.permission, subagentSessionPermission)
|
||||
const parentSessionPermission: Permission.Ruleset = []
|
||||
const subagentSessionPermission = deriveSubagentSessionPermission({
|
||||
parentSessionPermission,
|
||||
parentAgent: planAgent,
|
||||
subagent: explore!,
|
||||
})
|
||||
const effective = Permission.merge(explore!.permission, subagentSessionPermission)
|
||||
|
||||
// Already deny — sanity check.
|
||||
expect(Permission.evaluate("edit", "/x.ts", effective).action).toBe("deny")
|
||||
},
|
||||
})
|
||||
})
|
||||
// Already deny — sanity check.
|
||||
expect(Permission.evaluate("edit", "/x.ts", effective).action).toBe("deny")
|
||||
}),
|
||||
)
|
||||
|
||||
test("[#26514] custom user subagent launched from plan mode bypasses Plan Mode read-only", async () => {
|
||||
it.instance(
|
||||
"[#26514] custom user subagent launched from plan mode bypasses Plan Mode read-only",
|
||||
// The most damaging case: a user-defined subagent with default
|
||||
// permissions (allow-by-default, like `general`). The subagent must NOT
|
||||
// be able to edit when the parent agent is `plan`.
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
agent: {
|
||||
my_subagent: {
|
||||
description: "A user-defined subagent",
|
||||
mode: "subagent",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const planAgent = await load(tmp.path, (svc) => svc.get("plan"))
|
||||
const my = await load(tmp.path, (svc) => svc.get("my_subagent"))
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const planAgent = yield* Agent.Service.use((svc) => svc.get("plan"))
|
||||
const my = yield* Agent.Service.use((svc) => svc.get("my_subagent"))
|
||||
expect(planAgent).toBeDefined()
|
||||
expect(my).toBeDefined()
|
||||
|
||||
@@ -136,6 +111,15 @@ test("[#26514] custom user subagent launched from plan mode bypasses Plan Mode r
|
||||
// BUG: on origin/dev edit resolves to "allow" because the plan
|
||||
// agent's `edit: deny *` rule never reaches the subagent.
|
||||
expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny")
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
agent: {
|
||||
my_subagent: {
|
||||
description: "A user-defined subagent",
|
||||
mode: "subagent",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,88 +1,88 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Layer, Schema } from "effect"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { BusEvent } from "../../src/bus/bus-event"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const TestEvent = BusEvent.define("test.integration", Schema.Struct({ value: Schema.Number }))
|
||||
|
||||
function withInstance(directory: string, fn: () => Promise<void>) {
|
||||
return WithInstance.provide({ directory, fn })
|
||||
}
|
||||
const it = testEffect(Layer.mergeAll(Bus.layer, CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
describe("Bus integration: acquireRelease subscriber pattern", () => {
|
||||
afterEach(() => disposeAllInstances())
|
||||
|
||||
test("subscriber via callback facade receives events and cleans up on unsub", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const received: number[] = []
|
||||
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>()
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
const unsub = Bus.subscribe(TestEvent, (evt) => {
|
||||
const unsub = yield* bus.subscribeCallback(TestEvent, (evt) => {
|
||||
received.push(evt.properties.value)
|
||||
if (received.length === 2) Deferred.doneUnsafe(receivedTwo, Effect.void)
|
||||
})
|
||||
await Bun.sleep(10)
|
||||
await Bus.publish(TestEvent, { value: 1 })
|
||||
await Bus.publish(TestEvent, { value: 2 })
|
||||
await Bun.sleep(10)
|
||||
yield* bus.publish(TestEvent, { value: 1 })
|
||||
yield* bus.publish(TestEvent, { value: 2 })
|
||||
yield* Deferred.await(receivedTwo).pipe(Effect.timeout("2 seconds"))
|
||||
|
||||
expect(received).toEqual([1, 2])
|
||||
|
||||
unsub()
|
||||
await Bun.sleep(10)
|
||||
await Bus.publish(TestEvent, { value: 3 })
|
||||
await Bun.sleep(10)
|
||||
yield* Effect.sync(unsub)
|
||||
yield* bus.publish(TestEvent, { value: 3 })
|
||||
yield* Effect.sleep("10 millis")
|
||||
|
||||
expect(received).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
test("subscribeAll receives events from multiple types", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const received: Array<{ type: string; value?: number }> = []
|
||||
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>()
|
||||
|
||||
const OtherEvent = BusEvent.define("test.other", Schema.Struct({ value: Schema.Number }))
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
Bus.subscribeAll((evt) => {
|
||||
yield* bus.subscribeAllCallback((evt) => {
|
||||
received.push({ type: evt.type, value: evt.properties.value })
|
||||
if (received.length === 2) Deferred.doneUnsafe(receivedTwo, Effect.void)
|
||||
})
|
||||
await Bun.sleep(10)
|
||||
await Bus.publish(TestEvent, { value: 10 })
|
||||
await Bus.publish(OtherEvent, { value: 20 })
|
||||
await Bun.sleep(10)
|
||||
})
|
||||
yield* bus.publish(TestEvent, { value: 10 })
|
||||
yield* bus.publish(OtherEvent, { value: 20 })
|
||||
yield* Deferred.await(receivedTwo).pipe(Effect.timeout("2 seconds"))
|
||||
|
||||
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 },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
test("subscriber cleanup on instance disposal interrupts the stream", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const received: number[] = []
|
||||
let disposed = false
|
||||
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>()
|
||||
|
||||
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.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 disposeAllInstances()
|
||||
await Bun.sleep(50)
|
||||
yield* Effect.promise(() => disposeAllInstances())
|
||||
yield* Deferred.await(disposed).pipe(Effect.timeout("2 seconds"))
|
||||
|
||||
expect(received).toEqual([1])
|
||||
expect(disposed).toBe(true)
|
||||
})
|
||||
expect(received).toEqual([1])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,220 +1,240 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Deferred, Effect, Layer, Schema } from "effect"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { BusEvent } from "../../src/bus/bus-event"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const TestEvent = {
|
||||
Ping: BusEvent.define("test.ping", Schema.Struct({ value: Schema.Number })),
|
||||
Pong: BusEvent.define("test.pong", Schema.Struct({ message: Schema.String })),
|
||||
}
|
||||
|
||||
function withInstance(directory: string, fn: () => Promise<void>) {
|
||||
return WithInstance.provide({ directory, fn })
|
||||
}
|
||||
const it = testEffect(Layer.mergeAll(Bus.layer, CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
describe("Bus", () => {
|
||||
afterEach(() => disposeAllInstances())
|
||||
|
||||
describe("publish + subscribe", () => {
|
||||
test("subscriber is live immediately after subscribe returns", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const received: number[] = []
|
||||
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>()
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
Bus.subscribe(TestEvent.Ping, (evt) => {
|
||||
yield* bus.subscribeCallback(TestEvent.Ping, (evt) => {
|
||||
received.push(evt.properties.value)
|
||||
Deferred.doneUnsafe(done, Effect.void)
|
||||
})
|
||||
await Bus.publish(TestEvent.Ping, { value: 42 })
|
||||
await Bun.sleep(10)
|
||||
})
|
||||
yield* bus.publish(TestEvent.Ping, { value: 42 })
|
||||
yield* Deferred.await(done).pipe(Effect.timeout("2 seconds"))
|
||||
|
||||
expect(received).toEqual([42])
|
||||
})
|
||||
expect(received).toEqual([42])
|
||||
}),
|
||||
)
|
||||
|
||||
test("subscriber receives matching events", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const received: number[] = []
|
||||
it.instance("subscriber receives matching events", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const received: number[] = []
|
||||
const done = yield* Deferred.make<void>()
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
Bus.subscribe(TestEvent.Ping, (evt) => {
|
||||
yield* bus.subscribeCallback(TestEvent.Ping, (evt) => {
|
||||
received.push(evt.properties.value)
|
||||
if (received.length === 2) Deferred.doneUnsafe(done, Effect.void)
|
||||
})
|
||||
// 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)
|
||||
})
|
||||
yield* bus.publish(TestEvent.Ping, { value: 42 })
|
||||
yield* bus.publish(TestEvent.Ping, { value: 99 })
|
||||
yield* Deferred.await(done).pipe(Effect.timeout("2 seconds"))
|
||||
|
||||
expect(received).toEqual([42, 99])
|
||||
})
|
||||
expect(received).toEqual([42, 99])
|
||||
}),
|
||||
)
|
||||
|
||||
test("subscriber does not receive events of other types", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const pings: number[] = []
|
||||
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>()
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
Bus.subscribe(TestEvent.Ping, (evt) => {
|
||||
yield* bus.subscribeCallback(TestEvent.Ping, (evt) => {
|
||||
pings.push(evt.properties.value)
|
||||
Deferred.doneUnsafe(done, Effect.void)
|
||||
})
|
||||
await Bun.sleep(10)
|
||||
await Bus.publish(TestEvent.Pong, { message: "hello" })
|
||||
await Bus.publish(TestEvent.Ping, { value: 1 })
|
||||
await Bun.sleep(10)
|
||||
})
|
||||
yield* bus.publish(TestEvent.Pong, { message: "hello" })
|
||||
yield* bus.publish(TestEvent.Ping, { value: 1 })
|
||||
yield* Deferred.await(done).pipe(Effect.timeout("2 seconds"))
|
||||
|
||||
expect(pings).toEqual([1])
|
||||
})
|
||||
expect(pings).toEqual([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 })
|
||||
})
|
||||
})
|
||||
it.instance("publish with no subscribers does not throw", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
yield* bus.publish(TestEvent.Ping, { value: 1 })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("unsubscribe", () => {
|
||||
test("unsubscribe stops delivery", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const received: number[] = []
|
||||
it.instance("unsubscribe stops delivery", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const received: number[] = []
|
||||
const first = yield* Deferred.make<void>()
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
const unsub = Bus.subscribe(TestEvent.Ping, (evt) => {
|
||||
const unsub = yield* bus.subscribeCallback(TestEvent.Ping, (evt) => {
|
||||
received.push(evt.properties.value)
|
||||
if (evt.properties.value === 1) Deferred.doneUnsafe(first, Effect.void)
|
||||
})
|
||||
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)
|
||||
})
|
||||
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")
|
||||
|
||||
expect(received).toEqual([1])
|
||||
})
|
||||
expect(received).toEqual([1])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("subscribeAll", () => {
|
||||
test("subscribeAll is live immediately after subscribe returns", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const received: string[] = []
|
||||
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>()
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
Bus.subscribeAll((evt) => {
|
||||
yield* bus.subscribeAllCallback((evt) => {
|
||||
received.push(evt.type)
|
||||
Deferred.doneUnsafe(done, Effect.void)
|
||||
})
|
||||
await Bus.publish(TestEvent.Ping, { value: 1 })
|
||||
await Bun.sleep(10)
|
||||
})
|
||||
yield* bus.publish(TestEvent.Ping, { value: 1 })
|
||||
yield* Deferred.await(done).pipe(Effect.timeout("2 seconds"))
|
||||
|
||||
expect(received).toEqual(["test.ping"])
|
||||
})
|
||||
expect(received).toEqual(["test.ping"])
|
||||
}),
|
||||
)
|
||||
|
||||
test("receives all event types", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const received: string[] = []
|
||||
it.instance("receives all event types", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const received: string[] = []
|
||||
const done = yield* Deferred.make<void>()
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
Bus.subscribeAll((evt) => {
|
||||
yield* bus.subscribeAllCallback((evt) => {
|
||||
received.push(evt.type)
|
||||
if (received.length === 2) Deferred.doneUnsafe(done, Effect.void)
|
||||
})
|
||||
await Bun.sleep(10)
|
||||
await Bus.publish(TestEvent.Ping, { value: 1 })
|
||||
await Bus.publish(TestEvent.Pong, { message: "hi" })
|
||||
await Bun.sleep(10)
|
||||
})
|
||||
yield* bus.publish(TestEvent.Ping, { value: 1 })
|
||||
yield* bus.publish(TestEvent.Pong, { message: "hi" })
|
||||
yield* Deferred.await(done).pipe(Effect.timeout("2 seconds"))
|
||||
|
||||
expect(received).toContain("test.ping")
|
||||
expect(received).toContain("test.pong")
|
||||
})
|
||||
expect(received).toContain("test.ping")
|
||||
expect(received).toContain("test.pong")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("multiple subscribers", () => {
|
||||
test("all subscribers for same event type are called", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const a: number[] = []
|
||||
const b: number[] = []
|
||||
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>()
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
Bus.subscribe(TestEvent.Ping, (evt) => {
|
||||
yield* bus.subscribeCallback(TestEvent.Ping, (evt) => {
|
||||
a.push(evt.properties.value)
|
||||
Deferred.doneUnsafe(doneA, Effect.void)
|
||||
})
|
||||
Bus.subscribe(TestEvent.Ping, (evt) => {
|
||||
yield* bus.subscribeCallback(TestEvent.Ping, (evt) => {
|
||||
b.push(evt.properties.value)
|
||||
Deferred.doneUnsafe(doneB, Effect.void)
|
||||
})
|
||||
await Bun.sleep(10)
|
||||
await Bus.publish(TestEvent.Ping, { value: 7 })
|
||||
await Bun.sleep(10)
|
||||
})
|
||||
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"))
|
||||
|
||||
expect(a).toEqual([7])
|
||||
expect(b).toEqual([7])
|
||||
})
|
||||
expect(a).toEqual([7])
|
||||
expect(b).toEqual([7])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("instance isolation", () => {
|
||||
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[] = []
|
||||
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>()
|
||||
|
||||
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) => {
|
||||
receivedA.push(evt.properties.value)
|
||||
Deferred.doneUnsafe(doneA, Effect.void)
|
||||
})
|
||||
}).pipe(provideInstance(tmpA))
|
||||
|
||||
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.subscribeCallback(TestEvent.Ping, (evt) => {
|
||||
receivedB.push(evt.properties.value)
|
||||
Deferred.doneUnsafe(doneB, Effect.void)
|
||||
})
|
||||
}).pipe(provideInstance(tmpB))
|
||||
|
||||
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: 1 })
|
||||
}).pipe(provideInstance(tmpA))
|
||||
|
||||
await withInstance(tmpB.path, async () => {
|
||||
await Bus.publish(TestEvent.Ping, { value: 2 })
|
||||
await Bun.sleep(10)
|
||||
})
|
||||
yield* Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
yield* bus.publish(TestEvent.Ping, { value: 2 })
|
||||
}).pipe(provideInstance(tmpB))
|
||||
|
||||
expect(receivedA).toEqual([1])
|
||||
expect(receivedB).toEqual([2])
|
||||
})
|
||||
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])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("instance disposal", () => {
|
||||
test("InstanceDisposed is delivered to wildcard subscribers before stream ends", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const received: string[] = []
|
||||
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>()
|
||||
|
||||
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.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))
|
||||
|
||||
// disposeAllInstances triggers the finalizer which publishes InstanceDisposed
|
||||
await disposeAllInstances()
|
||||
await Bun.sleep(50)
|
||||
yield* Effect.promise(disposeAllInstances)
|
||||
yield* Deferred.await(disposed).pipe(Effect.timeout("2 seconds"))
|
||||
|
||||
expect(received).toContain("test.ping")
|
||||
expect(received).toContain(Bus.InstanceDisposed.type)
|
||||
})
|
||||
expect(received).toContain("test.ping")
|
||||
expect(received).toContain(Bus.InstanceDisposed.type)
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { afterEach, expect } from "bun:test"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Effect } from "effect"
|
||||
import fs from "fs/promises"
|
||||
import { fileURLToPath } from "url"
|
||||
import { InstanceRef } from "../../src/effect/instance-ref"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { disposeAllInstances, provideTestInstance, tmpdir } from "../fixture/fixture"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(AppFileSystem.defaultLayer)
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
@@ -14,35 +19,40 @@ afterEach(async () => {
|
||||
// has lost the outer InstanceRef. Services that read `InstanceState.context`
|
||||
// then fall back to `Instance.current` ALS, which must be installed at the JS
|
||||
// callback boundary (Node ALS persists across awaits, Effect's fiber context
|
||||
// does not). `provideTestInstance` mirrors effectCmd's load + ALS-restore wrap.
|
||||
// does not). `it.instance` provides the loaded InstanceRef; the explicit
|
||||
// Instance.restore mirrors effectCmd's load + ALS-restore wrap.
|
||||
// Pins effect-cmd.ts directly: the pattern test below exercises the load +
|
||||
// Instance.restore + dispose triple via the shared `provideTestInstance` fixture,
|
||||
// Instance.restore boundary via the shared `it.instance` fixture,
|
||||
// so a regression that removed `Instance.restore` from effect-cmd.ts wouldn't
|
||||
// fail it. This grep guards the actual production callsite.
|
||||
test("effect-cmd.ts wraps the handler body in Instance.restore", async () => {
|
||||
const source = await fs.readFile(new URL("../../src/cli/effect-cmd.ts", import.meta.url), "utf8")
|
||||
expect(source).toContain("Instance.restore(ctx")
|
||||
})
|
||||
it.live("effect-cmd.ts wraps the handler body in Instance.restore", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const source = yield* fs.readFileString(fileURLToPath(new URL("../../src/cli/effect-cmd.ts", import.meta.url)))
|
||||
expect(source).toContain("Instance.restore(ctx")
|
||||
}),
|
||||
)
|
||||
|
||||
test("Instance.current reachable from inner runPromise inside Effect.promise(async)", async () => {
|
||||
await using dir = await tmpdir({ git: true })
|
||||
await provideTestInstance({
|
||||
directory: dir.path,
|
||||
fn: () =>
|
||||
Effect.runPromise(
|
||||
Effect.promise(async () => {
|
||||
await new Promise((r) => setTimeout(r, 5))
|
||||
const current = await Effect.runPromise(
|
||||
Effect.sync(() => {
|
||||
try {
|
||||
return Instance.current
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}),
|
||||
)
|
||||
expect(current?.directory).toBe(dir.path)
|
||||
it.instance(
|
||||
"Instance.current reachable after await inside restored Effect.promise(async)",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) throw new Error("InstanceRef not provided")
|
||||
|
||||
const current = yield* Effect.promise(() =>
|
||||
Instance.restore(ctx, async () => {
|
||||
await Promise.resolve()
|
||||
try {
|
||||
return Instance.current
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}),
|
||||
),
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
expect(current?.directory).toBe(test.directory)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { AccountTransportError } from "../../src/account/schema"
|
||||
import { FormatError } from "../../src/cli/error"
|
||||
import { UI } from "../../src/cli/ui"
|
||||
|
||||
describe("cli.error", () => {
|
||||
test("formats account transport errors clearly", () => {
|
||||
@@ -15,4 +16,8 @@ describe("cli.error", () => {
|
||||
expect(formatted).toContain("This failed before the server returned an HTTP response.")
|
||||
expect(formatted).toContain("Check your network, proxy, or VPN configuration and try again.")
|
||||
})
|
||||
|
||||
test("formats cancelled UI errors as empty output", () => {
|
||||
expect(FormatError(new UI.CancelledError())).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,58 +1,50 @@
|
||||
import { test, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import path from "path"
|
||||
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { Config } from "@/config/config"
|
||||
import { Agent as AgentSvc } from "../../src/agent/agent"
|
||||
import { Color } from "@/util/color"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(AgentSvc.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
const it = testEffect(Layer.mergeAll(Config.defaultLayer, AgentSvc.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
const writeConfig = (dir: string, agent: Config.Info["agent"]) =>
|
||||
Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
agent,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("agent color parsed from project config", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
yield* writeConfig(dir, {
|
||||
build: { color: "#FFA500" },
|
||||
plan: { color: "primary" },
|
||||
})
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const cfg = yield* Effect.promise(() => AppRuntime.runPromise(Config.Service.use((svc) => svc.get())))
|
||||
it.instance(
|
||||
"agent color parsed from project config",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const cfg = yield* Config.Service.use((svc) => svc.get())
|
||||
expect(cfg.agent?.["build"]?.color).toBe("#FFA500")
|
||||
expect(cfg.agent?.["plan"]?.color).toBe("primary")
|
||||
}).pipe(provideInstance(dir))
|
||||
}),
|
||||
}),
|
||||
{
|
||||
git: true,
|
||||
config: {
|
||||
agent: {
|
||||
build: { color: "#FFA500" },
|
||||
plan: { color: "primary" },
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.live("Agent.get includes color from config", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
yield* writeConfig(dir, {
|
||||
plan: { color: "#A855F7" },
|
||||
build: { color: "accent" },
|
||||
})
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
it.instance(
|
||||
"Agent.get includes color from config",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const plan = yield* AgentSvc.Service.use((svc) => svc.get("plan"))
|
||||
expect(plan?.color).toBe("#A855F7")
|
||||
const build = yield* AgentSvc.Service.use((svc) => svc.get("build"))
|
||||
expect(build?.color).toBe("accent")
|
||||
}).pipe(provideInstance(dir))
|
||||
}),
|
||||
}),
|
||||
{
|
||||
git: true,
|
||||
config: {
|
||||
agent: {
|
||||
plan: { color: "#A855F7" },
|
||||
build: { color: "accent" },
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
test("Color.hexToAnsiBold converts valid hex to ANSI", () => {
|
||||
|
||||
@@ -3,7 +3,9 @@ import { Effect, Layer, Option } from "effect"
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigManaged } from "@/config/managed"
|
||||
import { ConfigPermission } from "@/config/permission"
|
||||
import { ConfigParse } from "../../src/config/parse"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
|
||||
import { Instance } from "../../src/project/instance"
|
||||
@@ -276,6 +278,40 @@ test("updates global config and omits empty shell key in json", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("global config update preserves single-object permission shape on disk", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
shell: "bash",
|
||||
permission: { bash: "ask" },
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const prev = Global.Path.config
|
||||
;(Global.Path as { config: string }).config = tmp.path
|
||||
await clear(true)
|
||||
|
||||
try {
|
||||
// Updating an unrelated key must not rewrite `permission` from object to array form.
|
||||
await saveGlobal({ shell: "zsh" })
|
||||
|
||||
const written = await Filesystem.readJson<{ permission?: unknown; shell?: string }>(
|
||||
path.join(tmp.path, "opencode.json"),
|
||||
)
|
||||
expect(written.shell).toBe("zsh")
|
||||
expect(Array.isArray(written.permission)).toBe(false)
|
||||
expect(written.permission).toEqual({ bash: "ask" })
|
||||
} finally {
|
||||
;(Global.Path as { config: string }).config = prev
|
||||
await clear(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("updates global config and omits empty shell key in jsonc", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
@@ -1713,7 +1749,10 @@ test("permission config preserves user key order", async () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
expect(Object.keys(config.permission!)).toEqual([
|
||||
// load() goes through the merge pipeline, producing the layered array form
|
||||
expect(config.permission).toHaveLength(1)
|
||||
const perm = (config.permission as ConfigPermission.Info[])[0]
|
||||
expect(Object.keys(perm)).toEqual([
|
||||
"*",
|
||||
"edit",
|
||||
"write",
|
||||
@@ -1729,6 +1768,129 @@ test("permission config preserves user key order", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Global bash "rm *" deny is inherited, but user's top-level "*" ask comes after and overrides it
|
||||
test("user top-level catchall overrides inherited bash rules", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
permission: {
|
||||
bash: { "rm *": "deny" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
const opencodeDir = path.join(dir, ".opencode")
|
||||
await fs.mkdir(opencodeDir, { recursive: true })
|
||||
await Filesystem.write(
|
||||
path.join(opencodeDir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
permission: {
|
||||
"*": "ask",
|
||||
bash: { "ls *": "allow" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const layers = ConfigPermission.toLayers(config.permission)
|
||||
const ruleset = Permission.merge(...layers.map((p) => Permission.fromConfig(p)))
|
||||
|
||||
expect(Permission.evaluate("bash", "rm -rf /", ruleset).action).toBe("ask")
|
||||
expect(Permission.evaluate("bash", "ls -la", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("bash", "echo hello", ruleset).action).toBe("ask")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// No top-level catchall, so global bash "rm *" deny is preserved
|
||||
test("inherited bash rules apply when no user top-level catchall", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
permission: {
|
||||
bash: { "rm *": "deny" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
const opencodeDir = path.join(dir, ".opencode")
|
||||
await fs.mkdir(opencodeDir, { recursive: true })
|
||||
await Filesystem.write(
|
||||
path.join(opencodeDir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
permission: {
|
||||
bash: { "ls *": "allow" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const layers = ConfigPermission.toLayers(config.permission)
|
||||
const ruleset = Permission.merge(...layers.map((p) => Permission.fromConfig(p)))
|
||||
|
||||
expect(Permission.evaluate("bash", "rm -rf /", ruleset).action).toBe("deny")
|
||||
expect(Permission.evaluate("bash", "ls -la", ruleset).action).toBe("allow")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// User's bash "*" catchall overrides global "rm *" deny
|
||||
test("user bash catchall overrides inherited bash rules", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
permission: {
|
||||
bash: { "rm *": "deny" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
const opencodeDir = path.join(dir, ".opencode")
|
||||
await fs.mkdir(opencodeDir, { recursive: true })
|
||||
await Filesystem.write(
|
||||
path.join(opencodeDir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
permission: {
|
||||
bash: { "*": "ask", "ls *": "allow" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const layers = ConfigPermission.toLayers(config.permission)
|
||||
const ruleset = Permission.merge(...layers.map((p) => Permission.fromConfig(p)))
|
||||
|
||||
expect(Permission.evaluate("bash", "rm -rf /", ruleset).action).toBe("ask")
|
||||
expect(Permission.evaluate("bash", "ls -la", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("bash", "echo hello", ruleset).action).toBe("ask")
|
||||
|
||||
// Non-bash permissions should use the top-level "*" rule
|
||||
expect(Permission.evaluate("read", "foo.txt", ruleset).action).toBe("ask")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("config parser preserves permission order while rejecting unknown top-level keys", () => {
|
||||
const config = ConfigParse.schema(
|
||||
Config.Info,
|
||||
@@ -1742,7 +1904,8 @@ test("config parser preserves permission order while rejecting unknown top-level
|
||||
"test",
|
||||
)
|
||||
|
||||
expect(Object.keys(config.permission!)).toEqual(["bash", "*", "edit"])
|
||||
// ConfigParse.schema preserves the raw shape the user wrote
|
||||
expect(Object.keys(config.permission as ConfigPermission.Info)).toEqual(["bash", "*", "edit"])
|
||||
try {
|
||||
ConfigParse.schema(Config.Info, { invalid_field: true }, "test")
|
||||
throw new Error("expected config parse to fail")
|
||||
@@ -2579,11 +2742,12 @@ test("parseManagedPlist parses permission rules", async () => {
|
||||
),
|
||||
"test:mobileconfig",
|
||||
)
|
||||
expect(config.permission?.["*"]).toBe("ask")
|
||||
expect(config.permission?.grep).toBe("allow")
|
||||
expect(config.permission?.webfetch).toBe("ask")
|
||||
expect(config.permission?.["~/.ssh/*"]).toBe("deny")
|
||||
const bash = config.permission?.bash as Record<string, string>
|
||||
const perm = config.permission as ConfigPermission.Info
|
||||
expect(perm?.["*"]).toBe("ask")
|
||||
expect(perm?.grep).toBe("allow")
|
||||
expect(perm?.webfetch).toBe("ask")
|
||||
expect(perm?.["~/.ssh/*"]).toBe("deny")
|
||||
const bash = perm?.bash as Record<string, string>
|
||||
expect(bash?.["rm -rf *"]).toBe("deny")
|
||||
expect(bash?.["curl *"]).toBe("deny")
|
||||
})
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Context, Effect, Layer, Logger } from "effect"
|
||||
import { Context, Deferred, Effect, Fiber, Layer, Logger } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { AppLayer } from "../../src/effect/app-runtime"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { InstanceRef } from "../../src/effect/instance-ref"
|
||||
import * as EffectLogger from "@opencode-ai/core/effect/logger"
|
||||
import { makeRuntime } from "../../src/effect/run-service"
|
||||
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import * as Observability from "@opencode-ai/core/effect/observability"
|
||||
import { attach } from "../../src/effect/run-service"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(CrossSpawnSpawner.defaultLayer)
|
||||
@@ -35,17 +36,8 @@ it.live("makeRuntime installs EffectLogger through Observability.layer", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
const current = yield* Effect.promise(() => makeRuntime(Dummy, layer).runPromise((svc) => svc.current()))
|
||||
|
||||
expect(current.effectLogger).toBe(true)
|
||||
expect(current.defaultLogger).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("AppRuntime also installs EffectLogger through Observability.layer", () =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Effect.promise(() =>
|
||||
AppRuntime.runPromise(Effect.map(Effect.service(Logger.CurrentLoggers), check)),
|
||||
const current = yield* Dummy.use((svc) => svc.current()).pipe(
|
||||
Effect.provide(Layer.provideMerge(layer, Observability.layer)),
|
||||
)
|
||||
|
||||
expect(current.effectLogger).toBe(true)
|
||||
@@ -53,46 +45,61 @@ it.live("AppRuntime also installs EffectLogger through Observability.layer", ()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("AppRuntime attaches InstanceRef from ALS", () =>
|
||||
it.live("AppLayer also installs EffectLogger through Observability.layer", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const current = yield* Effect.promise(() =>
|
||||
AppRuntime.runPromise(
|
||||
const current = yield* Effect.map(Effect.service(Logger.CurrentLoggers), check).pipe(Effect.provide(AppLayer))
|
||||
|
||||
expect(current.effectLogger).toBe(true)
|
||||
expect(current.defaultLogger).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"attach preserves InstanceRef from the current fiber context",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const current = yield* attach(
|
||||
Effect.gen(function* () {
|
||||
return (yield* InstanceRef)?.directory
|
||||
}),
|
||||
),
|
||||
).pipe(provideInstance(dir))
|
||||
)
|
||||
|
||||
expect(current).toBe(dir)
|
||||
}),
|
||||
expect(current).toBe(test.directory)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.live("EffectBridge preserves logger and instance context across async boundaries", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const result = yield* Effect.promise(() =>
|
||||
AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const bridge = yield* EffectBridge.make()
|
||||
return yield* Effect.promise(() =>
|
||||
Promise.resolve().then(() =>
|
||||
bridge.promise(
|
||||
Effect.gen(function* () {
|
||||
return {
|
||||
directory: (yield* InstanceRef)?.directory,
|
||||
...check(yield* Effect.service(Logger.CurrentLoggers)),
|
||||
}
|
||||
}),
|
||||
),
|
||||
it.instance(
|
||||
"EffectBridge preserves logger and instance context across async boundaries",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const bridge = yield* EffectBridge.make()
|
||||
const started = yield* Deferred.make<void>()
|
||||
|
||||
const fiber = yield* Effect.gen(function* () {
|
||||
yield* Deferred.succeed(started, undefined)
|
||||
return yield* Effect.promise(() =>
|
||||
Promise.resolve().then(() =>
|
||||
bridge.promise(
|
||||
Effect.gen(function* () {
|
||||
return {
|
||||
directory: (yield* InstanceRef)?.directory,
|
||||
...check(yield* Effect.service(Logger.CurrentLoggers)),
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
).pipe(provideInstance(dir))
|
||||
),
|
||||
)
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
expect(result.directory).toBe(dir)
|
||||
expect(result.effectLogger).toBe(true)
|
||||
expect(result.defaultLogger).toBe(false)
|
||||
}),
|
||||
yield* Deferred.await(started)
|
||||
const result = yield* Fiber.join(fiber)
|
||||
|
||||
expect(result.directory).toBe(test.directory)
|
||||
expect(result.effectLogger).toBe(true)
|
||||
expect(result.defaultLogger).toBe(false)
|
||||
}).pipe(Effect.provide(Observability.layer)),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
@@ -3,6 +3,11 @@ import { Deferred, Effect, Exit, Fiber, Latch, Ref, Scope } from "effect"
|
||||
import { Runner } from "@/effect/runner"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
const waitForState = <A, E>(runner: Runner.Runner<A, E>, tag: Runner.State<A, E>["_tag"]) =>
|
||||
Effect.gen(function* () {
|
||||
while (runner.state._tag !== tag) yield* Effect.yieldNow
|
||||
}).pipe(Effect.timeout("1 second"))
|
||||
|
||||
describe("Runner", () => {
|
||||
// --- ensureRunning semantics ---
|
||||
|
||||
@@ -152,7 +157,7 @@ describe("Runner", () => {
|
||||
const s = yield* Scope.Scope
|
||||
const runner = Runner.make<string>(s, { onInterrupt: Effect.succeed("fallback") })
|
||||
const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("never"))).pipe(Effect.forkChild)
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* waitForState(runner, "Running")
|
||||
|
||||
yield* runner.cancel
|
||||
|
||||
@@ -169,9 +174,9 @@ describe("Runner", () => {
|
||||
const runner = Runner.make<string>(s, { onInterrupt: Effect.succeed("fallback") })
|
||||
|
||||
const a = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild)
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* waitForState(runner, "Running")
|
||||
const b = yield* runner.ensureRunning(Effect.succeed("y")).pipe(Effect.forkChild)
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* runner.cancel
|
||||
|
||||
@@ -189,7 +194,7 @@ describe("Runner", () => {
|
||||
const s = yield* Scope.Scope
|
||||
const runner = Runner.make<string>(s)
|
||||
const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild)
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* waitForState(runner, "Running")
|
||||
yield* runner.cancel
|
||||
yield* Fiber.await(fiber)
|
||||
|
||||
@@ -215,7 +220,7 @@ describe("Runner", () => {
|
||||
)
|
||||
|
||||
const a = yield* runner.ensureRunning(first).pipe(Effect.exit, Effect.forkChild)
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* waitForState(runner, "Running")
|
||||
|
||||
const stop = yield* runner.cancel.pipe(Effect.forkChild)
|
||||
yield* Deferred.await(hit).pipe(Effect.timeout("250 millis"))
|
||||
@@ -293,7 +298,7 @@ describe("Runner", () => {
|
||||
const gate = yield* Deferred.make<void>()
|
||||
|
||||
const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("first"))).pipe(Effect.forkChild)
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* waitForState(runner, "Shell")
|
||||
|
||||
const exit = yield* runner.startShell(Effect.succeed("second")).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
@@ -314,7 +319,7 @@ describe("Runner", () => {
|
||||
})
|
||||
|
||||
const sh = yield* runner.startShell(Effect.never.pipe(Effect.as("aborted"))).pipe(Effect.forkChild)
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* waitForState(runner, "Shell")
|
||||
|
||||
const exit = yield* runner.startShell(Effect.succeed("second")).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
@@ -333,7 +338,7 @@ describe("Runner", () => {
|
||||
const gate = yield* Deferred.make<void>()
|
||||
|
||||
const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("ignored"))).pipe(Effect.forkChild)
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* waitForState(runner, "Shell")
|
||||
|
||||
const stop = yield* runner.cancel.pipe(Effect.forkChild)
|
||||
const stopExit = yield* Fiber.await(stop).pipe(Effect.timeout("250 millis"))
|
||||
@@ -380,11 +385,11 @@ describe("Runner", () => {
|
||||
const gate = yield* Deferred.make<void>()
|
||||
|
||||
const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("shell-result"))).pipe(Effect.forkChild)
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* waitForState(runner, "Shell")
|
||||
expect(runner.state._tag).toBe("Shell")
|
||||
|
||||
const run = yield* runner.ensureRunning(Effect.succeed("run-result")).pipe(Effect.forkChild)
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* waitForState(runner, "ShellThenRun")
|
||||
expect(runner.state._tag).toBe("ShellThenRun")
|
||||
|
||||
yield* Deferred.succeed(gate, undefined)
|
||||
@@ -406,7 +411,7 @@ describe("Runner", () => {
|
||||
const gate = yield* Deferred.make<void>()
|
||||
|
||||
const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("shell"))).pipe(Effect.forkChild)
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* waitForState(runner, "Shell")
|
||||
|
||||
const work = Effect.gen(function* () {
|
||||
yield* Ref.update(calls, (n) => n + 1)
|
||||
@@ -414,7 +419,7 @@ describe("Runner", () => {
|
||||
})
|
||||
const a = yield* runner.ensureRunning(work).pipe(Effect.forkChild)
|
||||
const b = yield* runner.ensureRunning(work).pipe(Effect.forkChild)
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* waitForState(runner, "ShellThenRun")
|
||||
|
||||
yield* Deferred.succeed(gate, undefined)
|
||||
yield* Fiber.await(sh)
|
||||
@@ -433,10 +438,10 @@ describe("Runner", () => {
|
||||
const runner = Runner.make<string>(s)
|
||||
|
||||
const sh = yield* runner.startShell(Effect.never.pipe(Effect.as("aborted"))).pipe(Effect.forkChild)
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* waitForState(runner, "Shell")
|
||||
|
||||
const run = yield* runner.ensureRunning(Effect.succeed("y")).pipe(Effect.forkChild)
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* waitForState(runner, "ShellThenRun")
|
||||
expect(runner.state._tag).toBe("ShellThenRun")
|
||||
|
||||
yield* runner.cancel
|
||||
@@ -472,7 +477,7 @@ describe("Runner", () => {
|
||||
onIdle: Ref.update(count, (n) => n + 1),
|
||||
})
|
||||
const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild)
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* waitForState(runner, "Running")
|
||||
yield* runner.cancel
|
||||
yield* Fiber.await(fiber)
|
||||
expect(yield* Ref.get(count)).toBeGreaterThanOrEqual(1)
|
||||
@@ -502,7 +507,7 @@ describe("Runner", () => {
|
||||
const gate = yield* Deferred.make<void>()
|
||||
|
||||
const fiber = yield* runner.ensureRunning(Deferred.await(gate).pipe(Effect.as("ok"))).pipe(Effect.forkChild)
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* waitForState(runner, "Running")
|
||||
expect(runner.busy).toBe(true)
|
||||
|
||||
yield* Deferred.succeed(gate, undefined)
|
||||
@@ -519,7 +524,7 @@ describe("Runner", () => {
|
||||
const gate = yield* Deferred.make<void>()
|
||||
|
||||
const fiber = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("ok"))).pipe(Effect.forkChild)
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* waitForState(runner, "Shell")
|
||||
expect(runner.busy).toBe(true)
|
||||
|
||||
yield* Deferred.succeed(gate, undefined)
|
||||
|
||||
@@ -3,67 +3,71 @@ import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { File } from "../../src/file"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { provideInstance, tmpdir } from "../fixture/fixture"
|
||||
|
||||
const run = <A, E>(eff: Effect.Effect<A, E, File.Service>) =>
|
||||
Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer))))
|
||||
const status = () => run(File.Service.use((svc) => svc.status()))
|
||||
const read = (file: string) => run(File.Service.use((svc) => svc.read(file)))
|
||||
|
||||
const wintest = process.platform === "win32" ? test : test.skip
|
||||
const it =
|
||||
process.platform === "win32"
|
||||
? (await import("../lib/effect")).testEffect((await import("../../src/file")).File.defaultLayer)
|
||||
: undefined
|
||||
|
||||
describe("file fsmonitor", () => {
|
||||
wintest("status does not start fsmonitor for readonly git checks", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const target = path.join(tmp.path, "tracked.txt")
|
||||
if (!it) {
|
||||
test.skip("status does not start fsmonitor for readonly git checks", () => {})
|
||||
test.skip("read does not start fsmonitor for git diffs", () => {})
|
||||
return
|
||||
}
|
||||
|
||||
await fs.writeFile(target, "base\n")
|
||||
await $`git add tracked.txt`.cwd(tmp.path).quiet()
|
||||
await $`git commit -m init`.cwd(tmp.path).quiet()
|
||||
await $`git config core.fsmonitor true`.cwd(tmp.path).quiet()
|
||||
await $`git fsmonitor--daemon stop`.cwd(tmp.path).quiet().nothrow()
|
||||
await fs.writeFile(target, "next\n")
|
||||
await fs.writeFile(path.join(tmp.path, "new.txt"), "new\n")
|
||||
it.instance(
|
||||
"status does not start fsmonitor for readonly git checks",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const { File } = yield* Effect.promise(() => import("../../src/file"))
|
||||
const { TestInstance } = yield* Effect.promise(() => import("../fixture/fixture"))
|
||||
const directory = (yield* TestInstance).directory
|
||||
const target = path.join(directory, "tracked.txt")
|
||||
|
||||
const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow()
|
||||
expect(before.exitCode).not.toBe(0)
|
||||
yield* Effect.promise(() => fs.writeFile(target, "base\n"))
|
||||
yield* Effect.promise(() => $`git add tracked.txt`.cwd(directory).quiet())
|
||||
yield* Effect.promise(() => $`git commit -m init`.cwd(directory).quiet())
|
||||
yield* Effect.promise(() => $`git config core.fsmonitor true`.cwd(directory).quiet())
|
||||
yield* Effect.promise(() => $`git fsmonitor--daemon stop`.cwd(directory).quiet().nothrow())
|
||||
yield* Effect.promise(() => fs.writeFile(target, "next\n"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "new.txt"), "new\n"))
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await status()
|
||||
},
|
||||
})
|
||||
const before = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(directory).quiet().nothrow())
|
||||
expect(before.exitCode).not.toBe(0)
|
||||
|
||||
const after = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow()
|
||||
expect(after.exitCode).not.toBe(0)
|
||||
})
|
||||
yield* File.Service.use((svc) => svc.status())
|
||||
|
||||
wintest("read does not start fsmonitor for git diffs", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const target = path.join(tmp.path, "tracked.txt")
|
||||
const after = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(directory).quiet().nothrow())
|
||||
expect(after.exitCode).not.toBe(0)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
await fs.writeFile(target, "base\n")
|
||||
await $`git add tracked.txt`.cwd(tmp.path).quiet()
|
||||
await $`git commit -m init`.cwd(tmp.path).quiet()
|
||||
await $`git config core.fsmonitor true`.cwd(tmp.path).quiet()
|
||||
await $`git fsmonitor--daemon stop`.cwd(tmp.path).quiet().nothrow()
|
||||
await fs.writeFile(target, "next\n")
|
||||
it.instance(
|
||||
"read does not start fsmonitor for git diffs",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const { File } = yield* Effect.promise(() => import("../../src/file"))
|
||||
const { TestInstance } = yield* Effect.promise(() => import("../fixture/fixture"))
|
||||
const directory = (yield* TestInstance).directory
|
||||
const target = path.join(directory, "tracked.txt")
|
||||
|
||||
const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow()
|
||||
expect(before.exitCode).not.toBe(0)
|
||||
yield* Effect.promise(() => fs.writeFile(target, "base\n"))
|
||||
yield* Effect.promise(() => $`git add tracked.txt`.cwd(directory).quiet())
|
||||
yield* Effect.promise(() => $`git commit -m init`.cwd(directory).quiet())
|
||||
yield* Effect.promise(() => $`git config core.fsmonitor true`.cwd(directory).quiet())
|
||||
yield* Effect.promise(() => $`git fsmonitor--daemon stop`.cwd(directory).quiet().nothrow())
|
||||
yield* Effect.promise(() => fs.writeFile(target, "next\n"))
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await read("tracked.txt")
|
||||
},
|
||||
})
|
||||
const before = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(directory).quiet().nothrow())
|
||||
expect(before.exitCode).not.toBe(0)
|
||||
|
||||
const after = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow()
|
||||
expect(after.exitCode).not.toBe(0)
|
||||
})
|
||||
yield* File.Service.use((svc) => svc.read("tracked.txt"))
|
||||
|
||||
const after = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(directory).quiet().nothrow())
|
||||
expect(after.exitCode).not.toBe(0)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,42 +1,55 @@
|
||||
import { test, expect, describe } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { expect, describe } from "bun:test"
|
||||
import { Cause, Effect, Exit } from "effect"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { File } from "../../src/file"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { InstanceState } from "../../src/effect/instance-state"
|
||||
import { containsPath } from "../../src/project/instance-context"
|
||||
import { provideInstance, tmpdir } from "../fixture/fixture"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const run = <A, E>(eff: Effect.Effect<A, E, File.Service>) =>
|
||||
Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer))))
|
||||
const read = (file: string) => run(File.Service.use((svc) => svc.read(file)))
|
||||
const list = (dir?: string) => run(File.Service.use((svc) => svc.list(dir)))
|
||||
const it = testEffect(File.defaultLayer)
|
||||
const read = (file: string) => File.Service.use((svc) => svc.read(file))
|
||||
const list = (dir?: string) => File.Service.use((svc) => svc.list(dir))
|
||||
const expectAccessDenied = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* effect.pipe(Effect.exit)
|
||||
if (Exit.isSuccess(exit)) throw new Error("expected access denied")
|
||||
expect(Cause.squash(exit.cause)).toHaveProperty("message", "Access denied: path escapes project directory")
|
||||
})
|
||||
|
||||
describe("Filesystem.contains", () => {
|
||||
test("allows paths within project", () => {
|
||||
expect(Filesystem.contains("/project", "/project/src")).toBe(true)
|
||||
expect(Filesystem.contains("/project", "/project/src/file.ts")).toBe(true)
|
||||
expect(Filesystem.contains("/project", "/project")).toBe(true)
|
||||
})
|
||||
it.effect("allows paths within project", () =>
|
||||
Effect.sync(() => {
|
||||
expect(Filesystem.contains("/project", "/project/src")).toBe(true)
|
||||
expect(Filesystem.contains("/project", "/project/src/file.ts")).toBe(true)
|
||||
expect(Filesystem.contains("/project", "/project")).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
test("blocks ../ traversal", () => {
|
||||
expect(Filesystem.contains("/project", "/project/../etc")).toBe(false)
|
||||
expect(Filesystem.contains("/project", "/project/src/../../etc")).toBe(false)
|
||||
expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false)
|
||||
})
|
||||
it.effect("blocks ../ traversal", () =>
|
||||
Effect.sync(() => {
|
||||
expect(Filesystem.contains("/project", "/project/../etc")).toBe(false)
|
||||
expect(Filesystem.contains("/project", "/project/src/../../etc")).toBe(false)
|
||||
expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
test("blocks absolute paths outside project", () => {
|
||||
expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false)
|
||||
expect(Filesystem.contains("/project", "/tmp/file")).toBe(false)
|
||||
expect(Filesystem.contains("/home/user/project", "/home/user/other")).toBe(false)
|
||||
})
|
||||
it.effect("blocks absolute paths outside project", () =>
|
||||
Effect.sync(() => {
|
||||
expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false)
|
||||
expect(Filesystem.contains("/project", "/tmp/file")).toBe(false)
|
||||
expect(Filesystem.contains("/home/user/project", "/home/user/other")).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
test("handles prefix collision edge cases", () => {
|
||||
expect(Filesystem.contains("/project", "/project-other/file")).toBe(false)
|
||||
expect(Filesystem.contains("/project", "/projectfile")).toBe(false)
|
||||
})
|
||||
it.effect("handles prefix collision edge cases", () =>
|
||||
Effect.sync(() => {
|
||||
expect(Filesystem.contains("/project", "/project-other/file")).toBe(false)
|
||||
expect(Filesystem.contains("/project", "/projectfile")).toBe(false)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
/*
|
||||
@@ -49,158 +62,124 @@ describe("Filesystem.contains", () => {
|
||||
* This is a SEPARATE code path from ReadTool, which has its own checks.
|
||||
*/
|
||||
describe("File.read path traversal protection", () => {
|
||||
test("rejects ../ traversal attempting to read /etc/passwd", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "allowed.txt"), "allowed content")
|
||||
},
|
||||
})
|
||||
it.instance("rejects ../ traversal attempting to read /etc/passwd", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => Bun.write(path.join(test.directory, "allowed.txt"), "allowed content"))
|
||||
yield* expectAccessDenied(read("../../../etc/passwd"))
|
||||
}),
|
||||
)
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(read("../../../etc/passwd")).rejects.toThrow("Access denied: path escapes project directory")
|
||||
},
|
||||
})
|
||||
})
|
||||
it.instance("rejects deeply nested traversal", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* expectAccessDenied(read("src/nested/../../../../../../../etc/passwd"))
|
||||
}),
|
||||
)
|
||||
|
||||
test("rejects deeply nested traversal", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
it.instance("allows valid paths within project", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => Bun.write(path.join(test.directory, "valid.txt"), "valid content"))
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(read("src/nested/../../../../../../../etc/passwd")).rejects.toThrow(
|
||||
"Access denied: path escapes project directory",
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("allows valid paths within project", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "valid.txt"), "valid content")
|
||||
},
|
||||
})
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await read("valid.txt")
|
||||
expect(result.content).toBe("valid content")
|
||||
},
|
||||
})
|
||||
})
|
||||
const result = yield* read("valid.txt")
|
||||
expect(result.content).toBe("valid content")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("File.list path traversal protection", () => {
|
||||
test("rejects ../ traversal attempting to list /etc", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
it.instance("rejects ../ traversal attempting to list /etc", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* expectAccessDenied(list("../../../etc"))
|
||||
}),
|
||||
)
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(list("../../../etc")).rejects.toThrow("Access denied: path escapes project directory")
|
||||
},
|
||||
})
|
||||
})
|
||||
it.instance("allows valid subdirectory listing", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => Bun.write(path.join(test.directory, "subdir", "file.txt"), "content"))
|
||||
|
||||
test("allows valid subdirectory listing", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "subdir", "file.txt"), "content")
|
||||
},
|
||||
})
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await list("subdir")
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
const result = yield* list("subdir")
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("containsPath", () => {
|
||||
test("returns true for path inside directory", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
it.instance(
|
||||
"returns true for path inside directory",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const ctx = yield* InstanceState.context
|
||||
expect(containsPath(path.join(test.directory, "foo.txt"), ctx)).toBe(true)
|
||||
expect(containsPath(path.join(test.directory, "src", "file.ts"), ctx)).toBe(true)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => {
|
||||
expect(containsPath(path.join(tmp.path, "foo.txt"), Instance.current)).toBe(true)
|
||||
expect(containsPath(path.join(tmp.path, "src", "file.ts"), Instance.current)).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
it.instance(
|
||||
"returns true for path inside worktree but outside directory (monorepo subdirectory scenario)",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const subdir = path.join(test.directory, "packages", "lib")
|
||||
yield* Effect.promise(() => fs.mkdir(subdir, { recursive: true }))
|
||||
const ctx = { ...(yield* InstanceState.context), directory: subdir }
|
||||
|
||||
test("returns true for path inside worktree but outside directory (monorepo subdirectory scenario)", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const subdir = path.join(tmp.path, "packages", "lib")
|
||||
await fs.mkdir(subdir, { recursive: true })
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: subdir,
|
||||
fn: () => {
|
||||
// .opencode at worktree root, but we're running from packages/lib
|
||||
expect(containsPath(path.join(tmp.path, ".opencode", "state"), Instance.current)).toBe(true)
|
||||
expect(containsPath(path.join(test.directory, ".opencode", "state"), ctx)).toBe(true)
|
||||
// sibling package should also be accessible
|
||||
expect(containsPath(path.join(tmp.path, "packages", "other", "file.ts"), Instance.current)).toBe(true)
|
||||
expect(containsPath(path.join(test.directory, "packages", "other", "file.ts"), ctx)).toBe(true)
|
||||
// worktree root itself
|
||||
expect(containsPath(tmp.path, Instance.current)).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
expect(containsPath(test.directory, ctx)).toBe(true)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
test("returns false for path outside both directory and worktree", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
it.instance(
|
||||
"returns false for path outside both directory and worktree",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const ctx = yield* InstanceState.context
|
||||
expect(containsPath("/etc/passwd", ctx)).toBe(false)
|
||||
expect(containsPath("/tmp/other-project", ctx)).toBe(false)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => {
|
||||
expect(containsPath("/etc/passwd", Instance.current)).toBe(false)
|
||||
expect(containsPath("/tmp/other-project", Instance.current)).toBe(false)
|
||||
},
|
||||
})
|
||||
})
|
||||
it.instance(
|
||||
"returns false for path with .. escaping worktree",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const ctx = yield* InstanceState.context
|
||||
expect(containsPath(path.join(test.directory, "..", "escape.txt"), ctx)).toBe(false)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
test("returns false for path with .. escaping worktree", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
it.instance(
|
||||
"handles directory === worktree (running from repo root)",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const ctx = yield* InstanceState.context
|
||||
expect(ctx.directory).toBe(ctx.worktree)
|
||||
expect(containsPath(path.join(test.directory, "file.txt"), ctx)).toBe(true)
|
||||
expect(containsPath("/etc/passwd", ctx)).toBe(false)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => {
|
||||
expect(containsPath(path.join(tmp.path, "..", "escape.txt"), Instance.current)).toBe(false)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("handles directory === worktree (running from repo root)", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => {
|
||||
expect(Instance.directory).toBe(Instance.worktree)
|
||||
expect(containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true)
|
||||
expect(containsPath("/etc/passwd", Instance.current)).toBe(false)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("non-git project does not allow arbitrary paths via worktree='/'", async () => {
|
||||
await using tmp = await tmpdir() // no git: true
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => {
|
||||
// worktree is "/" for non-git projects, but containsPath should NOT allow all paths
|
||||
expect(containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true)
|
||||
expect(containsPath("/etc/passwd", Instance.current)).toBe(false)
|
||||
expect(containsPath("/tmp/other", Instance.current)).toBe(false)
|
||||
},
|
||||
})
|
||||
})
|
||||
it.instance("non-git project does not allow arbitrary paths via worktree='/'", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const ctx = yield* InstanceState.context
|
||||
// worktree is "/" for non-git projects, but containsPath should NOT allow all paths
|
||||
expect(containsPath(path.join(test.directory, "file.txt"), ctx)).toBe(true)
|
||||
expect(containsPath("/etc/passwd", ctx)).toBe(false)
|
||||
expect(containsPath("/tmp/other", ctx)).toBe(false)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,214 +1,220 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Ripgrep } from "../../src/file/ripgrep"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const run = <A>(effect: Effect.Effect<A, unknown, Ripgrep.Service>) =>
|
||||
effect.pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
|
||||
const it = testEffect(Ripgrep.defaultLayer)
|
||||
|
||||
const tmpdir = (init?: (dir: string) => Effect.Effect<void>) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(async () => fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-test-")))),
|
||||
(dir) =>
|
||||
Effect.promise(() =>
|
||||
fs.rm(dir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 5,
|
||||
retryDelay: 100,
|
||||
}),
|
||||
).pipe(Effect.ignore),
|
||||
).pipe(Effect.tap((dir) => init?.(dir) ?? Effect.void))
|
||||
|
||||
const write = (file: string, data: string) => Effect.promise(() => Bun.write(file, data))
|
||||
const mkdir = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true }))
|
||||
const collectFiles = (input: Ripgrep.FilesInput) =>
|
||||
Ripgrep.Service.use((rg) =>
|
||||
rg.files(input).pipe(
|
||||
Stream.runCollect,
|
||||
Effect.map((c) => [...c]),
|
||||
),
|
||||
)
|
||||
|
||||
const withRipgrepConfig = <A, E, R>(value: string, effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const prev = process.env["RIPGREP_CONFIG_PATH"]
|
||||
process.env["RIPGREP_CONFIG_PATH"] = value
|
||||
return prev
|
||||
}),
|
||||
() => effect,
|
||||
(prev) =>
|
||||
Effect.sync(() => {
|
||||
if (prev === undefined) delete process.env["RIPGREP_CONFIG_PATH"]
|
||||
else process.env["RIPGREP_CONFIG_PATH"] = prev
|
||||
}),
|
||||
)
|
||||
|
||||
describe("file.ripgrep", () => {
|
||||
test("defaults to include hidden", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "visible.txt"), "hello")
|
||||
await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
|
||||
await Bun.write(path.join(dir, ".opencode", "thing.json"), "{}")
|
||||
},
|
||||
})
|
||||
it.live("defaults to include hidden", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* write(path.join(dir, "visible.txt"), "hello")
|
||||
yield* mkdir(path.join(dir, ".opencode"))
|
||||
yield* write(path.join(dir, ".opencode", "thing.json"), "{}")
|
||||
}),
|
||||
)
|
||||
|
||||
const files = await run(
|
||||
Ripgrep.Service.use((rg) =>
|
||||
rg.files({ cwd: tmp.path }).pipe(
|
||||
Stream.runCollect,
|
||||
Effect.map((c) => [...c]),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(files.includes("visible.txt")).toBe(true)
|
||||
expect(files.includes(path.join(".opencode", "thing.json"))).toBe(true)
|
||||
})
|
||||
const files = yield* collectFiles({ cwd: dir })
|
||||
expect(files.includes("visible.txt")).toBe(true)
|
||||
expect(files.includes(path.join(".opencode", "thing.json"))).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
test("hidden false excludes hidden", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "visible.txt"), "hello")
|
||||
await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
|
||||
await Bun.write(path.join(dir, ".opencode", "thing.json"), "{}")
|
||||
},
|
||||
})
|
||||
it.live("hidden false excludes hidden", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* write(path.join(dir, "visible.txt"), "hello")
|
||||
yield* mkdir(path.join(dir, ".opencode"))
|
||||
yield* write(path.join(dir, ".opencode", "thing.json"), "{}")
|
||||
}),
|
||||
)
|
||||
|
||||
const files = await run(
|
||||
Ripgrep.Service.use((rg) =>
|
||||
rg.files({ cwd: tmp.path, hidden: false }).pipe(
|
||||
Stream.runCollect,
|
||||
Effect.map((c) => [...c]),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(files.includes("visible.txt")).toBe(true)
|
||||
expect(files.includes(path.join(".opencode", "thing.json"))).toBe(false)
|
||||
})
|
||||
const files = yield* collectFiles({ cwd: dir, hidden: false })
|
||||
expect(files.includes("visible.txt")).toBe(true)
|
||||
expect(files.includes(path.join(".opencode", "thing.json"))).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
test("search returns empty when nothing matches", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "match.ts"), "const value = 'other'\n")
|
||||
},
|
||||
})
|
||||
it.live("search returns empty when nothing matches", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) => write(path.join(dir, "match.ts"), "const value = 'other'\n"))
|
||||
|
||||
const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle" })))
|
||||
expect(result.partial).toBe(false)
|
||||
expect(result.items).toEqual([])
|
||||
})
|
||||
const result = yield* Ripgrep.Service.use((rg) => rg.search({ cwd: dir, pattern: "needle" }))
|
||||
expect(result.partial).toBe(false)
|
||||
expect(result.items).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
test("search returns match metadata with normalized path", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await fs.mkdir(path.join(dir, "src"), { recursive: true })
|
||||
await Bun.write(path.join(dir, "src", "match.ts"), "const needle = 1\n")
|
||||
},
|
||||
})
|
||||
it.live("search returns match metadata with normalized path", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* mkdir(path.join(dir, "src"))
|
||||
yield* write(path.join(dir, "src", "match.ts"), "const needle = 1\n")
|
||||
}),
|
||||
)
|
||||
|
||||
const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle" })))
|
||||
expect(result.partial).toBe(false)
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0]?.path.text).toBe(path.join("src", "match.ts"))
|
||||
expect(result.items[0]?.line_number).toBe(1)
|
||||
expect(result.items[0]?.lines.text).toContain("needle")
|
||||
})
|
||||
|
||||
test("search returns matched rows with glob filter", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "match.ts"), "const value = 'needle'\n")
|
||||
await Bun.write(path.join(dir, "skip.txt"), "const value = 'other'\n")
|
||||
},
|
||||
})
|
||||
|
||||
const result = await run(
|
||||
Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle", glob: ["*.ts"] })),
|
||||
)
|
||||
expect(result.partial).toBe(false)
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0]?.path.text).toContain("match.ts")
|
||||
expect(result.items[0]?.lines.text).toContain("needle")
|
||||
})
|
||||
|
||||
test("search supports explicit file targets", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "match.ts"), "const value = 'needle'\n")
|
||||
await Bun.write(path.join(dir, "skip.ts"), "const value = 'needle'\n")
|
||||
},
|
||||
})
|
||||
|
||||
const file = path.join(tmp.path, "match.ts")
|
||||
const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle", file: [file] })))
|
||||
expect(result.partial).toBe(false)
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0]?.path.text).toBe(file)
|
||||
})
|
||||
|
||||
test("files returns empty when glob matches no files", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await fs.mkdir(path.join(dir, "packages", "console"), { recursive: true })
|
||||
await Bun.write(path.join(dir, "packages", "console", "package.json"), "{}")
|
||||
},
|
||||
})
|
||||
|
||||
const files = await run(
|
||||
Ripgrep.Service.use((rg) =>
|
||||
rg.files({ cwd: tmp.path, glob: ["packages/*"] }).pipe(
|
||||
Stream.runCollect,
|
||||
Effect.map((c) => [...c]),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(files).toEqual([])
|
||||
})
|
||||
|
||||
test("files returns stream of filenames", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "a.txt"), "hello")
|
||||
await Bun.write(path.join(dir, "b.txt"), "world")
|
||||
},
|
||||
})
|
||||
|
||||
const files = await run(
|
||||
Ripgrep.Service.use((rg) =>
|
||||
rg.files({ cwd: tmp.path }).pipe(
|
||||
Stream.runCollect,
|
||||
Effect.map((c) => [...c].sort()),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(files).toEqual(["a.txt", "b.txt"])
|
||||
})
|
||||
|
||||
test("files respects glob filter", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "keep.ts"), "yes")
|
||||
await Bun.write(path.join(dir, "skip.txt"), "no")
|
||||
},
|
||||
})
|
||||
|
||||
const files = await run(
|
||||
Ripgrep.Service.use((rg) =>
|
||||
rg.files({ cwd: tmp.path, glob: ["*.ts"] }).pipe(
|
||||
Stream.runCollect,
|
||||
Effect.map((c) => [...c]),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(files).toEqual(["keep.ts"])
|
||||
})
|
||||
|
||||
test("files dies on nonexistent directory", async () => {
|
||||
const exit = await Ripgrep.Service.use((rg) =>
|
||||
rg.files({ cwd: "/tmp/nonexistent-dir-12345" }).pipe(Stream.runCollect),
|
||||
).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromiseExit)
|
||||
expect(exit._tag).toBe("Failure")
|
||||
})
|
||||
|
||||
test("ignores RIPGREP_CONFIG_PATH in direct mode", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "match.ts"), "const needle = 1\n")
|
||||
},
|
||||
})
|
||||
|
||||
const prev = process.env["RIPGREP_CONFIG_PATH"]
|
||||
process.env["RIPGREP_CONFIG_PATH"] = path.join(tmp.path, "missing-ripgreprc")
|
||||
try {
|
||||
const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle" })))
|
||||
const result = yield* Ripgrep.Service.use((rg) => rg.search({ cwd: dir, pattern: "needle" }))
|
||||
expect(result.partial).toBe(false)
|
||||
expect(result.items).toHaveLength(1)
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env["RIPGREP_CONFIG_PATH"]
|
||||
else process.env["RIPGREP_CONFIG_PATH"] = prev
|
||||
}
|
||||
})
|
||||
expect(result.items[0]?.path.text).toBe(path.join("src", "match.ts"))
|
||||
expect(result.items[0]?.line_number).toBe(1)
|
||||
expect(result.items[0]?.lines.text).toContain("needle")
|
||||
}),
|
||||
)
|
||||
|
||||
test("ignores RIPGREP_CONFIG_PATH in worker mode", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "match.ts"), "const needle = 1\n")
|
||||
},
|
||||
})
|
||||
it.live("search returns matched rows with glob filter", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* write(path.join(dir, "match.ts"), "const value = 'needle'\n")
|
||||
yield* write(path.join(dir, "skip.txt"), "const value = 'other'\n")
|
||||
}),
|
||||
)
|
||||
|
||||
const prev = process.env["RIPGREP_CONFIG_PATH"]
|
||||
process.env["RIPGREP_CONFIG_PATH"] = path.join(tmp.path, "missing-ripgreprc")
|
||||
try {
|
||||
const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle" })))
|
||||
const result = yield* Ripgrep.Service.use((rg) => rg.search({ cwd: dir, pattern: "needle", glob: ["*.ts"] }))
|
||||
expect(result.partial).toBe(false)
|
||||
expect(result.items).toHaveLength(1)
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env["RIPGREP_CONFIG_PATH"]
|
||||
else process.env["RIPGREP_CONFIG_PATH"] = prev
|
||||
}
|
||||
})
|
||||
expect(result.items[0]?.path.text).toContain("match.ts")
|
||||
expect(result.items[0]?.lines.text).toContain("needle")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("search supports explicit file targets", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* write(path.join(dir, "match.ts"), "const value = 'needle'\n")
|
||||
yield* write(path.join(dir, "skip.ts"), "const value = 'needle'\n")
|
||||
}),
|
||||
)
|
||||
|
||||
const file = path.join(dir, "match.ts")
|
||||
const result = yield* Ripgrep.Service.use((rg) => rg.search({ cwd: dir, pattern: "needle", file: [file] }))
|
||||
expect(result.partial).toBe(false)
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0]?.path.text).toBe(file)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("files returns empty when glob matches no files", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* mkdir(path.join(dir, "packages", "console"))
|
||||
yield* write(path.join(dir, "packages", "console", "package.json"), "{}")
|
||||
}),
|
||||
)
|
||||
|
||||
const files = yield* collectFiles({ cwd: dir, glob: ["packages/*"] })
|
||||
expect(files).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("files returns stream of filenames", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* write(path.join(dir, "a.txt"), "hello")
|
||||
yield* write(path.join(dir, "b.txt"), "world")
|
||||
}),
|
||||
)
|
||||
|
||||
const files = yield* collectFiles({ cwd: dir }).pipe(Effect.map((files) => files.sort()))
|
||||
expect(files).toEqual(["a.txt", "b.txt"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("files respects glob filter", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* write(path.join(dir, "keep.ts"), "yes")
|
||||
yield* write(path.join(dir, "skip.txt"), "no")
|
||||
}),
|
||||
)
|
||||
|
||||
const files = yield* collectFiles({ cwd: dir, glob: ["*.ts"] })
|
||||
expect(files).toEqual(["keep.ts"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("files dies on nonexistent directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Ripgrep.Service.use((rg) =>
|
||||
rg.files({ cwd: "/tmp/nonexistent-dir-12345" }).pipe(Stream.runCollect),
|
||||
).pipe(Effect.exit)
|
||||
expect(exit._tag).toBe("Failure")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("ignores RIPGREP_CONFIG_PATH in direct mode", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) => write(path.join(dir, "match.ts"), "const needle = 1\n"))
|
||||
|
||||
const result = yield* withRipgrepConfig(
|
||||
path.join(dir, "missing-ripgreprc"),
|
||||
Ripgrep.Service.use((rg) => rg.search({ cwd: dir, pattern: "needle" })),
|
||||
)
|
||||
expect(result.items).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("ignores RIPGREP_CONFIG_PATH in worker mode", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) => write(path.join(dir, "match.ts"), "const needle = 1\n"))
|
||||
|
||||
const result = yield* withRipgrepConfig(
|
||||
path.join(dir, "missing-ripgreprc"),
|
||||
Ripgrep.Service.use((rg) => rg.search({ cwd: dir, pattern: "needle" })),
|
||||
)
|
||||
expect(result.items).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -135,7 +135,7 @@ export function tmpdirScoped(options?: { git?: boolean; config?: Partial<Config.
|
||||
yield* git("config", "commit.gpgsign", "false")
|
||||
yield* git("config", "user.email", "test@opencode.test")
|
||||
yield* git("config", "user.name", "Test")
|
||||
yield* git("commit", "--allow-empty", "-m", "root commit")
|
||||
yield* git("commit", "--allow-empty", "-m", `root commit ${dir}`)
|
||||
}
|
||||
|
||||
if (options?.config) {
|
||||
|
||||
@@ -1,71 +1,70 @@
|
||||
import { $ } from "bun"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { ManagedRuntime } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { Git } from "../../src/git"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt"
|
||||
const it = testEffect(Git.defaultLayer)
|
||||
|
||||
async function withGit<T>(body: (rt: ManagedRuntime.ManagedRuntime<Git.Service, never>) => Promise<T>) {
|
||||
const rt = ManagedRuntime.make(Git.defaultLayer)
|
||||
try {
|
||||
return await body(rt)
|
||||
} finally {
|
||||
await rt.dispose()
|
||||
}
|
||||
}
|
||||
const scopedTmpdir = (options?: Parameters<typeof tmpdir>[0]) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir(options)),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
|
||||
describe("Git", () => {
|
||||
test("branch() returns current branch name", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await withGit(async (rt) => {
|
||||
const branch = await rt.runPromise(Git.Service.use((git) => git.branch(tmp.path)))
|
||||
it.live("branch() returns current branch name", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* scopedTmpdir({ git: true })
|
||||
const git = yield* Git.Service
|
||||
const branch = yield* git.branch(tmp.path)
|
||||
expect(branch).toBeDefined()
|
||||
expect(typeof branch).toBe("string")
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
test("branch() returns undefined for non-git directories", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await withGit(async (rt) => {
|
||||
const branch = await rt.runPromise(Git.Service.use((git) => git.branch(tmp.path)))
|
||||
it.live("branch() returns undefined for non-git directories", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* scopedTmpdir()
|
||||
const git = yield* Git.Service
|
||||
const branch = yield* git.branch(tmp.path)
|
||||
expect(branch).toBeUndefined()
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
test("branch() returns undefined for detached HEAD", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const hash = (await $`git rev-parse HEAD`.cwd(tmp.path).quiet().text()).trim()
|
||||
await $`git checkout --detach ${hash}`.cwd(tmp.path).quiet()
|
||||
|
||||
await withGit(async (rt) => {
|
||||
const branch = await rt.runPromise(Git.Service.use((git) => git.branch(tmp.path)))
|
||||
it.live("branch() returns undefined for detached HEAD", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* scopedTmpdir({ git: true })
|
||||
const hash = (yield* Effect.promise(() => $`git rev-parse HEAD`.cwd(tmp.path).quiet().text())).trim()
|
||||
yield* Effect.promise(() => $`git checkout --detach ${hash}`.cwd(tmp.path).quiet())
|
||||
const git = yield* Git.Service
|
||||
const branch = yield* git.branch(tmp.path)
|
||||
expect(branch).toBeUndefined()
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
test("defaultBranch() uses init.defaultBranch when available", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await $`git branch -M trunk`.cwd(tmp.path).quiet()
|
||||
await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet()
|
||||
|
||||
await withGit(async (rt) => {
|
||||
const branch = await rt.runPromise(Git.Service.use((git) => git.defaultBranch(tmp.path)))
|
||||
it.live("defaultBranch() uses init.defaultBranch when available", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* scopedTmpdir({ git: true })
|
||||
yield* Effect.promise(() => $`git branch -M trunk`.cwd(tmp.path).quiet())
|
||||
yield* Effect.promise(() => $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet())
|
||||
const git = yield* Git.Service
|
||||
const branch = yield* git.defaultBranch(tmp.path)
|
||||
expect(branch?.name).toBe("trunk")
|
||||
expect(branch?.ref).toBe("trunk")
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
test("status() handles special filenames", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8")
|
||||
|
||||
await withGit(async (rt) => {
|
||||
const status = await rt.runPromise(Git.Service.use((git) => git.status(tmp.path)))
|
||||
it.live("status() handles special filenames", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* scopedTmpdir({ git: true })
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8"))
|
||||
const git = yield* Git.Service
|
||||
const status = yield* git.status(tmp.path)
|
||||
expect(status).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
@@ -74,23 +73,24 @@ describe("Git", () => {
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
test("diff(), stats(), and mergeBase() parse tracked changes", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await $`git branch -M main`.cwd(tmp.path).quiet()
|
||||
await fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
|
||||
await $`git checkout -b feature/test`.cwd(tmp.path).quiet()
|
||||
await fs.writeFile(path.join(tmp.path, weird), "after\n", "utf-8")
|
||||
it.live("diff(), stats(), and mergeBase() parse tracked changes", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* scopedTmpdir({ git: true })
|
||||
yield* Effect.promise(() => $`git branch -M main`.cwd(tmp.path).quiet())
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8"))
|
||||
yield* Effect.promise(() => $`git add .`.cwd(tmp.path).quiet())
|
||||
yield* Effect.promise(() => $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet())
|
||||
yield* Effect.promise(() => $`git checkout -b feature/test`.cwd(tmp.path).quiet())
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, weird), "after\n", "utf-8"))
|
||||
|
||||
await withGit(async (rt) => {
|
||||
const [base, diff, stats] = await Promise.all([
|
||||
rt.runPromise(Git.Service.use((git) => git.mergeBase(tmp.path, "main"))),
|
||||
rt.runPromise(Git.Service.use((git) => git.diff(tmp.path, "HEAD"))),
|
||||
rt.runPromise(Git.Service.use((git) => git.stats(tmp.path, "HEAD"))),
|
||||
const git = yield* Git.Service
|
||||
const [base, diff, stats] = yield* Effect.all([
|
||||
git.mergeBase(tmp.path, "main"),
|
||||
git.diff(tmp.path, "HEAD"),
|
||||
git.stats(tmp.path, "HEAD"),
|
||||
])
|
||||
|
||||
expect(base).toBeTruthy()
|
||||
@@ -111,23 +111,24 @@ describe("Git", () => {
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
test("patch() returns capped native patch output", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "other.txt"), "old\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
|
||||
await fs.writeFile(path.join(tmp.path, weird), "after\n", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "other.txt"), "new\n", "utf-8")
|
||||
it.live("patch() returns capped native patch output", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* scopedTmpdir({ git: true })
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "other.txt"), "old\n", "utf-8"))
|
||||
yield* Effect.promise(() => $`git add .`.cwd(tmp.path).quiet())
|
||||
yield* Effect.promise(() => $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet())
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, weird), "after\n", "utf-8"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "other.txt"), "new\n", "utf-8"))
|
||||
|
||||
await withGit(async (rt) => {
|
||||
const [patch, all, capped] = await Promise.all([
|
||||
rt.runPromise(Git.Service.use((git) => git.patch(tmp.path, "HEAD", weird, { context: 2_147_483_647 }))),
|
||||
rt.runPromise(Git.Service.use((git) => git.patchAll(tmp.path, "HEAD", { context: 2_147_483_647 }))),
|
||||
rt.runPromise(Git.Service.use((git) => git.patch(tmp.path, "HEAD", weird, { maxOutputBytes: 1 }))),
|
||||
const git = yield* Git.Service
|
||||
const [patch, all, capped] = yield* Effect.all([
|
||||
git.patch(tmp.path, "HEAD", weird, { context: 2_147_483_647 }),
|
||||
git.patchAll(tmp.path, "HEAD", { context: 2_147_483_647 }),
|
||||
git.patch(tmp.path, "HEAD", weird, { maxOutputBytes: 1 }),
|
||||
])
|
||||
|
||||
expect(patch.truncated).toBe(false)
|
||||
@@ -140,17 +141,18 @@ describe("Git", () => {
|
||||
expect(all.text).toContain("+new")
|
||||
expect(capped.truncated).toBe(true)
|
||||
expect(capped.text).toBe("")
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
test("patchUntracked() and statUntracked() handle added files", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, weird), "one\ntwo\n", "utf-8")
|
||||
it.live("patchUntracked() and statUntracked() handle added files", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* scopedTmpdir({ git: true })
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, weird), "one\ntwo\n", "utf-8"))
|
||||
|
||||
await withGit(async (rt) => {
|
||||
const [patch, stat] = await Promise.all([
|
||||
rt.runPromise(Git.Service.use((git) => git.patchUntracked(tmp.path, weird, { context: 2_147_483_647 }))),
|
||||
rt.runPromise(Git.Service.use((git) => git.statUntracked(tmp.path, weird))),
|
||||
const git = yield* Git.Service
|
||||
const [patch, stat] = yield* Effect.all([
|
||||
git.patchUntracked(tmp.path, weird, { context: 2_147_483_647 }),
|
||||
git.statUntracked(tmp.path, weird),
|
||||
])
|
||||
|
||||
expect(patch.truncated).toBe(false)
|
||||
@@ -158,18 +160,19 @@ describe("Git", () => {
|
||||
expect(patch.text).toContain("+one")
|
||||
expect(patch.text).toContain("+two")
|
||||
expect(stat).toEqual(expect.objectContaining({ file: weird, additions: 2, deletions: 0 }))
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
test("show() returns empty text for binary blobs", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, "bin.dat"), new Uint8Array([0, 1, 2, 3]))
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "add binary"`.cwd(tmp.path).quiet()
|
||||
it.live("show() returns empty text for binary blobs", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* scopedTmpdir({ git: true })
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "bin.dat"), new Uint8Array([0, 1, 2, 3])))
|
||||
yield* Effect.promise(() => $`git add .`.cwd(tmp.path).quiet())
|
||||
yield* Effect.promise(() => $`git commit --no-gpg-sign -m "add binary"`.cwd(tmp.path).quiet())
|
||||
|
||||
await withGit(async (rt) => {
|
||||
const text = await rt.runPromise(Git.Service.use((git) => git.show(tmp.path, "HEAD", "bin.dat")))
|
||||
const git = yield* Git.Service
|
||||
const text = yield* git.show(tmp.path, "HEAD", "bin.dat")
|
||||
expect(text).toBe("")
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, test, expect } from "bun:test"
|
||||
import { Permission } from "../src/permission"
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigPermission } from "@/config/permission"
|
||||
import { Instance } from "../src/project/instance"
|
||||
import { WithInstance } from "../src/project/with-instance"
|
||||
import { disposeAllInstances, tmpdir } from "./fixture/fixture"
|
||||
@@ -163,7 +164,9 @@ describe("permission.task with real config files", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
const ruleset = Permission.merge(
|
||||
...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)),
|
||||
)
|
||||
// general and orchestrator-fast should be allowed, code-reviewer denied
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
|
||||
@@ -188,7 +191,9 @@ describe("permission.task with real config files", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
const ruleset = Permission.merge(
|
||||
...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)),
|
||||
)
|
||||
// general and code-reviewer should be ask, orchestrator-* denied
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask")
|
||||
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
|
||||
@@ -213,7 +218,9 @@ describe("permission.task with real config files", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
const ruleset = Permission.merge(
|
||||
...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)),
|
||||
)
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
// Unspecified agents default to "ask"
|
||||
@@ -240,7 +247,9 @@ describe("permission.task with real config files", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
const ruleset = Permission.merge(
|
||||
...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)),
|
||||
)
|
||||
|
||||
// Verify task permissions
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
@@ -278,7 +287,9 @@ describe("permission.task with real config files", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
const ruleset = Permission.merge(
|
||||
...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)),
|
||||
)
|
||||
|
||||
// Last matching rule wins - "*" deny is last, so all agents are denied
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("deny")
|
||||
@@ -309,7 +320,9 @@ describe("permission.task with real config files", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
const ruleset = Permission.merge(
|
||||
...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)),
|
||||
)
|
||||
|
||||
// Evaluate uses findLast - "general" allow comes after "*" deny
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
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 { provideTestInstance, tmpdir } from "../fixture/fixture"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { provideInstance, TestInstance, tmpdirScoped } 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(
|
||||
@@ -37,13 +41,15 @@ function layer(directory: string, plugins: string[]) {
|
||||
}
|
||||
|
||||
describe("plugin.auth-override", () => {
|
||||
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 })
|
||||
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")
|
||||
|
||||
await Bun.write(
|
||||
yield* fs.writeWithDirs(
|
||||
path.join(pluginDir, "custom-copilot-auth.ts"),
|
||||
[
|
||||
"export default {",
|
||||
@@ -61,37 +67,26 @@ describe("plugin.auth-override", () => {
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await using plain = await tmpdir()
|
||||
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),
|
||||
)
|
||||
|
||||
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]))),
|
||||
)
|
||||
},
|
||||
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")
|
||||
}),
|
||||
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)
|
||||
{ git: true },
|
||||
30000,
|
||||
)
|
||||
})
|
||||
|
||||
const file = path.join(import.meta.dir, "../../src/plugin/index.ts")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,16 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { afterEach, expect } from "bun:test"
|
||||
import { existsSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { bootstrap as cliBootstrap } from "../../src/cli/bootstrap"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { InstanceRuntime } from "../../src/project/instance-runtime"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { InstanceLayer } from "../../src/project/instance-layer"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(InstanceLayer.layer, CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
// InstanceBootstrap must run before any code touches the instance —
|
||||
// originally tracked by PRs #25389 and #25449, now a permanent
|
||||
@@ -19,58 +24,64 @@ afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
async function bootstrapFixture() {
|
||||
return tmpdir({
|
||||
init: async (dir) => {
|
||||
const marker = path.join(dir, "config-hook-fired")
|
||||
const pluginFile = path.join(dir, "plugin.ts")
|
||||
await Bun.write(
|
||||
pluginFile,
|
||||
[
|
||||
`const MARKER = ${JSON.stringify(marker)}`,
|
||||
"export default async () => ({",
|
||||
" config: async () => {",
|
||||
' await Bun.write(MARKER, "ran")',
|
||||
" },",
|
||||
"})",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
plugin: [pathToFileURL(pluginFile).href],
|
||||
}),
|
||||
)
|
||||
return marker
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
test("WithInstance.provide runs InstanceBootstrap before fn", async () => {
|
||||
await using tmp = await bootstrapFixture()
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => "ok",
|
||||
})
|
||||
|
||||
expect(existsSync(tmp.extra)).toBe(true)
|
||||
const bootstrapFixture = Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const marker = path.join(dir, "config-hook-fired")
|
||||
const pluginFile = path.join(dir, "plugin.ts")
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
pluginFile,
|
||||
[
|
||||
`const MARKER = ${JSON.stringify(marker)}`,
|
||||
"export default async () => ({",
|
||||
" config: async () => {",
|
||||
' await Bun.write(MARKER, "ran")',
|
||||
" },",
|
||||
"})",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
plugin: [pathToFileURL(pluginFile).href],
|
||||
}),
|
||||
),
|
||||
)
|
||||
return { directory: dir, marker }
|
||||
})
|
||||
|
||||
test("CLI bootstrap runs InstanceBootstrap before callback", async () => {
|
||||
await using tmp = await bootstrapFixture()
|
||||
it.live("InstanceStore.provide runs InstanceBootstrap before effect", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* bootstrapFixture
|
||||
const store = yield* InstanceStore.Service
|
||||
|
||||
await cliBootstrap(tmp.path, async () => "ok")
|
||||
yield* store.provide({ directory: tmp.directory }, Effect.succeed("ok"))
|
||||
|
||||
expect(existsSync(tmp.extra)).toBe(true)
|
||||
})
|
||||
expect(existsSync(tmp.marker)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
test("InstanceRuntime.reloadInstance runs InstanceBootstrap", async () => {
|
||||
await using tmp = await bootstrapFixture()
|
||||
it.live("CLI bootstrap runs InstanceBootstrap before callback", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* bootstrapFixture
|
||||
|
||||
await InstanceRuntime.reloadInstance({ directory: tmp.path })
|
||||
yield* Effect.promise(() => cliBootstrap(tmp.directory, async () => "ok"))
|
||||
|
||||
expect(existsSync(tmp.extra)).toBe(true)
|
||||
})
|
||||
expect(existsSync(tmp.marker)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("InstanceStore.reload runs InstanceBootstrap", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* bootstrapFixture
|
||||
const store = yield* InstanceStore.Service
|
||||
|
||||
yield* store.reload({ directory: tmp.directory })
|
||||
|
||||
expect(existsSync(tmp.marker)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Effect, Fiber, Layer } from "effect"
|
||||
import { Deferred, 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, tmpdirScoped } from "../fixture/fixture"
|
||||
import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
let bootstrapRun: Effect.Effect<void> = Effect.void
|
||||
@@ -75,18 +74,18 @@ describe("InstanceStore", () => {
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const store = yield* InstanceStore.Service
|
||||
const started = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
let initialized = 0
|
||||
|
||||
bootstrapRun = Effect.promise(async () => {
|
||||
bootstrapRun = Effect.gen(function* () {
|
||||
initialized++
|
||||
started.resolve()
|
||||
await release.promise
|
||||
yield* Deferred.succeed(started, undefined)
|
||||
yield* Deferred.await(release)
|
||||
})
|
||||
const first = yield* store.load({ directory: dir }).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.promise(() => started.promise)
|
||||
yield* Deferred.await(started)
|
||||
|
||||
bootstrapRun = Effect.sync(() => {
|
||||
initialized++
|
||||
@@ -94,7 +93,7 @@ describe("InstanceStore", () => {
|
||||
const second = yield* store.load({ directory: dir }).pipe(Effect.forkScoped)
|
||||
|
||||
expect(initialized).toBe(1)
|
||||
release.resolve()
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
|
||||
const [firstCtx, secondCtx] = yield* Effect.all([Fiber.join(first), Fiber.join(second)])
|
||||
expect(secondCtx).toBe(firstCtx)
|
||||
@@ -147,8 +146,8 @@ describe("InstanceStore", () => {
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const store = yield* InstanceStore.Service
|
||||
const reloading = Promise.withResolvers<void>()
|
||||
const releaseReload = Promise.withResolvers<void>()
|
||||
const reloading = yield* Deferred.make<void>()
|
||||
const releaseReload = yield* Deferred.make<void>()
|
||||
const disposed: Array<string> = []
|
||||
const off = registerDisposer(async (directory) => {
|
||||
disposed.push(directory)
|
||||
@@ -156,15 +155,15 @@ describe("InstanceStore", () => {
|
||||
yield* Effect.addFinalizer(() => Effect.sync(off))
|
||||
|
||||
const first = yield* store.load({ directory: dir })
|
||||
bootstrapRun = Effect.promise(async () => {
|
||||
reloading.resolve()
|
||||
await releaseReload.promise
|
||||
bootstrapRun = Effect.gen(function* () {
|
||||
yield* Deferred.succeed(reloading, undefined)
|
||||
yield* Deferred.await(releaseReload)
|
||||
})
|
||||
const reload = yield* store.reload({ directory: dir }).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.promise(() => reloading.promise)
|
||||
yield* Deferred.await(reloading)
|
||||
const staleDispose = yield* store.dispose(first).pipe(Effect.forkScoped)
|
||||
releaseReload.resolve()
|
||||
yield* Deferred.succeed(releaseReload, undefined)
|
||||
|
||||
const second = yield* Fiber.join(reload)
|
||||
yield* Fiber.join(staleDispose)
|
||||
@@ -178,23 +177,23 @@ describe("InstanceStore", () => {
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const store = yield* InstanceStore.Service
|
||||
const disposing = Promise.withResolvers<void>()
|
||||
const releaseDispose = Promise.withResolvers<void>()
|
||||
const disposing = yield* Deferred.make<void>()
|
||||
const releaseDispose = yield* Deferred.make<void>()
|
||||
const disposed: Array<string> = []
|
||||
const off = registerDisposer(async (directory) => {
|
||||
disposed.push(directory)
|
||||
disposing.resolve()
|
||||
await releaseDispose.promise
|
||||
Deferred.doneUnsafe(disposing, Effect.void)
|
||||
await Effect.runPromise(Deferred.await(releaseDispose))
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(off))
|
||||
|
||||
yield* store.load({ directory: dir })
|
||||
const first = yield* store.disposeAll().pipe(Effect.forkScoped)
|
||||
yield* Effect.promise(() => disposing.promise)
|
||||
yield* Deferred.await(disposing)
|
||||
const second = yield* store.disposeAll().pipe(Effect.forkScoped)
|
||||
|
||||
expect(disposed).toEqual([dir])
|
||||
releaseDispose.resolve()
|
||||
yield* Deferred.succeed(releaseDispose, undefined)
|
||||
yield* Effect.all([Fiber.join(first), Fiber.join(second)])
|
||||
expect(disposed).toEqual([dir])
|
||||
}),
|
||||
@@ -221,19 +220,19 @@ describe("InstanceStore", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("provides legacy Promise callers with instance ALS", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
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")
|
||||
|
||||
const directory = yield* Effect.promise(() =>
|
||||
WithInstance.provide({
|
||||
directory: dir,
|
||||
fn: () => Instance.directory,
|
||||
}),
|
||||
)
|
||||
const directory = yield* Effect.promise(() => Promise.resolve(Instance.restore(ctx, () => Instance.directory)))
|
||||
|
||||
expect(directory).toBe(dir)
|
||||
expect(() => Instance.current).toThrow()
|
||||
}),
|
||||
expect(directory).toBe(test.directory)
|
||||
expect(() => Instance.current).toThrow()
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Project } from "@/project/project"
|
||||
import { Database } from "@/storage/db"
|
||||
import { eq } from "drizzle-orm"
|
||||
@@ -8,19 +8,14 @@ import { ProjectID } from "../../src/project/schema"
|
||||
import { SessionID } from "../../src/session/schema"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { $ } from "bun"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Effect } from "effect"
|
||||
import { tmpdirScoped } from "../fixture/fixture"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
Log.init({ print: false })
|
||||
void Log.init({ print: false })
|
||||
|
||||
function run<A>(fn: (svc: Project.Interface) => Effect.Effect<A>) {
|
||||
return Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Project.Service
|
||||
return yield* fn(svc)
|
||||
}).pipe(Effect.provide(Project.defaultLayer)),
|
||||
)
|
||||
}
|
||||
const it = testEffect(Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
function legacySessionID() {
|
||||
// Global-session migration covers persisted IDs from before prefixed session IDs.
|
||||
@@ -63,91 +58,102 @@ function ensureGlobal() {
|
||||
}
|
||||
|
||||
describe("migrateFromGlobal", () => {
|
||||
test("migrates global sessions on first project creation", async () => {
|
||||
// 1. Start with git init but no commits — creates "global" project row
|
||||
await using tmp = await tmpdir()
|
||||
await $`git init`.cwd(tmp.path).quiet()
|
||||
await $`git config user.name "Test"`.cwd(tmp.path).quiet()
|
||||
await $`git config user.email "test@opencode.test"`.cwd(tmp.path).quiet()
|
||||
await $`git config commit.gpgsign false`.cwd(tmp.path).quiet()
|
||||
const { project: pre } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
expect(pre.id).toBe(ProjectID.global)
|
||||
it.live("migrates global sessions on first project creation", () =>
|
||||
Effect.gen(function* () {
|
||||
// 1. Start with git init but no commits — creates "global" project row
|
||||
const tmp = yield* tmpdirScoped()
|
||||
yield* Effect.promise(() => $`git init`.cwd(tmp).quiet())
|
||||
yield* Effect.promise(() => $`git config user.name "Test"`.cwd(tmp).quiet())
|
||||
yield* Effect.promise(() => $`git config user.email "test@opencode.test"`.cwd(tmp).quiet())
|
||||
yield* Effect.promise(() => $`git config commit.gpgsign false`.cwd(tmp).quiet())
|
||||
const projects = yield* Project.Service
|
||||
const { project: pre } = yield* projects.fromDirectory(tmp)
|
||||
expect(pre.id).toBe(ProjectID.global)
|
||||
|
||||
// 2. Seed a session under "global" with matching directory
|
||||
const id = legacySessionID()
|
||||
seed({ id, dir: tmp.path, project: ProjectID.global })
|
||||
// 2. Seed a session under "global" with matching directory
|
||||
const id = legacySessionID()
|
||||
yield* Effect.sync(() => seed({ id, dir: tmp, project: ProjectID.global }))
|
||||
|
||||
// 3. Make a commit so the project gets a real ID
|
||||
await $`git commit --allow-empty -m "root"`.cwd(tmp.path).quiet()
|
||||
// 3. Make a commit so the project gets a real ID
|
||||
yield* Effect.promise(() => $`git commit --allow-empty -m "root"`.cwd(tmp).quiet())
|
||||
|
||||
const { project: real } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
expect(real.id).not.toBe(ProjectID.global)
|
||||
const { project: real } = yield* projects.fromDirectory(tmp)
|
||||
expect(real.id).not.toBe(ProjectID.global)
|
||||
|
||||
// 4. The session should have been migrated to the real project ID
|
||||
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
|
||||
expect(row).toBeDefined()
|
||||
expect(row!.project_id).toBe(real.id)
|
||||
})
|
||||
// 4. The session should have been migrated to the real project ID
|
||||
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
|
||||
expect(row).toBeDefined()
|
||||
expect(row!.project_id).toBe(real.id)
|
||||
}),
|
||||
)
|
||||
|
||||
test("migrates global sessions even when project row already exists", async () => {
|
||||
// 1. Create a repo with a commit — real project ID created immediately
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
expect(project.id).not.toBe(ProjectID.global)
|
||||
it.live("migrates global sessions even when project row already exists", () =>
|
||||
Effect.gen(function* () {
|
||||
// 1. Create a repo with a commit — real project ID created immediately
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const projects = yield* Project.Service
|
||||
const { project } = yield* projects.fromDirectory(tmp)
|
||||
expect(project.id).not.toBe(ProjectID.global)
|
||||
|
||||
// 2. Ensure "global" project row exists (as it would from a prior no-git session)
|
||||
ensureGlobal()
|
||||
// 2. Ensure "global" project row exists (as it would from a prior no-git session)
|
||||
yield* Effect.sync(() => ensureGlobal())
|
||||
|
||||
// 3. Seed a session under "global" with matching directory.
|
||||
// This simulates a session created before git init that wasn't
|
||||
// present when the real project row was first created.
|
||||
const id = legacySessionID()
|
||||
seed({ id, dir: tmp.path, project: ProjectID.global })
|
||||
// 3. Seed a session under "global" with matching directory.
|
||||
// This simulates a session created before git init that wasn't
|
||||
// present when the real project row was first created.
|
||||
const id = legacySessionID()
|
||||
yield* Effect.sync(() => seed({ id, dir: tmp, project: ProjectID.global }))
|
||||
|
||||
// 4. Call fromDirectory again — project row already exists,
|
||||
// so the current code skips migration entirely. This is the bug.
|
||||
await run((svc) => svc.fromDirectory(tmp.path))
|
||||
// 4. Call fromDirectory again — project row already exists,
|
||||
// so the current code skips migration entirely. This is the bug.
|
||||
yield* projects.fromDirectory(tmp)
|
||||
|
||||
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
|
||||
expect(row).toBeDefined()
|
||||
expect(row!.project_id).toBe(project.id)
|
||||
})
|
||||
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
|
||||
expect(row).toBeDefined()
|
||||
expect(row!.project_id).toBe(project.id)
|
||||
}),
|
||||
)
|
||||
|
||||
test("does not claim sessions with empty directory", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
expect(project.id).not.toBe(ProjectID.global)
|
||||
it.live("does not claim sessions with empty directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const projects = yield* Project.Service
|
||||
const { project } = yield* projects.fromDirectory(tmp)
|
||||
expect(project.id).not.toBe(ProjectID.global)
|
||||
|
||||
ensureGlobal()
|
||||
yield* Effect.sync(() => ensureGlobal())
|
||||
|
||||
// Legacy sessions may lack a directory value.
|
||||
// Without a matching origin directory, they should remain global.
|
||||
const id = legacySessionID()
|
||||
seed({ id, dir: "", project: ProjectID.global })
|
||||
// Legacy sessions may lack a directory value.
|
||||
// Without a matching origin directory, they should remain global.
|
||||
const id = legacySessionID()
|
||||
yield* Effect.sync(() => seed({ id, dir: "", project: ProjectID.global }))
|
||||
|
||||
await run((svc) => svc.fromDirectory(tmp.path))
|
||||
yield* projects.fromDirectory(tmp)
|
||||
|
||||
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
|
||||
expect(row).toBeDefined()
|
||||
expect(row!.project_id).toBe(ProjectID.global)
|
||||
})
|
||||
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
|
||||
expect(row).toBeDefined()
|
||||
expect(row!.project_id).toBe(ProjectID.global)
|
||||
}),
|
||||
)
|
||||
|
||||
test("does not steal sessions from unrelated directories", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
expect(project.id).not.toBe(ProjectID.global)
|
||||
it.live("does not steal sessions from unrelated directories", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const projects = yield* Project.Service
|
||||
const { project } = yield* projects.fromDirectory(tmp)
|
||||
expect(project.id).not.toBe(ProjectID.global)
|
||||
|
||||
ensureGlobal()
|
||||
yield* Effect.sync(() => ensureGlobal())
|
||||
|
||||
// Seed a session under "global" but for a DIFFERENT directory
|
||||
const id = legacySessionID()
|
||||
seed({ id, dir: "/some/other/dir", project: ProjectID.global })
|
||||
// Seed a session under "global" but for a DIFFERENT directory
|
||||
const id = legacySessionID()
|
||||
yield* Effect.sync(() => seed({ id, dir: "/some/other/dir", project: ProjectID.global }))
|
||||
|
||||
await run((svc) => svc.fromDirectory(tmp.path))
|
||||
|
||||
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
|
||||
expect(row).toBeDefined()
|
||||
// Should remain under "global" — not stolen
|
||||
expect(row!.project_id).toBe(ProjectID.global)
|
||||
})
|
||||
yield* projects.fromDirectory(tmp)
|
||||
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
|
||||
expect(row).toBeDefined()
|
||||
// Should remain under "global" — not stolen
|
||||
expect(row!.project_id).toBe(ProjectID.global)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,132 @@
|
||||
import { test, expect, afterEach } from "bun:test"
|
||||
import path from "path"
|
||||
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Provider } from "../../src/provider/provider"
|
||||
import { ProviderID } from "../../src/provider/schema"
|
||||
import { Env } from "../../src/env"
|
||||
import { Effect } from "effect"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { makeRuntime } from "../../src/effect/run-service"
|
||||
|
||||
const envRuntime = makeRuntime(Env.Service, Env.defaultLayer)
|
||||
const set = (k: string, v: string) => envRuntime.runSync((svc) => svc.set(k, v))
|
||||
|
||||
async function list() {
|
||||
return AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
return yield* provider.list()
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const DIGITALOCEAN = ProviderID.make("digitalocean")
|
||||
|
||||
const originalAuthContent = process.env.OPENCODE_AUTH_CONTENT
|
||||
afterEach(() => {
|
||||
if (originalAuthContent === undefined) delete process.env.OPENCODE_AUTH_CONTENT
|
||||
else process.env.OPENCODE_AUTH_CONTENT = originalAuthContent
|
||||
})
|
||||
|
||||
function injectAuth(metadata: Record<string, string> | undefined) {
|
||||
process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({
|
||||
digitalocean: {
|
||||
type: "api",
|
||||
key: "sk_do_test",
|
||||
...(metadata ? { metadata } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
test("digitalocean provider autoloads from DIGITALOCEAN_ACCESS_TOKEN", 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" }))
|
||||
},
|
||||
})
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
set("DIGITALOCEAN_ACCESS_TOKEN", "test-token")
|
||||
const providers = await list()
|
||||
expect(providers[DIGITALOCEAN]).toBeDefined()
|
||||
expect(providers[DIGITALOCEAN].source).toBe("env")
|
||||
const baseModel = Object.values(providers[DIGITALOCEAN].models)[0]
|
||||
expect(baseModel.api.url).toBe("https://inference.do-ai.run/v1")
|
||||
expect(baseModel.api.npm).toBe("@ai-sdk/openai-compatible")
|
||||
const routerEntries = Object.keys(providers[DIGITALOCEAN].models).filter((id) => id.startsWith("router:"))
|
||||
expect(routerEntries.length).toBe(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("digitalocean provider.models surfaces cached routers from auth metadata", 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" }))
|
||||
},
|
||||
})
|
||||
injectAuth({
|
||||
routers: JSON.stringify([
|
||||
{ name: "my-router", uuid: "11f1499a-aaaa-bbbb-cccc-4e013e2ddde4" },
|
||||
{ name: "other-router", uuid: "22f1499a-aaaa-bbbb-cccc-4e013e2ddde4" },
|
||||
]),
|
||||
routers_fetched_at: String(Date.now()),
|
||||
oauth_access: "doo_v1_test",
|
||||
oauth_expires: String(Date.now() + 60 * 60 * 1000),
|
||||
})
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const models = providers[DIGITALOCEAN].models
|
||||
expect(models["router:my-router"]).toBeDefined()
|
||||
expect(models["router:my-router"].api.id).toBe("router:my-router")
|
||||
expect(models["router:my-router"].api.url).toBe("https://inference.do-ai.run/v1")
|
||||
expect(models["router:my-router"].api.npm).toBe("@ai-sdk/openai-compatible")
|
||||
expect(models["router:other-router"]).toBeDefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("digitalocean provider.models skips refresh when oauth bearer is expired", 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" }))
|
||||
},
|
||||
})
|
||||
injectAuth({
|
||||
routers: JSON.stringify([{ name: "stale-router", uuid: "stale" }]),
|
||||
routers_fetched_at: "0",
|
||||
oauth_access: "doo_v1_expired",
|
||||
oauth_expires: "1",
|
||||
})
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const models = providers[DIGITALOCEAN].models
|
||||
expect(models["router:stale-router"]).toBeDefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("digitalocean provider.models passes through base models when no auth metadata", 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" }))
|
||||
},
|
||||
})
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
set("DIGITALOCEAN_ACCESS_TOKEN", "test-token")
|
||||
const providers = await list()
|
||||
const models = providers[DIGITALOCEAN].models
|
||||
expect(Object.keys(models).length).toBeGreaterThan(0)
|
||||
expect(Object.keys(models).filter((id) => id.startsWith("router:")).length).toBe(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -582,64 +582,52 @@ it.instance(
|
||||
},
|
||||
)
|
||||
|
||||
test("model options are merged from existing model", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
anthropic: {
|
||||
models: {
|
||||
"claude-sonnet-4-20250514": {
|
||||
options: {
|
||||
customOption: "custom-value",
|
||||
},
|
||||
},
|
||||
it.instance(
|
||||
"model options are merged from existing model",
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* Provider.Service.use((provider) => provider.list())
|
||||
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
expect(model.options.customOption).toBe("custom-value")
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
anthropic: {
|
||||
options: {
|
||||
apiKey: "test-api-key",
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4-20250514": {
|
||||
options: {
|
||||
customOption: "custom-value",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
const providers = await list()
|
||||
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
expect(model.options.customOption).toBe("custom-value")
|
||||
},
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
test("provider removed when all models filtered out", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
anthropic: {
|
||||
whitelist: ["nonexistent-model"],
|
||||
},
|
||||
it.instance(
|
||||
"provider removed when all models filtered out",
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* Provider.Service.use((provider) => provider.list())
|
||||
expect(providers[ProviderID.anthropic]).toBeUndefined()
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
anthropic: {
|
||||
options: {
|
||||
apiKey: "test-api-key",
|
||||
},
|
||||
}),
|
||||
)
|
||||
whitelist: ["nonexistent-model"],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
const providers = await list()
|
||||
expect(providers[ProviderID.anthropic]).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
test("closest finds model by partial match", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
|
||||
@@ -2,7 +2,6 @@ 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"
|
||||
@@ -398,9 +397,8 @@ it.live("pending question rejects on instance dispose", () =>
|
||||
}).pipe(provideInstance(dir), Effect.forkScoped)
|
||||
|
||||
expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1)
|
||||
yield* Effect.promise(() =>
|
||||
WithInstance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }),
|
||||
)
|
||||
const ctx = yield* Effect.sync(() => Instance.current).pipe(provideInstance(dir))
|
||||
yield* Effect.promise(() => InstanceRuntime.disposeInstance(ctx))
|
||||
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Effect } from "effect"
|
||||
import { eq } from "drizzle-orm"
|
||||
import * as Database from "@/storage/db"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
|
||||
@@ -11,80 +10,68 @@ import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
|
||||
import { MessageID, PartID } from "../../src/session/schema"
|
||||
import { PartTable } from "@/session/session.sql"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { it } from "../lib/effect"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Session.defaultLayer)
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
const withTmp = <A, E, R>(
|
||||
options: Parameters<typeof tmpdir>[0],
|
||||
fn: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>,
|
||||
) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir(options)),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(Effect.flatMap(fn))
|
||||
|
||||
async function seedCorruptStepFinishPart(directory: string) {
|
||||
return WithInstance.provide({
|
||||
directory,
|
||||
fn: () =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const info = yield* session.create({})
|
||||
const message = yield* session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID: info.id,
|
||||
agent: "build",
|
||||
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
const partID = PartID.ascending()
|
||||
yield* session.updatePart({
|
||||
id: partID,
|
||||
sessionID: info.id,
|
||||
messageID: message.id,
|
||||
const seedCorruptStepFinishPart = Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const info = yield* session.create({})
|
||||
const message = yield* session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID: info.id,
|
||||
agent: "build",
|
||||
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
const partID = PartID.ascending()
|
||||
yield* session.updatePart({
|
||||
id: partID,
|
||||
sessionID: info.id,
|
||||
messageID: message.id,
|
||||
type: "step-finish",
|
||||
reason: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
// Schema.Finite still rejects NaN at encode: exact mirror of the corrupt row
|
||||
// that broke the user's session in the OMO/Windows bug.
|
||||
yield* Effect.sync(() =>
|
||||
Database.use((db) =>
|
||||
db
|
||||
.update(PartTable)
|
||||
.set({
|
||||
data: {
|
||||
type: "step-finish",
|
||||
reason: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
// Schema.Finite still rejects NaN at encode — exact mirror of the
|
||||
// corrupt row that broke the user's session in the OMO/Windows bug.
|
||||
Database.use((db) =>
|
||||
db
|
||||
.update(PartTable)
|
||||
.set({
|
||||
data: {
|
||||
type: "step-finish",
|
||||
reason: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
} as never, // drizzle's .set() can't narrow the discriminated union
|
||||
})
|
||||
.where(eq(PartTable.id, partID))
|
||||
.run(),
|
||||
)
|
||||
return info.id
|
||||
}).pipe(Effect.provide(Session.defaultLayer)),
|
||||
),
|
||||
})
|
||||
}
|
||||
tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
} as never, // drizzle's .set() can't narrow the discriminated union
|
||||
})
|
||||
.where(eq(PartTable.id, partID))
|
||||
.run(),
|
||||
),
|
||||
)
|
||||
return info.id
|
||||
})
|
||||
|
||||
describe("schema-rejection wire shape", () => {
|
||||
it.live(
|
||||
it.instance(
|
||||
"Payload schema rejection returns NamedError-shaped JSON, not empty",
|
||||
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const res = yield* Effect.promise(async () =>
|
||||
Server.Default().app.request(SyncPaths.history, {
|
||||
method: "POST",
|
||||
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
|
||||
headers: { "x-opencode-directory": test.directory, "content-type": "application/json" },
|
||||
body: JSON.stringify({ aggregate: -1 }),
|
||||
}),
|
||||
)
|
||||
@@ -99,36 +86,38 @@ describe("schema-rejection wire shape", () => {
|
||||
expect(parsed.data.message).toEqual(expect.any(String))
|
||||
expect(parsed.data.message.length).toBeGreaterThan(0)
|
||||
}),
|
||||
),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
it.live(
|
||||
it.instance(
|
||||
"Query schema rejection returns NamedError-shaped JSON",
|
||||
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
// /find/file?limit=999999 violates the limit constraint check.
|
||||
const url = `/find/file?query=foo&limit=999999&directory=${encodeURIComponent(tmp.path)}`
|
||||
const url = `/find/file?query=foo&limit=999999&directory=${encodeURIComponent(test.directory)}`
|
||||
const res = yield* Effect.promise(async () => Server.Default().app.request(url))
|
||||
const body = yield* Effect.promise(async () => res.text())
|
||||
expect(res.status).toBe(400)
|
||||
const parsed = JSON.parse(body)
|
||||
expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Query" } })
|
||||
}),
|
||||
),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
it.live(
|
||||
it.instance(
|
||||
"rejected request body never echoes back unbounded — message is capped",
|
||||
// Defense against DoS-amplification + secret-echo: Effect's Issue formatter
|
||||
// dumps the rejected `actual` verbatim. A multi-MB invalid array would
|
||||
// become a multi-MB 400 response and log line. Cap kicks in around 1KB.
|
||||
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const huge = "X".repeat(50_000)
|
||||
const res = yield* Effect.promise(async () =>
|
||||
Server.Default().app.request(SyncPaths.history, {
|
||||
method: "POST",
|
||||
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
|
||||
headers: { "x-opencode-directory": test.directory, "content-type": "application/json" },
|
||||
body: JSON.stringify({ aggregate: huge }),
|
||||
}),
|
||||
)
|
||||
@@ -139,15 +128,16 @@ describe("schema-rejection wire shape", () => {
|
||||
const parsed = JSON.parse(body)
|
||||
expect(parsed.data.message).not.toContain(huge)
|
||||
}),
|
||||
),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
it.live(
|
||||
it.instance(
|
||||
"response-encode failure: corrupted stored row returns NamedError-shaped JSON with field path",
|
||||
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const sessionID = yield* Effect.promise(() => seedCorruptStepFinishPart(tmp.path))
|
||||
const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(tmp.path)}`
|
||||
const test = yield* TestInstance
|
||||
const sessionID = yield* seedCorruptStepFinishPart
|
||||
const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(test.directory)}`
|
||||
const res = yield* Effect.promise(async () => Server.Default().app.request(url))
|
||||
const body = yield* Effect.promise(async () => res.text())
|
||||
expect(res.status).toBe(400)
|
||||
@@ -157,6 +147,6 @@ describe("schema-rejection wire shape", () => {
|
||||
// Field path in data.message — what made this PR worth shipping.
|
||||
expect(parsed.data.message).toMatch(/output/)
|
||||
}),
|
||||
),
|
||||
{ config: { formatter: false, lsp: false } },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,29 +1,25 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import { afterEach, describe, expect, mock, spyOn } from "bun:test"
|
||||
import { Context, Effect } from "effect"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
|
||||
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { Session } from "@/session/session"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
const context = Context.empty() as Context.Context<unknown>
|
||||
const it = testEffect(Session.defaultLayer)
|
||||
|
||||
function app() {
|
||||
return Server.Default().app
|
||||
}
|
||||
|
||||
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
|
||||
return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer)))
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
|
||||
@@ -32,111 +28,138 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe("sync HttpApi", () => {
|
||||
test("serves sync routes", async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
|
||||
const info = spyOn(Log.create({ service: "server.sync" }), "info")
|
||||
it.instance(
|
||||
"serves sync routes",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
const tmp = yield* TestInstance
|
||||
const headers = { "x-opencode-directory": tmp.directory, "content-type": "application/json" }
|
||||
const info = spyOn(Log.create({ service: "server.sync" }), "info")
|
||||
const session = yield* Session.Service.use((svc) => svc.create({ title: "sync" }))
|
||||
|
||||
const session = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => runSession(Session.Service.use((svc) => svc.create({ title: "sync" }))),
|
||||
})
|
||||
const started = yield* Effect.promise(() =>
|
||||
Promise.resolve(app().request(SyncPaths.start, { method: "POST", headers })),
|
||||
)
|
||||
expect(started.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => started.json())).toBe(true)
|
||||
|
||||
const started = await app().request(SyncPaths.start, { method: "POST", headers })
|
||||
expect(started.status).toBe(200)
|
||||
expect(await started.json()).toBe(true)
|
||||
const history = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
app().request(SyncPaths.history, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(history.status).toBe(200)
|
||||
const rows = (yield* Effect.promise(() => history.json())) as Array<{
|
||||
id: string
|
||||
aggregate_id: string
|
||||
seq: number
|
||||
type: string
|
||||
data: Record<string, unknown>
|
||||
}>
|
||||
expect(rows.map((row) => row.aggregate_id)).toContain(session.id)
|
||||
|
||||
const history = await app().request(SyncPaths.history, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
expect(history.status).toBe(200)
|
||||
const rows = (await history.json()) as Array<{
|
||||
id: string
|
||||
aggregate_id: string
|
||||
seq: number
|
||||
type: string
|
||||
data: Record<string, unknown>
|
||||
}>
|
||||
expect(rows.map((row) => row.aggregate_id)).toContain(session.id)
|
||||
|
||||
const replayed = await app().request(SyncPaths.replay, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
directory: tmp.path,
|
||||
events: rows
|
||||
.filter((row) => row.aggregate_id === session.id)
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
aggregateID: row.aggregate_id,
|
||||
seq: row.seq,
|
||||
type: row.type,
|
||||
data: row.data,
|
||||
})),
|
||||
const replayed = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
app().request(SyncPaths.replay, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
directory: tmp.directory,
|
||||
events: rows
|
||||
.filter((row) => row.aggregate_id === session.id)
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
aggregateID: row.aggregate_id,
|
||||
seq: row.seq,
|
||||
type: row.type,
|
||||
data: row.data,
|
||||
})),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(replayed.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => replayed.json())).toEqual({ sessionID: session.id })
|
||||
expect(info.mock.calls.some(([message]) => message === "sync replay requested")).toBe(true)
|
||||
expect(info.mock.calls.some(([message]) => message === "sync replay complete")).toBe(true)
|
||||
}),
|
||||
})
|
||||
expect(replayed.status).toBe(200)
|
||||
expect(await replayed.json()).toEqual({ sessionID: session.id })
|
||||
expect(info.mock.calls.some(([message]) => message === "sync replay requested")).toBe(true)
|
||||
expect(info.mock.calls.some(([message]) => message === "sync replay complete")).toBe(true)
|
||||
})
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
test("validates seq values", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
|
||||
const cases = [
|
||||
{
|
||||
path: SyncPaths.history,
|
||||
body: { aggregate: -1 },
|
||||
},
|
||||
{
|
||||
path: SyncPaths.history,
|
||||
body: { aggregate: 1.5 },
|
||||
},
|
||||
{
|
||||
path: SyncPaths.replay,
|
||||
body: {
|
||||
directory: tmp.path,
|
||||
events: [{ id: "event", aggregateID: "session", seq: -1, type: "session.created", data: {} }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: SyncPaths.replay,
|
||||
body: {
|
||||
directory: tmp.path,
|
||||
events: [{ id: "event", aggregateID: "session", seq: 1.5, type: "session.created", data: {} }],
|
||||
},
|
||||
},
|
||||
]
|
||||
it.instance(
|
||||
"validates seq values",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const headers = { "x-opencode-directory": tmp.directory, "content-type": "application/json" }
|
||||
const cases = [
|
||||
{
|
||||
path: SyncPaths.history,
|
||||
body: { aggregate: -1 },
|
||||
},
|
||||
{
|
||||
path: SyncPaths.history,
|
||||
body: { aggregate: 1.5 },
|
||||
},
|
||||
{
|
||||
path: SyncPaths.replay,
|
||||
body: {
|
||||
directory: tmp.directory,
|
||||
events: [{ id: "event", aggregateID: "session", seq: -1, type: "session.created", data: {} }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: SyncPaths.replay,
|
||||
body: {
|
||||
directory: tmp.directory,
|
||||
events: [{ id: "event", aggregateID: "session", seq: 1.5, type: "session.created", data: {} }],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
for (const item of cases) {
|
||||
const response = await app().request(item.path, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(item.body),
|
||||
})
|
||||
expect(response.status).toBe(400)
|
||||
}
|
||||
})
|
||||
|
||||
test.todo("returns structured validation errors", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const response = await ExperimentalHttpApiServer.webHandler().handler(
|
||||
new Request(`http://localhost${SyncPaths.history}`, {
|
||||
method: "POST",
|
||||
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
|
||||
body: JSON.stringify({ aggregate: -1 }),
|
||||
for (const item of cases) {
|
||||
const response = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
app().request(item.path, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(item.body),
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(response.status).toBe(400)
|
||||
}
|
||||
}),
|
||||
context,
|
||||
)
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(response.headers.get("content-type") ?? "").toContain("application/json")
|
||||
const body = (await response.json()) as Record<string, unknown>
|
||||
expect(body.success).toBe(false)
|
||||
expect(Array.isArray(body.error) || Array.isArray(body.errors)).toBe(true)
|
||||
})
|
||||
it.instance.skip(
|
||||
"returns structured validation errors",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const response = yield* Effect.promise(() =>
|
||||
ExperimentalHttpApiServer.webHandler().handler(
|
||||
new Request(`http://localhost${SyncPaths.history}`, {
|
||||
method: "POST",
|
||||
headers: { "x-opencode-directory": tmp.directory, "content-type": "application/json" },
|
||||
body: JSON.stringify({ aggregate: -1 }),
|
||||
}),
|
||||
context,
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(response.headers.get("content-type") ?? "").toContain("application/json")
|
||||
const body = (yield* Effect.promise(() => response.json())) as Record<string, unknown>
|
||||
expect(body.success).toBe(false)
|
||||
expect(Array.isArray(body.error) || Array.isArray(body.errors)).toBe(true)
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -5,11 +5,10 @@
|
||||
// negative. The pre-fix `safe()` clamp only guarded against non-finite. The
|
||||
// strict `NonNegativeInt` schema then made every load of the message list
|
||||
// fail to encode, killing Desktop boot for every user with such a row.
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
|
||||
import { Session } from "@/session/session"
|
||||
@@ -17,81 +16,66 @@ import { MessageID, PartID } from "../../src/session/schema"
|
||||
import * as Database from "@/storage/db"
|
||||
import { PartTable } from "@/session/session.sql"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { it } from "../lib/effect"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
const it = testEffect(Session.defaultLayer)
|
||||
|
||||
function seedNegativeTokenSession(directory: string) {
|
||||
return Effect.promise(async () =>
|
||||
WithInstance.provide({
|
||||
directory,
|
||||
fn: () =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const info = yield* session.create({})
|
||||
const message = yield* session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID: info.id,
|
||||
agent: "build",
|
||||
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
const partID = PartID.ascending()
|
||||
yield* session.updatePart({
|
||||
id: partID,
|
||||
sessionID: info.id,
|
||||
messageID: message.id,
|
||||
type: "step-finish",
|
||||
reason: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
function seedNegativeTokenSession() {
|
||||
return Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const info = yield* session.create({})
|
||||
const message = yield* session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID: info.id,
|
||||
agent: "build",
|
||||
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
const partID = PartID.ascending()
|
||||
yield* session.updatePart({
|
||||
id: partID,
|
||||
sessionID: info.id,
|
||||
messageID: message.id,
|
||||
type: "step-finish",
|
||||
reason: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
|
||||
// Bypass the schema with a direct SQL update to install the
|
||||
// negative `output` value we want to test loading.
|
||||
Database.use((db) =>
|
||||
db
|
||||
.update(PartTable)
|
||||
.set({
|
||||
data: {
|
||||
type: "step-finish",
|
||||
reason: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
} as never,
|
||||
})
|
||||
.where(eq(PartTable.id, partID))
|
||||
.run(),
|
||||
)
|
||||
// Bypass the schema with a direct SQL update to install the
|
||||
// negative `output` value we want to test loading.
|
||||
Database.use((db) =>
|
||||
db
|
||||
.update(PartTable)
|
||||
.set({
|
||||
data: {
|
||||
type: "step-finish",
|
||||
reason: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
} as never,
|
||||
})
|
||||
.where(eq(PartTable.id, partID))
|
||||
.run(),
|
||||
)
|
||||
|
||||
return info.id
|
||||
}).pipe(Effect.provide(Session.defaultLayer)),
|
||||
),
|
||||
}),
|
||||
)
|
||||
return info.id
|
||||
})
|
||||
}
|
||||
|
||||
describe("messages endpoint tolerates legacy negative token counts", () => {
|
||||
it.live(
|
||||
it.instance(
|
||||
"returns 200 even when a step-finish part has tokens.output < 0",
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir({ config: { formatter: false, lsp: false } })),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const sessionID = yield* seedNegativeTokenSession(tmp.path)
|
||||
const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(tmp.path)}`
|
||||
const res = yield* Effect.promise(async () => Server.Default().app.request(url))
|
||||
expect(res.status, "messages endpoint 400'd on legacy negative tokens").not.toBe(400)
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => resetDatabase()))
|
||||
const test = yield* TestInstance
|
||||
const sessionID = yield* seedNegativeTokenSession()
|
||||
const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(test.directory)}`
|
||||
const res = yield* Effect.promise(async () => Server.Default().app.request(url))
|
||||
expect(res.status, "messages endpoint 400'd on legacy negative tokens").not.toBe(400)
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,28 +1,14 @@
|
||||
import { afterEach, describe, expect, mock, test } from "bun:test"
|
||||
import { afterEach, describe, expect, mock } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { Session as SessionNs } from "@/session/session"
|
||||
import type { SessionID } from "../../src/session/schema"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
|
||||
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
|
||||
}
|
||||
|
||||
const svc = {
|
||||
...SessionNs,
|
||||
create(input?: SessionNs.CreateInput) {
|
||||
return run(SessionNs.Service.use((svc) => svc.create(input)))
|
||||
},
|
||||
remove(id: SessionID) {
|
||||
return run(SessionNs.Service.use((svc) => svc.remove(id)))
|
||||
},
|
||||
}
|
||||
const it = testEffect(SessionNs.defaultLayer)
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
@@ -30,21 +16,28 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe("session action routes", () => {
|
||||
test("abort route returns success", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await svc.create({})
|
||||
const app = Server.Default().app
|
||||
it.instance(
|
||||
"abort route returns success",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const session = yield* Effect.acquireRelease(
|
||||
SessionNs.Service.use((svc) => svc.create({})),
|
||||
(created) => SessionNs.Service.use((svc) => svc.remove(created.id)).pipe(Effect.ignore),
|
||||
)
|
||||
|
||||
const res = await app.request(`/session/${session.id}/abort`, { method: "POST" })
|
||||
const res = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
Server.Default().app.request(`/session/${session.id}/abort`, {
|
||||
method: "POST",
|
||||
headers: { "x-opencode-directory": test.directory },
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toBe(true)
|
||||
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
expect(yield* Effect.promise(() => res.json())).toBe(true)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,33 +1,25 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Session as SessionNs } from "@/session/session"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { mkdir } from "fs/promises"
|
||||
import path from "path"
|
||||
import { Database } from "@/storage/db"
|
||||
import { SessionTable } from "@/session/session.sql"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
void Log.init({ print: false })
|
||||
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
const it = testEffect(SessionNs.defaultLayer)
|
||||
|
||||
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
|
||||
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
|
||||
}
|
||||
|
||||
const svc = {
|
||||
...SessionNs,
|
||||
create(input?: SessionNs.CreateInput) {
|
||||
return run(SessionNs.Service.use((svc) => svc.create(input)))
|
||||
},
|
||||
list(input?: SessionNs.ListInput) {
|
||||
return run(SessionNs.Service.use((svc) => svc.list(input)))
|
||||
},
|
||||
}
|
||||
const withSession = (input?: Parameters<SessionNs.Interface["create"]>[0]) =>
|
||||
Effect.acquireRelease(
|
||||
SessionNs.Service.use((session) => session.create(input)),
|
||||
(created) => SessionNs.Service.use((session) => session.remove(created.id).pipe(Effect.ignore)),
|
||||
)
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
|
||||
@@ -35,205 +27,199 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe("session.list", () => {
|
||||
test("does not filter by directory when directory is omitted", async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await mkdir(path.join(tmp.path, "packages", "opencode"), { recursive: true })
|
||||
await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true })
|
||||
it.instance(
|
||||
"does not filter by directory when directory is omitted",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "opencode"), { recursive: true }))
|
||||
yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true }))
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const root = await svc.create({ title: "root" })
|
||||
const root = yield* withSession({ title: "root" })
|
||||
const parent = yield* withSession({ title: "parent" }).pipe(
|
||||
provideInstance(path.join(test.directory, "packages")),
|
||||
)
|
||||
const current = yield* withSession({ title: "current" }).pipe(
|
||||
provideInstance(path.join(test.directory, "packages", "opencode")),
|
||||
)
|
||||
const sibling = yield* withSession({ title: "sibling" }).pipe(
|
||||
provideInstance(path.join(test.directory, "packages", "app")),
|
||||
)
|
||||
|
||||
const parent = await WithInstance.provide({
|
||||
directory: path.join(tmp.path, "packages"),
|
||||
fn: async () => svc.create({ title: "parent" }),
|
||||
})
|
||||
const current = await WithInstance.provide({
|
||||
directory: path.join(tmp.path, "packages", "opencode"),
|
||||
fn: async () => svc.create({ title: "current" }),
|
||||
})
|
||||
const sibling = await WithInstance.provide({
|
||||
directory: path.join(tmp.path, "packages", "app"),
|
||||
fn: async () => svc.create({ title: "sibling" }),
|
||||
})
|
||||
|
||||
const ids = (await svc.list()).map((s) => s.id)
|
||||
const ids = (yield* SessionNs.Service.use((session) => session.list())).map((session) => session.id)
|
||||
expect(ids).toContain(root.id)
|
||||
expect(ids).toContain(parent.id)
|
||||
expect(ids).toContain(current.id)
|
||||
expect(ids).toContain(sibling.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
test("filters by directory when directory is provided", async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await mkdir(path.join(tmp.path, "packages", "opencode"), { recursive: true })
|
||||
await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true })
|
||||
it.instance(
|
||||
"filters by directory when directory is provided",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "opencode"), { recursive: true }))
|
||||
yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true }))
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const root = await svc.create({ title: "root" })
|
||||
const root = yield* withSession({ title: "root" })
|
||||
const parent = yield* withSession({ title: "parent" }).pipe(
|
||||
provideInstance(path.join(test.directory, "packages")),
|
||||
)
|
||||
const current = yield* withSession({ title: "current" }).pipe(
|
||||
provideInstance(path.join(test.directory, "packages", "opencode")),
|
||||
)
|
||||
const sibling = yield* withSession({ title: "sibling" }).pipe(
|
||||
provideInstance(path.join(test.directory, "packages", "app")),
|
||||
)
|
||||
|
||||
const parent = await WithInstance.provide({
|
||||
directory: path.join(tmp.path, "packages"),
|
||||
fn: async () => svc.create({ title: "parent" }),
|
||||
})
|
||||
const current = await WithInstance.provide({
|
||||
directory: path.join(tmp.path, "packages", "opencode"),
|
||||
fn: async () => svc.create({ title: "current" }),
|
||||
})
|
||||
const sibling = await WithInstance.provide({
|
||||
directory: path.join(tmp.path, "packages", "app"),
|
||||
fn: async () => svc.create({ title: "sibling" }),
|
||||
})
|
||||
|
||||
const ids = (await svc.list({ directory: path.join(tmp.path, "packages", "opencode") })).map((s) => s.id)
|
||||
const ids = (yield* SessionNs.Service.use((session) =>
|
||||
session.list({ directory: path.join(test.directory, "packages", "opencode") }),
|
||||
)).map((session) => session.id)
|
||||
expect(ids).not.toContain(root.id)
|
||||
expect(ids).not.toContain(parent.id)
|
||||
expect(ids).toContain(current.id)
|
||||
expect(ids).not.toContain(sibling.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
test("filters by path and ignores directory when path is provided", async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await mkdir(path.join(tmp.path, "packages", "opencode", "src", "deep"), { recursive: true })
|
||||
await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true })
|
||||
it.instance(
|
||||
"filters by path and ignores directory when path is provided",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() =>
|
||||
mkdir(path.join(test.directory, "packages", "opencode", "src", "deep"), { recursive: true }),
|
||||
)
|
||||
yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true }))
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const parent = await WithInstance.provide({
|
||||
directory: path.join(tmp.path, "packages", "opencode"),
|
||||
fn: async () => svc.create({ title: "parent" }),
|
||||
})
|
||||
const current = await WithInstance.provide({
|
||||
directory: path.join(tmp.path, "packages", "opencode", "src"),
|
||||
fn: async () => svc.create({ title: "current" }),
|
||||
})
|
||||
const deeper = await WithInstance.provide({
|
||||
directory: path.join(tmp.path, "packages", "opencode", "src", "deep"),
|
||||
fn: async () => svc.create({ title: "deeper" }),
|
||||
})
|
||||
const sibling = await WithInstance.provide({
|
||||
directory: path.join(tmp.path, "packages", "app"),
|
||||
fn: async () => svc.create({ title: "sibling" }),
|
||||
})
|
||||
const parent = yield* withSession({ title: "parent" }).pipe(
|
||||
provideInstance(path.join(test.directory, "packages", "opencode")),
|
||||
)
|
||||
const current = yield* withSession({ title: "current" }).pipe(
|
||||
provideInstance(path.join(test.directory, "packages", "opencode", "src")),
|
||||
)
|
||||
const deeper = yield* withSession({ title: "deeper" }).pipe(
|
||||
provideInstance(path.join(test.directory, "packages", "opencode", "src", "deep")),
|
||||
)
|
||||
const sibling = yield* withSession({ title: "sibling" }).pipe(
|
||||
provideInstance(path.join(test.directory, "packages", "app")),
|
||||
)
|
||||
|
||||
const pathIDs = (
|
||||
await svc.list({
|
||||
directory: path.join(tmp.path, "packages", "app"),
|
||||
const pathIDs = (yield* SessionNs.Service.use((session) =>
|
||||
session.list({
|
||||
directory: path.join(test.directory, "packages", "app"),
|
||||
path: "packages/opencode/src",
|
||||
})
|
||||
).map((s) => s.id)
|
||||
}),
|
||||
)).map((session) => session.id)
|
||||
expect(pathIDs).not.toContain(parent.id)
|
||||
expect(pathIDs).toContain(current.id)
|
||||
expect(pathIDs).toContain(deeper.id)
|
||||
expect(pathIDs).not.toContain(sibling.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
test("falls back to directory when filtering legacy sessions without path", async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await mkdir(path.join(tmp.path, "packages", "opencode", "src"), { recursive: true })
|
||||
await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true })
|
||||
it.instance(
|
||||
"falls back to directory when filtering legacy sessions without path",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() =>
|
||||
mkdir(path.join(test.directory, "packages", "opencode", "src"), { recursive: true }),
|
||||
)
|
||||
yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true }))
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const current = await WithInstance.provide({
|
||||
directory: path.join(tmp.path, "packages", "opencode", "src"),
|
||||
fn: async () => svc.create({ title: "legacy-current" }),
|
||||
})
|
||||
const sibling = await WithInstance.provide({
|
||||
directory: path.join(tmp.path, "packages", "app"),
|
||||
fn: async () => svc.create({ title: "legacy-sibling" }),
|
||||
})
|
||||
const current = yield* withSession({ title: "legacy-current" }).pipe(
|
||||
provideInstance(path.join(test.directory, "packages", "opencode", "src")),
|
||||
)
|
||||
const sibling = yield* withSession({ title: "legacy-sibling" }).pipe(
|
||||
provideInstance(path.join(test.directory, "packages", "app")),
|
||||
)
|
||||
|
||||
Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, current.id)).run())
|
||||
Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sibling.id)).run())
|
||||
yield* Effect.sync(() =>
|
||||
Database.use((db) =>
|
||||
db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, current.id)).run(),
|
||||
),
|
||||
)
|
||||
yield* Effect.sync(() =>
|
||||
Database.use((db) =>
|
||||
db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sibling.id)).run(),
|
||||
),
|
||||
)
|
||||
|
||||
const pathIDs = (
|
||||
await svc.list({
|
||||
directory: path.join(tmp.path, "packages", "opencode", "src"),
|
||||
const pathIDs = (yield* SessionNs.Service.use((session) =>
|
||||
session.list({
|
||||
directory: path.join(test.directory, "packages", "opencode", "src"),
|
||||
path: "packages/opencode/src",
|
||||
})
|
||||
).map((s) => s.id)
|
||||
}),
|
||||
)).map((session) => session.id)
|
||||
expect(pathIDs).toContain(current.id)
|
||||
expect(pathIDs).not.toContain(sibling.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
test("filters root sessions", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const root = await svc.create({ title: "root-session" })
|
||||
const child = await svc.create({ title: "child-session", parentID: root.id })
|
||||
it.instance(
|
||||
"filters root sessions",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* withSession({ title: "root-session" })
|
||||
const child = yield* withSession({ title: "child-session", parentID: root.id })
|
||||
|
||||
const sessions = await svc.list({ roots: true })
|
||||
const ids = sessions.map((s) => s.id)
|
||||
const sessions = yield* SessionNs.Service.use((session) => session.list({ roots: true }))
|
||||
const ids = sessions.map((session) => session.id)
|
||||
|
||||
expect(ids).toContain(root.id)
|
||||
expect(ids).not.toContain(child.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
test("filters by start time", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await svc.create({ title: "new-session" })
|
||||
const futureStart = Date.now() + 86400000
|
||||
|
||||
const sessions = await svc.list({ start: futureStart })
|
||||
it.instance(
|
||||
"filters by start time",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* withSession({ title: "new-session" })
|
||||
const sessions = yield* SessionNs.Service.use((session) => session.list({ start: Date.now() + 86400000 }))
|
||||
expect(sessions.length).toBe(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
test("filters by search term", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await svc.create({ title: "unique-search-term-abc" })
|
||||
await svc.create({ title: "other-session-xyz" })
|
||||
it.instance(
|
||||
"filters by search term",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* withSession({ title: "unique-search-term-abc" })
|
||||
yield* withSession({ title: "other-session-xyz" })
|
||||
|
||||
const sessions = await svc.list({ search: "unique-search" })
|
||||
const titles = sessions.map((s) => s.title)
|
||||
const sessions = yield* SessionNs.Service.use((session) => session.list({ search: "unique-search" }))
|
||||
const titles = sessions.map((session) => session.title)
|
||||
|
||||
expect(titles).toContain("unique-search-term-abc")
|
||||
expect(titles).not.toContain("other-session-xyz")
|
||||
},
|
||||
})
|
||||
})
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
test("respects limit parameter", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await svc.create({ title: "session-1" })
|
||||
await svc.create({ title: "session-2" })
|
||||
await svc.create({ title: "session-3" })
|
||||
it.instance(
|
||||
"respects limit parameter",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* withSession({ title: "session-1" })
|
||||
yield* withSession({ title: "session-2" })
|
||||
yield* withSession({ title: "session-3" })
|
||||
|
||||
const sessions = await svc.list({ limit: 2 })
|
||||
const sessions = yield* SessionNs.Service.use((session) => session.list({ limit: 2 }))
|
||||
expect(sessions.length).toBe(2)
|
||||
},
|
||||
})
|
||||
})
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -28,7 +28,6 @@ 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 })
|
||||
|
||||
@@ -46,10 +45,6 @@ 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()
|
||||
})
|
||||
@@ -294,11 +289,11 @@ function readCompactionPart(sessionID: SessionID) {
|
||||
|
||||
function llm() {
|
||||
const queue: Array<
|
||||
Stream.Stream<LLMEvent, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLMEvent, unknown>)
|
||||
Stream.Stream<LLM.Event, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown>)
|
||||
> = []
|
||||
|
||||
return {
|
||||
push(stream: Stream.Stream<LLMEvent, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLMEvent, unknown>)) {
|
||||
push(stream: Stream.Stream<LLM.Event, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown>)) {
|
||||
queue.push(stream)
|
||||
},
|
||||
layer: Layer.succeed(
|
||||
@@ -317,22 +312,54 @@ function llm() {
|
||||
function reply(
|
||||
text: string,
|
||||
capture?: (input: LLM.StreamInput) => void,
|
||||
): (input: LLM.StreamInput) => Stream.Stream<LLMEvent, unknown> {
|
||||
): (input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown> {
|
||||
return (input) => {
|
||||
capture?.(input)
|
||||
return Stream.make(
|
||||
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(),
|
||||
}),
|
||||
{ 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -899,12 +926,12 @@ describe("session.compaction.process", () => {
|
||||
)
|
||||
|
||||
itCompaction.instance(
|
||||
"does not persist tail_start_id for serialized recent turns",
|
||||
"persists tail_start_id for retained recent turns",
|
||||
Effect.gen(function* () {
|
||||
const ssn = yield* SessionNs.Service
|
||||
const session = yield* ssn.create({})
|
||||
yield* createUserMessage(session.id, "first")
|
||||
yield* createUserMessage(session.id, "second")
|
||||
const keep = yield* createUserMessage(session.id, "second")
|
||||
yield* createUserMessage(session.id, "third")
|
||||
yield* createSummaryCompaction(session.id)
|
||||
|
||||
@@ -920,18 +947,18 @@ describe("session.compaction.process", () => {
|
||||
|
||||
const part = yield* readCompactionPart(session.id)
|
||||
expect(part?.type).toBe("compaction")
|
||||
expect(part?.tail_start_id).toBeUndefined()
|
||||
expect(part?.tail_start_id).toBe(keep.id)
|
||||
}).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) })),
|
||||
)
|
||||
|
||||
itCompaction.instance(
|
||||
"does not persist tail_start_id when shrinking serialized tail",
|
||||
"shrinks retained tail to fit preserve token budget",
|
||||
Effect.gen(function* () {
|
||||
const ssn = yield* SessionNs.Service
|
||||
const session = yield* ssn.create({})
|
||||
yield* createUserMessage(session.id, "first")
|
||||
yield* createUserMessage(session.id, "x".repeat(2_000))
|
||||
yield* createUserMessage(session.id, "tiny")
|
||||
const keep = yield* createUserMessage(session.id, "tiny")
|
||||
yield* createSummaryCompaction(session.id)
|
||||
|
||||
const msgs = yield* ssn.messages({ sessionID: session.id })
|
||||
@@ -946,7 +973,7 @@ describe("session.compaction.process", () => {
|
||||
|
||||
const part = yield* readCompactionPart(session.id)
|
||||
expect(part?.type).toBe("compaction")
|
||||
expect(part?.tail_start_id).toBeUndefined()
|
||||
expect(part?.tail_start_id).toBe(keep.id)
|
||||
}).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 100 }) })),
|
||||
)
|
||||
|
||||
@@ -978,7 +1005,7 @@ describe("session.compaction.process", () => {
|
||||
)
|
||||
|
||||
itCompaction.instance(
|
||||
"serializes retained tail media as text in the summary input",
|
||||
"falls back to full summary when retained tail media exceeds preserve token budget",
|
||||
() => {
|
||||
const stub = llm()
|
||||
let captured = ""
|
||||
@@ -1051,16 +1078,15 @@ describe("session.compaction.process", () => {
|
||||
|
||||
const part = yield* readCompactionPart(session.id)
|
||||
expect(part?.type).toBe("compaction")
|
||||
expect(part?.tail_start_id).toBeUndefined()
|
||||
expect(part?.tail_start_id).toBe(keep.id)
|
||||
expect(captured).toContain("zzzz")
|
||||
expect(captured).toContain("keep tail")
|
||||
expect(captured).not.toContain("keep tail")
|
||||
|
||||
const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id))
|
||||
expect(filtered.map((msg) => msg.info.id)).toEqual([parent!, expect.any(String)])
|
||||
expect(filtered.map((msg) => msg.info.id).slice(0, 3)).toEqual([parent!, expect.any(String), keep.id])
|
||||
expect(filtered[1]?.info.role).toBe("assistant")
|
||||
expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true)
|
||||
expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id)
|
||||
expect(filtered.map((msg) => msg.info.id)).not.toContain(keep.id)
|
||||
}).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) }))
|
||||
},
|
||||
{ git: true },
|
||||
@@ -1171,7 +1197,7 @@ describe("session.compaction.process", () => {
|
||||
Stream.fromAsyncIterable(
|
||||
{
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield LLMEvent.stepStart({ index: 0 })
|
||||
yield { type: "start" } as LLM.Event
|
||||
throw new APICallError({
|
||||
message: "boom",
|
||||
url: "https://example.com/v1/chat/completions",
|
||||
@@ -1263,16 +1289,49 @@ describe("session.compaction.process", () => {
|
||||
const stub = llm()
|
||||
stub.push(
|
||||
Stream.make(
|
||||
LLMEvent.toolCall({ id: "call-1", name: "_noop", input: {} }),
|
||||
LLMEvent.stepFinish({
|
||||
index: 0,
|
||||
reason: "tool-calls",
|
||||
usage: basicUsage(),
|
||||
}),
|
||||
LLMEvent.requestFinish({
|
||||
reason: "tool-calls",
|
||||
usage: basicUsage(),
|
||||
}),
|
||||
{ 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,
|
||||
),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
@@ -1294,13 +1353,13 @@ describe("session.compaction.process", () => {
|
||||
)
|
||||
|
||||
itCompaction.instance(
|
||||
"summarizes the head while serializing recent tail into summary input",
|
||||
"summarizes only the head while keeping recent tail out of summary input",
|
||||
() => {
|
||||
const stub = llm()
|
||||
let captured: LLM.StreamInput["messages"] = []
|
||||
let captured = ""
|
||||
stub.push(
|
||||
reply("summary", (input) => {
|
||||
captured = input.messages
|
||||
captured = JSON.stringify(input.messages)
|
||||
}),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
@@ -1321,15 +1380,10 @@ describe("session.compaction.process", () => {
|
||||
auto: false,
|
||||
})
|
||||
|
||||
const head = JSON.stringify(captured.slice(0, -1))
|
||||
const prompt = JSON.stringify(captured.at(-1))
|
||||
expect(head).toContain("older context")
|
||||
expect(head).not.toContain("keep this turn")
|
||||
expect(head).not.toContain("and this one too")
|
||||
expect(prompt).toContain("keep this turn")
|
||||
expect(prompt).toContain("and this one too")
|
||||
expect(prompt).toContain("recent-conversation-tail")
|
||||
expect(prompt).not.toContain("What did we do so far?")
|
||||
expect(captured).toContain("older context")
|
||||
expect(captured).not.toContain("keep this turn")
|
||||
expect(captured).not.toContain("and this one too")
|
||||
expect(captured).not.toContain("What did we do so far?")
|
||||
}).pipe(withCompaction({ llm: stub.layer }))
|
||||
},
|
||||
{ git: true },
|
||||
@@ -1377,7 +1431,7 @@ describe("session.compaction.process", () => {
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
itCompaction.instance("does not replay recent pre-compaction turns across repeated compactions", () => {
|
||||
itCompaction.instance("keeps recent pre-compaction turns across repeated compactions", () => {
|
||||
const stub = llm()
|
||||
stub.push(reply("summary one"))
|
||||
stub.push(reply("summary two"))
|
||||
@@ -1408,8 +1462,8 @@ describe("session.compaction.process", () => {
|
||||
|
||||
expect(ids).not.toContain(u1.id)
|
||||
expect(ids).not.toContain(u2.id)
|
||||
expect(ids).not.toContain(u3.id)
|
||||
expect(ids).not.toContain(u4.id)
|
||||
expect(ids).toContain(u3.id)
|
||||
expect(ids).toContain(u4.id)
|
||||
expect(filtered.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(true)
|
||||
expect(
|
||||
filtered.some((msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction")),
|
||||
@@ -1418,7 +1472,7 @@ describe("session.compaction.process", () => {
|
||||
})
|
||||
|
||||
itCompaction.instance(
|
||||
"ignores previous summaries when sizing the serialized tail",
|
||||
"ignores previous summaries when sizing the retained tail",
|
||||
Effect.gen(function* () {
|
||||
const ssn = yield* SessionNs.Service
|
||||
const test = yield* TestInstance
|
||||
@@ -1457,7 +1511,7 @@ describe("session.compaction.process", () => {
|
||||
|
||||
const part = yield* readCompactionPart(session.id)
|
||||
expect(part?.type).toBe("compaction")
|
||||
expect(part?.tail_start_id).toBeUndefined()
|
||||
expect(part?.tail_start_id).toBe(keep.id)
|
||||
}).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 500 }) })),
|
||||
)
|
||||
})
|
||||
@@ -1483,7 +1537,20 @@ describe("SessionNs.getUsage", () => {
|
||||
const model = createModel({ context: 100_000, output: 32_000 })
|
||||
const result = SessionNs.getUsage({
|
||||
model,
|
||||
usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500 }),
|
||||
usage: {
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500,
|
||||
totalTokens: 1500,
|
||||
inputTokenDetails: {
|
||||
noCacheTokens: undefined,
|
||||
cacheReadTokens: undefined,
|
||||
cacheWriteTokens: undefined,
|
||||
},
|
||||
outputTokenDetails: {
|
||||
textTokens: undefined,
|
||||
reasoningTokens: undefined,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.tokens.input).toBe(1000)
|
||||
@@ -1497,7 +1564,20 @@ describe("SessionNs.getUsage", () => {
|
||||
const model = createModel({ context: 100_000, output: 32_000 })
|
||||
const result = SessionNs.getUsage({
|
||||
model,
|
||||
usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cacheReadInputTokens: 200 }),
|
||||
usage: {
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500,
|
||||
totalTokens: 1500,
|
||||
inputTokenDetails: {
|
||||
noCacheTokens: 800,
|
||||
cacheReadTokens: 200,
|
||||
cacheWriteTokens: undefined,
|
||||
},
|
||||
outputTokenDetails: {
|
||||
textTokens: undefined,
|
||||
reasoningTokens: undefined,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.tokens.input).toBe(800)
|
||||
@@ -1508,7 +1588,20 @@ describe("SessionNs.getUsage", () => {
|
||||
const model = createModel({ context: 100_000, output: 32_000 })
|
||||
const result = SessionNs.getUsage({
|
||||
model,
|
||||
usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500 }),
|
||||
usage: {
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500,
|
||||
totalTokens: 1500,
|
||||
inputTokenDetails: {
|
||||
noCacheTokens: undefined,
|
||||
cacheReadTokens: undefined,
|
||||
cacheWriteTokens: undefined,
|
||||
},
|
||||
outputTokenDetails: {
|
||||
textTokens: undefined,
|
||||
reasoningTokens: undefined,
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
anthropic: {
|
||||
cacheCreationInputTokens: 300,
|
||||
@@ -1524,7 +1617,20 @@ describe("SessionNs.getUsage", () => {
|
||||
// AI SDK v6 normalizes inputTokens to include cached tokens for all providers
|
||||
const result = SessionNs.getUsage({
|
||||
model,
|
||||
usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cacheReadInputTokens: 200 }),
|
||||
usage: {
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500,
|
||||
totalTokens: 1500,
|
||||
inputTokenDetails: {
|
||||
noCacheTokens: 800,
|
||||
cacheReadTokens: 200,
|
||||
cacheWriteTokens: undefined,
|
||||
},
|
||||
outputTokenDetails: {
|
||||
textTokens: undefined,
|
||||
reasoningTokens: undefined,
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
anthropic: {},
|
||||
},
|
||||
@@ -1538,7 +1644,20 @@ describe("SessionNs.getUsage", () => {
|
||||
const model = createModel({ context: 100_000, output: 32_000 })
|
||||
const result = SessionNs.getUsage({
|
||||
model,
|
||||
usage: usage({ inputTokens: 1000, outputTokens: 500, reasoningTokens: 100, totalTokens: 1500 }),
|
||||
usage: {
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500,
|
||||
totalTokens: 1500,
|
||||
inputTokenDetails: {
|
||||
noCacheTokens: undefined,
|
||||
cacheReadTokens: undefined,
|
||||
cacheWriteTokens: undefined,
|
||||
},
|
||||
outputTokenDetails: {
|
||||
textTokens: 400,
|
||||
reasoningTokens: 100,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.tokens.input).toBe(1000)
|
||||
@@ -1559,7 +1678,20 @@ describe("SessionNs.getUsage", () => {
|
||||
})
|
||||
const result = SessionNs.getUsage({
|
||||
model,
|
||||
usage: usage({ inputTokens: 0, outputTokens: 1_000_000, reasoningTokens: 250_000, totalTokens: 1_000_000 }),
|
||||
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,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.tokens.output).toBe(750_000)
|
||||
@@ -1571,7 +1703,20 @@ describe("SessionNs.getUsage", () => {
|
||||
const model = createModel({ context: 100_000, output: 32_000 })
|
||||
const result = SessionNs.getUsage({
|
||||
model,
|
||||
usage: usage({ inputTokens: 0, outputTokens: 0, totalTokens: 0 }),
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
inputTokenDetails: {
|
||||
noCacheTokens: undefined,
|
||||
cacheReadTokens: undefined,
|
||||
cacheWriteTokens: undefined,
|
||||
},
|
||||
outputTokenDetails: {
|
||||
textTokens: undefined,
|
||||
reasoningTokens: undefined,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.tokens.input).toBe(0)
|
||||
@@ -1594,7 +1739,20 @@ describe("SessionNs.getUsage", () => {
|
||||
})
|
||||
const result = SessionNs.getUsage({
|
||||
model,
|
||||
usage: usage({ inputTokens: 1_000_000, outputTokens: 100_000, totalTokens: 1_100_000 }),
|
||||
usage: {
|
||||
inputTokens: 1_000_000,
|
||||
outputTokens: 100_000,
|
||||
totalTokens: 1_100_000,
|
||||
inputTokenDetails: {
|
||||
noCacheTokens: undefined,
|
||||
cacheReadTokens: undefined,
|
||||
cacheWriteTokens: undefined,
|
||||
},
|
||||
outputTokenDetails: {
|
||||
textTokens: undefined,
|
||||
reasoningTokens: undefined,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.cost).toBe(3 + 1.5)
|
||||
@@ -1605,16 +1763,24 @@ 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 item = usage({
|
||||
const usage = {
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500,
|
||||
totalTokens: 1500,
|
||||
cacheReadInputTokens: 200,
|
||||
})
|
||||
inputTokenDetails: {
|
||||
noCacheTokens: 800,
|
||||
cacheReadTokens: 200,
|
||||
cacheWriteTokens: undefined,
|
||||
},
|
||||
outputTokenDetails: {
|
||||
textTokens: undefined,
|
||||
reasoningTokens: undefined,
|
||||
},
|
||||
}
|
||||
if (npm === "@ai-sdk/amazon-bedrock") {
|
||||
const result = SessionNs.getUsage({
|
||||
model,
|
||||
usage: item,
|
||||
usage,
|
||||
metadata: {
|
||||
bedrock: {
|
||||
usage: {
|
||||
@@ -1635,7 +1801,7 @@ describe("SessionNs.getUsage", () => {
|
||||
|
||||
const result = SessionNs.getUsage({
|
||||
model,
|
||||
usage: item,
|
||||
usage,
|
||||
metadata: {
|
||||
anthropic: {
|
||||
cacheCreationInputTokens: 300,
|
||||
@@ -1656,7 +1822,20 @@ describe("SessionNs.getUsage", () => {
|
||||
const model = createModel({ context: 100_000, output: 32_000, npm: "@ai-sdk/google-vertex/anthropic" })
|
||||
const result = SessionNs.getUsage({
|
||||
model,
|
||||
usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cacheReadInputTokens: 200 }),
|
||||
usage: {
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500,
|
||||
totalTokens: 1500,
|
||||
inputTokenDetails: {
|
||||
noCacheTokens: 800,
|
||||
cacheReadTokens: 200,
|
||||
cacheWriteTokens: undefined,
|
||||
},
|
||||
outputTokenDetails: {
|
||||
textTokens: undefined,
|
||||
reasoningTokens: undefined,
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
vertex: {
|
||||
cacheCreationInputTokens: 300,
|
||||
|
||||
@@ -581,18 +581,6 @@ 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",
|
||||
@@ -706,18 +694,6 @@ 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",
|
||||
|
||||
@@ -650,7 +650,7 @@ describe("MessageV2.filterCompacted", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("ignores original tail when compaction stores tail_start_id", () =>
|
||||
it.instance("retains original tail when compaction stores tail_start_id", () =>
|
||||
withSession(({ session, sessionID }) =>
|
||||
Effect.gen(function* () {
|
||||
const u1 = yield* addUser(sessionID, "first")
|
||||
@@ -696,12 +696,12 @@ describe("MessageV2.filterCompacted", () => {
|
||||
|
||||
const result = MessageV2.filterCompacted(MessageV2.stream(sessionID))
|
||||
|
||||
expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a3])
|
||||
expect(result.map((item) => item.info.id)).toEqual([c1, s1, u2, a2, u3, a3])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("fork keeps legacy tail_start_id without replaying the tail", () =>
|
||||
it.instance("fork remaps compaction tail_start_id for filterCompacted", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionNs.Service
|
||||
const created = yield* session.create({})
|
||||
@@ -748,7 +748,7 @@ describe("MessageV2.filterCompacted", () => {
|
||||
})
|
||||
|
||||
const parentFiltered = MessageV2.filterCompacted(MessageV2.stream(created.id))
|
||||
expect(parentFiltered.map((item) => item.info.id)).toEqual([c1, s1, u3, a3])
|
||||
expect(parentFiltered.map((item) => item.info.id)).toEqual([c1, s1, u2, a2, u3, a3])
|
||||
|
||||
const forked = yield* session.fork({ sessionID: created.id })
|
||||
const childFiltered = MessageV2.filterCompacted(MessageV2.stream(forked.id))
|
||||
@@ -758,14 +758,14 @@ describe("MessageV2.filterCompacted", () => {
|
||||
expect(tailPart?.type).toBe("compaction")
|
||||
if (!tailPart || tailPart.type !== "compaction") throw new Error("Expected forked compaction part")
|
||||
expect(tailPart.tail_start_id).toBeDefined()
|
||||
expect(childFiltered.some((m) => m.info.id === tailPart.tail_start_id)).toBe(false)
|
||||
expect(childFiltered.some((m) => m.info.id === tailPart.tail_start_id)).toBe(true)
|
||||
|
||||
yield* session.remove(forked.id)
|
||||
yield* session.remove(created.id)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("does not replay an assistant tail when compaction starts inside a turn", () =>
|
||||
it.instance("retains an assistant tail when compaction starts inside a turn", () =>
|
||||
withSession(({ session, sessionID }) =>
|
||||
Effect.gen(function* () {
|
||||
const u1 = yield* addUser(sessionID, "first")
|
||||
@@ -819,7 +819,7 @@ describe("MessageV2.filterCompacted", () => {
|
||||
|
||||
const result = MessageV2.filterCompacted(MessageV2.stream(sessionID))
|
||||
|
||||
expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a4])
|
||||
expect(result.map((item) => item.info.id)).toEqual([c1, s1, a3, u3, a4])
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -891,7 +891,7 @@ describe("MessageV2.filterCompacted", () => {
|
||||
|
||||
const result = MessageV2.filterCompacted(MessageV2.stream(sessionID))
|
||||
|
||||
expect(result.map((item) => item.info.id)).toEqual([c2, s2, u4, a4])
|
||||
expect(result.map((item) => item.info.id)).toEqual([c2, s2, u3, a3, u4, a4])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,186 +1,174 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Exit, Layer } from "effect"
|
||||
import { Session as SessionNs } from "@/session/session"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const projectRoot = path.join(__dirname, "../..")
|
||||
void Log.init({ print: false })
|
||||
|
||||
function create(input?: SessionNs.CreateInput) {
|
||||
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create(input)))
|
||||
}
|
||||
const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
function get(id: SessionID) {
|
||||
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.get(id)))
|
||||
}
|
||||
const awaitDeferred = <T>(deferred: Deferred.Deferred<T>, message: string) =>
|
||||
Effect.race(
|
||||
Deferred.await(deferred),
|
||||
Effect.sleep("2 seconds").pipe(Effect.flatMap(() => Effect.fail(new Error(message)))),
|
||||
)
|
||||
|
||||
function remove(id: SessionID) {
|
||||
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.remove(id)))
|
||||
}
|
||||
const remove = (id: SessionID) => SessionNs.Service.use((svc) => svc.remove(id))
|
||||
|
||||
function updateMessage<T extends MessageV2.Info>(msg: T) {
|
||||
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updateMessage(msg)))
|
||||
}
|
||||
|
||||
function updatePart<T extends MessageV2.Part>(part: T) {
|
||||
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updatePart(part)))
|
||||
const subscribeGlobal = (type: string, callback: (event: NonNullable<GlobalEvent["payload"]>) => void) => {
|
||||
const listener = (event: GlobalEvent) => {
|
||||
if (event.payload?.type === type) callback(event.payload)
|
||||
}
|
||||
GlobalBus.on("event", listener)
|
||||
return () => GlobalBus.off("event", listener)
|
||||
}
|
||||
|
||||
describe("session.created event", () => {
|
||||
test("should emit session.created event when session is created", async () => {
|
||||
await WithInstance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
let eventReceived = false
|
||||
let receivedInfo: SessionNs.Info | undefined
|
||||
it.instance("should emit session.created event when session is created", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionNs.Service
|
||||
const received = yield* Deferred.make<SessionNs.Info>()
|
||||
|
||||
const unsub = Bus.subscribe(SessionNs.Event.Created, (event) => {
|
||||
eventReceived = true
|
||||
receivedInfo = event.properties.info as SessionNs.Info
|
||||
})
|
||||
const unsub = subscribeGlobal(SessionNs.Event.Created.type, (event) => {
|
||||
Deferred.doneUnsafe(received, Effect.succeed(event.properties.info as SessionNs.Info))
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(unsub))
|
||||
|
||||
const info = await create({})
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
unsub()
|
||||
const info = yield* session.create({})
|
||||
const receivedInfo = yield* awaitDeferred(received, "timed out waiting for session.created")
|
||||
|
||||
expect(eventReceived).toBe(true)
|
||||
expect(receivedInfo).toBeDefined()
|
||||
expect(receivedInfo?.id).toBe(info.id)
|
||||
expect(receivedInfo?.projectID).toBe(info.projectID)
|
||||
expect(receivedInfo?.directory).toBe(info.directory)
|
||||
expect(receivedInfo?.path).toBe(info.path)
|
||||
expect(receivedInfo?.title).toBe(info.title)
|
||||
expect(receivedInfo.id).toBe(info.id)
|
||||
expect(receivedInfo.projectID).toBe(info.projectID)
|
||||
expect(receivedInfo.directory).toBe(info.directory)
|
||||
expect(receivedInfo.path).toBe(info.path)
|
||||
expect(receivedInfo.title).toBe(info.title)
|
||||
|
||||
await remove(info.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
yield* session.remove(info.id)
|
||||
}),
|
||||
)
|
||||
|
||||
test("session.created event should be emitted before session.updated", async () => {
|
||||
await WithInstance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const events: string[] = []
|
||||
it.instance("session.created event should be emitted before session.updated", () =>
|
||||
Effect.gen(function* () {
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return
|
||||
|
||||
const unsubCreated = Bus.subscribe(SessionNs.Event.Created, () => {
|
||||
events.push("created")
|
||||
})
|
||||
const session = yield* SessionNs.Service
|
||||
const events: string[] = []
|
||||
const received = yield* Deferred.make<string[]>()
|
||||
const push = (event: string) => {
|
||||
events.push(event)
|
||||
if (events.includes("created") && events.includes("updated")) {
|
||||
Deferred.doneUnsafe(received, Effect.succeed(events))
|
||||
}
|
||||
}
|
||||
|
||||
const unsubUpdated = Bus.subscribe(SessionNs.Event.Updated, () => {
|
||||
events.push("updated")
|
||||
})
|
||||
const unsubCreated = subscribeGlobal(SessionNs.Event.Created.type, () => {
|
||||
push("created")
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(unsubCreated))
|
||||
|
||||
const info = await create({})
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
unsubCreated()
|
||||
unsubUpdated()
|
||||
const unsubUpdated = subscribeGlobal(SessionNs.Event.Updated.type, () => {
|
||||
push("updated")
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(unsubUpdated))
|
||||
|
||||
expect(events).toContain("created")
|
||||
expect(events).toContain("updated")
|
||||
expect(events.indexOf("created")).toBeLessThan(events.indexOf("updated"))
|
||||
const info = yield* session.create({})
|
||||
const receivedEvents = yield* awaitDeferred(received, "timed out waiting for session created/updated events")
|
||||
|
||||
await remove(info.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
expect(receivedEvents).toContain("created")
|
||||
expect(receivedEvents).toContain("updated")
|
||||
expect(receivedEvents.indexOf("created")).toBeLessThan(receivedEvents.indexOf("updated"))
|
||||
|
||||
yield* session.remove(info.id)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("step-finish token propagation via Bus event", () => {
|
||||
test(
|
||||
it.instance(
|
||||
"non-zero tokens propagate through PartUpdated event",
|
||||
async () => {
|
||||
await WithInstance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const info = await create({})
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionNs.Service
|
||||
const info = yield* session.create({})
|
||||
|
||||
const messageID = MessageID.ascending()
|
||||
await updateMessage({
|
||||
id: messageID,
|
||||
sessionID: info.id,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: "user",
|
||||
model: { providerID: "test", modelID: "test" },
|
||||
tools: {},
|
||||
mode: "",
|
||||
} as unknown as MessageV2.Info)
|
||||
const messageID = MessageID.ascending()
|
||||
yield* session.updateMessage({
|
||||
id: messageID,
|
||||
sessionID: info.id,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: "user",
|
||||
model: { providerID: "test", modelID: "test" },
|
||||
tools: {},
|
||||
mode: "",
|
||||
} as unknown as MessageV2.Info)
|
||||
|
||||
// Bus subscribers receive readonly Schema.Type payloads; `MessageV2.Part`
|
||||
// is the mutable domain type. Cast bridges the two — safe because the
|
||||
// test only reads the value afterwards.
|
||||
let received: MessageV2.Part | undefined
|
||||
const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, (event) => {
|
||||
received = event.properties.part as MessageV2.Part
|
||||
})
|
||||
// Bus subscribers receive readonly Schema.Type payloads; `MessageV2.Part`
|
||||
// is the mutable domain type. Cast bridges the two — safe because the
|
||||
// test only reads the value afterwards.
|
||||
const received = yield* Deferred.make<MessageV2.Part>()
|
||||
const unsub = subscribeGlobal(MessageV2.Event.PartUpdated.type, (event) => {
|
||||
Deferred.doneUnsafe(received, Effect.succeed(event.properties.part as MessageV2.Part))
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(unsub))
|
||||
|
||||
const tokens = {
|
||||
total: 1500,
|
||||
input: 500,
|
||||
output: 800,
|
||||
reasoning: 200,
|
||||
cache: { read: 100, write: 50 },
|
||||
}
|
||||
const tokens = {
|
||||
total: 1500,
|
||||
input: 500,
|
||||
output: 800,
|
||||
reasoning: 200,
|
||||
cache: { read: 100, write: 50 },
|
||||
}
|
||||
|
||||
const partInput = {
|
||||
id: PartID.ascending(),
|
||||
messageID,
|
||||
sessionID: info.id,
|
||||
type: "step-finish" as const,
|
||||
reason: "stop",
|
||||
cost: 0.005,
|
||||
tokens,
|
||||
}
|
||||
const partInput = {
|
||||
id: PartID.ascending(),
|
||||
messageID,
|
||||
sessionID: info.id,
|
||||
type: "step-finish" as const,
|
||||
reason: "stop",
|
||||
cost: 0.005,
|
||||
tokens,
|
||||
}
|
||||
|
||||
await updatePart(partInput)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
yield* session.updatePart(partInput)
|
||||
const receivedPart = yield* awaitDeferred(received, "timed out waiting for message.part.updated")
|
||||
|
||||
expect(received).toBeDefined()
|
||||
expect(received!.type).toBe("step-finish")
|
||||
const finish = received as MessageV2.StepFinishPart
|
||||
expect(finish.tokens.input).toBe(500)
|
||||
expect(finish.tokens.output).toBe(800)
|
||||
expect(finish.tokens.reasoning).toBe(200)
|
||||
expect(finish.tokens.total).toBe(1500)
|
||||
expect(finish.tokens.cache.read).toBe(100)
|
||||
expect(finish.tokens.cache.write).toBe(50)
|
||||
expect(finish.cost).toBe(0.005)
|
||||
expect(received).not.toBe(partInput)
|
||||
expect(receivedPart.type).toBe("step-finish")
|
||||
const finish = receivedPart as MessageV2.StepFinishPart
|
||||
expect(finish.tokens.input).toBe(500)
|
||||
expect(finish.tokens.output).toBe(800)
|
||||
expect(finish.tokens.reasoning).toBe(200)
|
||||
expect(finish.tokens.total).toBe(1500)
|
||||
expect(finish.tokens.cache.read).toBe(100)
|
||||
expect(finish.tokens.cache.write).toBe(50)
|
||||
expect(finish.cost).toBe(0.005)
|
||||
expect(receivedPart).not.toBe(partInput)
|
||||
|
||||
unsub()
|
||||
await remove(info.id)
|
||||
},
|
||||
})
|
||||
},
|
||||
yield* session.remove(info.id)
|
||||
}),
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
})
|
||||
|
||||
describe("Session", () => {
|
||||
test("remove works without an instance", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
it.live("remove works without an instance", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionNs.Service
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const info = yield* provideInstance(dir)(session.create({ title: "remove-without-instance" }))
|
||||
|
||||
const info = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => create({ title: "remove-without-instance" }),
|
||||
})
|
||||
const removeExit = yield* remove(info.id).pipe(Effect.exit)
|
||||
expect(Exit.isSuccess(removeExit)).toBe(true)
|
||||
|
||||
await expect(async () => {
|
||||
await remove(info.id)
|
||||
}).not.toThrow()
|
||||
|
||||
let missing = false
|
||||
await get(info.id).catch(() => {
|
||||
missing = true
|
||||
})
|
||||
|
||||
expect(missing).toBe(true)
|
||||
})
|
||||
const getExit = yield* session.get(info.id).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(getExit)).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,250 +1,219 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionPrompt } from "../../src/session/prompt"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const projectRoot = path.join(__dirname, "../..")
|
||||
void Log.init({ print: false })
|
||||
|
||||
// Skip tests if no API key is available
|
||||
const hasApiKey = !!process.env.ANTHROPIC_API_KEY
|
||||
|
||||
// Helper to run test within Instance context
|
||||
async function withInstance<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return WithInstance.provide({
|
||||
directory: projectRoot,
|
||||
fn,
|
||||
})
|
||||
}
|
||||
|
||||
function run<A, E>(fx: Effect.Effect<A, E, SessionPrompt.Service | Session.Service>) {
|
||||
return Effect.runPromise(
|
||||
fx.pipe(Effect.scoped, Effect.provide(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))),
|
||||
)
|
||||
}
|
||||
const it = testEffect(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))
|
||||
const live = hasApiKey ? it.instance : it.instance.skip
|
||||
|
||||
describe("StructuredOutput Integration", () => {
|
||||
test.skipIf(!hasApiKey)(
|
||||
live(
|
||||
"produces structured output with simple schema",
|
||||
async () => {
|
||||
await withInstance(() =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({ title: "Structured Output Test" })
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({ title: "Structured Output Test" })
|
||||
|
||||
const result = yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "What is 2 + 2? Provide a simple answer.",
|
||||
},
|
||||
],
|
||||
format: {
|
||||
type: "json_schema",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
answer: { type: "number", description: "The numerical answer" },
|
||||
explanation: { type: "string", description: "Brief explanation" },
|
||||
},
|
||||
required: ["answer"],
|
||||
},
|
||||
retryCount: 0,
|
||||
const result = yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "What is 2 + 2? Provide a simple answer.",
|
||||
},
|
||||
],
|
||||
format: {
|
||||
type: "json_schema",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
answer: { type: "number", description: "The numerical answer" },
|
||||
explanation: { type: "string", description: "Brief explanation" },
|
||||
},
|
||||
})
|
||||
required: ["answer"],
|
||||
},
|
||||
retryCount: 0,
|
||||
},
|
||||
})
|
||||
|
||||
// Verify structured output was captured (only on assistant messages)
|
||||
expect(result.info.role).toBe("assistant")
|
||||
if (result.info.role === "assistant") {
|
||||
expect(result.info.structured).toBeDefined()
|
||||
expect(typeof result.info.structured).toBe("object")
|
||||
// Verify structured output was captured (only on assistant messages)
|
||||
expect(result.info.role).toBe("assistant")
|
||||
if (result.info.role === "assistant") {
|
||||
expect(result.info.structured).toBeDefined()
|
||||
expect(typeof result.info.structured).toBe("object")
|
||||
|
||||
const output = result.info.structured as any
|
||||
expect(output.answer).toBe(4)
|
||||
const output = result.info.structured as any
|
||||
expect(output.answer).toBe(4)
|
||||
|
||||
// Verify no error was set
|
||||
expect(result.info.error).toBeUndefined()
|
||||
}
|
||||
// Verify no error was set
|
||||
expect(result.info.error).toBeUndefined()
|
||||
}
|
||||
|
||||
// Clean up
|
||||
// Note: Not removing session to avoid race with background SessionSummary.summarize
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
// Clean up
|
||||
// Note: Not removing session to avoid race with background SessionSummary.summarize
|
||||
}),
|
||||
{ git: true },
|
||||
60000,
|
||||
)
|
||||
|
||||
test.skipIf(!hasApiKey)(
|
||||
live(
|
||||
"produces structured output with nested objects",
|
||||
async () => {
|
||||
await withInstance(() =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({ title: "Nested Schema Test" })
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({ title: "Nested Schema Test" })
|
||||
|
||||
const result = yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Tell me about Anthropic company in a structured format.",
|
||||
},
|
||||
],
|
||||
format: {
|
||||
type: "json_schema",
|
||||
schema: {
|
||||
const result = yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Tell me about Anthropic company in a structured format.",
|
||||
},
|
||||
],
|
||||
format: {
|
||||
type: "json_schema",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
company: {
|
||||
type: "object",
|
||||
properties: {
|
||||
company: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
founded: { type: "number" },
|
||||
},
|
||||
required: ["name", "founded"],
|
||||
},
|
||||
products: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
name: { type: "string" },
|
||||
founded: { type: "number" },
|
||||
},
|
||||
required: ["company"],
|
||||
required: ["name", "founded"],
|
||||
},
|
||||
products: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
retryCount: 0,
|
||||
},
|
||||
})
|
||||
required: ["company"],
|
||||
},
|
||||
retryCount: 0,
|
||||
},
|
||||
})
|
||||
|
||||
// Verify structured output was captured (only on assistant messages)
|
||||
expect(result.info.role).toBe("assistant")
|
||||
if (result.info.role === "assistant") {
|
||||
expect(result.info.structured).toBeDefined()
|
||||
const output = result.info.structured as any
|
||||
// Verify structured output was captured (only on assistant messages)
|
||||
expect(result.info.role).toBe("assistant")
|
||||
if (result.info.role === "assistant") {
|
||||
expect(result.info.structured).toBeDefined()
|
||||
const output = result.info.structured as any
|
||||
|
||||
expect(output.company).toBeDefined()
|
||||
expect(output.company.name).toBe("Anthropic")
|
||||
expect(typeof output.company.founded).toBe("number")
|
||||
expect(output.company).toBeDefined()
|
||||
expect(output.company.name).toBe("Anthropic")
|
||||
expect(typeof output.company.founded).toBe("number")
|
||||
|
||||
if (output.products) {
|
||||
expect(Array.isArray(output.products)).toBe(true)
|
||||
}
|
||||
if (output.products) {
|
||||
expect(Array.isArray(output.products)).toBe(true)
|
||||
}
|
||||
|
||||
// Verify no error was set
|
||||
expect(result.info.error).toBeUndefined()
|
||||
}
|
||||
// Verify no error was set
|
||||
expect(result.info.error).toBeUndefined()
|
||||
}
|
||||
|
||||
// Clean up
|
||||
// Note: Not removing session to avoid race with background SessionSummary.summarize
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
// Clean up
|
||||
// Note: Not removing session to avoid race with background SessionSummary.summarize
|
||||
}),
|
||||
{ git: true },
|
||||
60000,
|
||||
)
|
||||
|
||||
test.skipIf(!hasApiKey)(
|
||||
live(
|
||||
"works with text outputFormat (default)",
|
||||
async () => {
|
||||
await withInstance(() =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({ title: "Text Output Test" })
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({ title: "Text Output Test" })
|
||||
|
||||
const result = yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Say hello.",
|
||||
},
|
||||
],
|
||||
format: {
|
||||
type: "text",
|
||||
},
|
||||
})
|
||||
const result = yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Say hello.",
|
||||
},
|
||||
],
|
||||
format: {
|
||||
type: "text",
|
||||
},
|
||||
})
|
||||
|
||||
// Verify no structured output (text mode) and no error
|
||||
expect(result.info.role).toBe("assistant")
|
||||
if (result.info.role === "assistant") {
|
||||
expect(result.info.structured).toBeUndefined()
|
||||
expect(result.info.error).toBeUndefined()
|
||||
}
|
||||
// Verify no structured output (text mode) and no error
|
||||
expect(result.info.role).toBe("assistant")
|
||||
if (result.info.role === "assistant") {
|
||||
expect(result.info.structured).toBeUndefined()
|
||||
expect(result.info.error).toBeUndefined()
|
||||
}
|
||||
|
||||
// Verify we got a response with parts
|
||||
expect(result.parts.length).toBeGreaterThan(0)
|
||||
// Verify we got a response with parts
|
||||
expect(result.parts.length).toBeGreaterThan(0)
|
||||
|
||||
// Clean up
|
||||
// Note: Not removing session to avoid race with background SessionSummary.summarize
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
// Clean up
|
||||
// Note: Not removing session to avoid race with background SessionSummary.summarize
|
||||
}),
|
||||
{ git: true },
|
||||
60000,
|
||||
)
|
||||
|
||||
test.skipIf(!hasApiKey)(
|
||||
live(
|
||||
"stores outputFormat on user message",
|
||||
async () => {
|
||||
await withInstance(() =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({ title: "OutputFormat Storage Test" })
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({ title: "OutputFormat Storage Test" })
|
||||
|
||||
yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "What is 1 + 1?",
|
||||
},
|
||||
],
|
||||
format: {
|
||||
type: "json_schema",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
result: { type: "number" },
|
||||
},
|
||||
required: ["result"],
|
||||
},
|
||||
retryCount: 3,
|
||||
yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "What is 1 + 1?",
|
||||
},
|
||||
],
|
||||
format: {
|
||||
type: "json_schema",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
result: { type: "number" },
|
||||
},
|
||||
})
|
||||
required: ["result"],
|
||||
},
|
||||
retryCount: 3,
|
||||
},
|
||||
})
|
||||
|
||||
// Get all messages from session
|
||||
const messages = yield* sessions.messages({ sessionID: session.id })
|
||||
const userMessage = messages.find((m) => m.info.role === "user")
|
||||
// Get all messages from session
|
||||
const messages = yield* sessions.messages({ sessionID: session.id })
|
||||
const userMessage = messages.find((m) => m.info.role === "user")
|
||||
|
||||
// Verify outputFormat was stored on user message
|
||||
expect(userMessage).toBeDefined()
|
||||
if (userMessage?.info.role === "user") {
|
||||
expect(userMessage.info.format).toBeDefined()
|
||||
expect(userMessage.info.format?.type).toBe("json_schema")
|
||||
if (userMessage.info.format?.type === "json_schema") {
|
||||
expect(userMessage.info.format.retryCount).toBe(3)
|
||||
}
|
||||
}
|
||||
// Verify outputFormat was stored on user message
|
||||
expect(userMessage).toBeDefined()
|
||||
if (userMessage?.info.role === "user") {
|
||||
expect(userMessage.info.format).toBeDefined()
|
||||
expect(userMessage.info.format?.type).toBe("json_schema")
|
||||
if (userMessage.info.format?.type === "json_schema") {
|
||||
expect(userMessage.info.format.retryCount).toBe(3)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
// Note: Not removing session to avoid race with background SessionSummary.summarize
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
// Clean up
|
||||
// Note: Not removing session to avoid race with background SessionSummary.summarize
|
||||
}),
|
||||
{ git: true },
|
||||
60000,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { describe, expect, beforeAll, afterAll } from "bun:test"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Effect, Layer } 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>
|
||||
@@ -12,6 +14,7 @@ let downloadCount = 0
|
||||
|
||||
const fixturePath = path.join(import.meta.dir, "../fixture/skills")
|
||||
const cacheDir = path.join(Global.Path.cache, "skills")
|
||||
const it = testEffect(Layer.mergeAll(Discovery.defaultLayer, AppFileSystem.defaultLayer))
|
||||
|
||||
beforeAll(async () => {
|
||||
await rm(cacheDir, { recursive: true, force: true })
|
||||
@@ -47,70 +50,90 @@ afterAll(async () => {
|
||||
})
|
||||
|
||||
describe("Discovery.pull", () => {
|
||||
const pull = (url: string) =>
|
||||
Effect.runPromise(Discovery.Service.use((s) => s.pull(url)).pipe(Effect.provide(Discovery.defaultLayer)))
|
||||
it.live("downloads skills from cloudflare url", () =>
|
||||
Effect.gen(function* () {
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const discovery = yield* Discovery.Service
|
||||
const dirs = yield* discovery.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* fsys.existsSafe(md)).toBe(true)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
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("url without trailing slash works", () =>
|
||||
Effect.gen(function* () {
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const discovery = yield* Discovery.Service
|
||||
const dirs = yield* discovery.pull(CLOUDFLARE_SKILLS_URL.replace(/\/$/, ""))
|
||||
expect(dirs.length).toBeGreaterThan(0)
|
||||
for (const dir of dirs) {
|
||||
const md = path.join(dir, "SKILL.md")
|
||||
expect(yield* fsys.existsSafe(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("returns empty array for invalid url", () =>
|
||||
Effect.gen(function* () {
|
||||
const discovery = yield* Discovery.Service
|
||||
const dirs = yield* discovery.pull(`http://localhost:${server.port}/invalid-url/`)
|
||||
expect(dirs).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
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 non-json response", () =>
|
||||
Effect.gen(function* () {
|
||||
// any url not explicitly handled in server returns 404 text "Not Found"
|
||||
const discovery = yield* Discovery.Service
|
||||
const dirs = yield* discovery.pull(`http://localhost:${server.port}/some-other-path/`)
|
||||
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("downloads reference files alongside SKILL.md", () =>
|
||||
Effect.gen(function* () {
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const discovery = yield* Discovery.Service
|
||||
const dirs = yield* discovery.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* fsys.existsSafe(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("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("caches downloaded files on second pull", () =>
|
||||
Effect.gen(function* () {
|
||||
// clear dir and downloadCount
|
||||
yield* Effect.promise(() => rm(cacheDir, { recursive: true, force: true }))
|
||||
downloadCount = 0
|
||||
const discovery = yield* Discovery.Service
|
||||
|
||||
test("caches downloaded files on second pull", async () => {
|
||||
// clear dir and downloadCount
|
||||
await rm(cacheDir, { recursive: true, force: true })
|
||||
downloadCount = 0
|
||||
// first pull to populate cache
|
||||
const first = yield* discovery.pull(CLOUDFLARE_SKILLS_URL)
|
||||
expect(first.length).toBeGreaterThan(0)
|
||||
const firstCount = downloadCount
|
||||
expect(firstCount).toBeGreaterThan(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)
|
||||
// second pull should return same results from cache
|
||||
const second = yield* discovery.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 = await 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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { afterEach, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect, Fiber } from "effect"
|
||||
import { Effect, Fiber, Layer } from "effect"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Snapshot.defaultLayer)
|
||||
const it = testEffect(Layer.mergeAll(Snapshot.defaultLayer, AppFileSystem.defaultLayer))
|
||||
|
||||
// Git always outputs /-separated paths internally. Snapshot.patch() joins them
|
||||
// with path.join (which produces \ on Windows) then normalizes back to /.
|
||||
@@ -27,17 +27,13 @@ const exec = (cwd: string, command: string[]) =>
|
||||
if (code !== 0) throw new Error(`${command.join(" ")} failed: ${await new Response(proc.stderr).text()}`)
|
||||
})
|
||||
|
||||
const write = (file: string, content: string | Uint8Array) => Effect.promise(() => Filesystem.write(file, content))
|
||||
const readText = (file: string) => Effect.promise(() => fs.readFile(file, "utf-8"))
|
||||
const exists = (file: string) =>
|
||||
Effect.promise(() =>
|
||||
fs
|
||||
.access(file)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
)
|
||||
const mkdirp = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true }))
|
||||
const rm = (file: string) => Effect.promise(() => fs.rm(file, { recursive: true, force: true }))
|
||||
const write = (file: string, content: string | Uint8Array) =>
|
||||
AppFileSystem.Service.use((fs) => fs.writeWithDirs(file, content))
|
||||
const readText = (file: string) => AppFileSystem.Service.use((fs) => fs.readFileString(file))
|
||||
const exists = (file: string) => AppFileSystem.Service.use((fs) => fs.existsSafe(file))
|
||||
const mkdirp = (dir: string) => AppFileSystem.Service.use((fs) => fs.ensureDir(dir))
|
||||
const rm = (file: string) =>
|
||||
AppFileSystem.Service.use((fs) => fs.remove(file, { recursive: true, force: true }).pipe(Effect.ignore))
|
||||
|
||||
const initialize = Effect.fn("SnapshotTest.initialize")(function* (dir: string) {
|
||||
const unique = Math.random().toString(36).slice(2)
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
import { Effect, ManagedRuntime, Layer } from "effect"
|
||||
import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import { ApplyPatchTool } from "../../src/tool/apply_patch"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Format } from "../../src/format"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const runtime = ManagedRuntime.make(
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
LSP.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
@@ -58,11 +57,11 @@ type ToolCtx = typeof baseCtx & {
|
||||
ask: (input: AskInput) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
const execute = async (params: { patchText: string }, ctx: ToolCtx) => {
|
||||
const info = await runtime.runPromise(ApplyPatchTool)
|
||||
const tool = await runtime.runPromise(info.init())
|
||||
return Effect.runPromise(tool.execute(params, ctx))
|
||||
}
|
||||
const execute = Effect.fn("ApplyPatchToolTest.execute")(function* (params: { patchText: string }, ctx: ToolCtx) {
|
||||
const info = yield* ApplyPatchTool
|
||||
const tool = yield* info.init()
|
||||
return yield* tool.execute(params, ctx)
|
||||
})
|
||||
|
||||
const makeCtx = () => {
|
||||
const calls: AskInput[] = []
|
||||
@@ -77,39 +76,56 @@ const makeCtx = () => {
|
||||
return { ctx, calls }
|
||||
}
|
||||
|
||||
const readText = (filepath: string) => Effect.promise(() => fs.readFile(filepath, "utf-8"))
|
||||
const writeText = (filepath: string, content: string) => Effect.promise(() => fs.writeFile(filepath, content, "utf-8"))
|
||||
const makeDir = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true }))
|
||||
|
||||
const expectFailure = <A, E, R>(effect: Effect.Effect<A, E, R>, message?: string) =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Effect.exit(effect)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit) && message) expect(Cause.pretty(exit.cause)).toContain(message)
|
||||
})
|
||||
|
||||
const expectReadFailure = (filepath: string) => expectFailure(readText(filepath))
|
||||
|
||||
describe("tool.apply_patch freeform", () => {
|
||||
test("requires patchText", async () => {
|
||||
const { ctx } = makeCtx()
|
||||
await expect(execute({ patchText: "" }, ctx)).rejects.toThrow("patchText is required")
|
||||
})
|
||||
it.live("requires patchText", () =>
|
||||
Effect.gen(function* () {
|
||||
const { ctx } = makeCtx()
|
||||
yield* expectFailure(execute({ patchText: "" }, ctx), "patchText is required")
|
||||
}),
|
||||
)
|
||||
|
||||
test("rejects invalid patch format", async () => {
|
||||
const { ctx } = makeCtx()
|
||||
await expect(execute({ patchText: "invalid patch" }, ctx)).rejects.toThrow("apply_patch verification failed")
|
||||
})
|
||||
it.live("rejects invalid patch format", () =>
|
||||
Effect.gen(function* () {
|
||||
const { ctx } = makeCtx()
|
||||
yield* expectFailure(execute({ patchText: "invalid patch" }, ctx), "apply_patch verification failed")
|
||||
}),
|
||||
)
|
||||
|
||||
test("rejects empty patch", async () => {
|
||||
const { ctx } = makeCtx()
|
||||
const emptyPatch = "*** Begin Patch\n*** End Patch"
|
||||
await expect(execute({ patchText: emptyPatch }, ctx)).rejects.toThrow("patch rejected: empty patch")
|
||||
})
|
||||
it.live("rejects empty patch", () =>
|
||||
Effect.gen(function* () {
|
||||
const { ctx } = makeCtx()
|
||||
yield* expectFailure(execute({ patchText: "*** Begin Patch\n*** End Patch" }, ctx), "patch rejected: empty patch")
|
||||
}),
|
||||
)
|
||||
|
||||
test("applies add/update/delete in one patch", async () => {
|
||||
await using fixture = await tmpdir({ git: true })
|
||||
const { ctx, calls } = makeCtx()
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const modifyPath = path.join(fixture.path, "modify.txt")
|
||||
const deletePath = path.join(fixture.path, "delete.txt")
|
||||
await fs.writeFile(modifyPath, "line1\nline2\n", "utf-8")
|
||||
await fs.writeFile(deletePath, "obsolete\n", "utf-8")
|
||||
it.instance(
|
||||
"applies add/update/delete in one patch",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const { ctx, calls } = makeCtx()
|
||||
const modifyPath = path.join(test.directory, "modify.txt")
|
||||
const deletePath = path.join(test.directory, "delete.txt")
|
||||
yield* writeText(modifyPath, "line1\nline2\n")
|
||||
yield* writeText(deletePath, "obsolete\n")
|
||||
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Delete File: delete.txt\n*** Update File: modify.txt\n@@\n-line2\n+changed\n*** End Patch"
|
||||
|
||||
const result = await execute({ patchText }, ctx)
|
||||
const result = yield* execute({ patchText }, ctx)
|
||||
|
||||
expect(result.title).toContain("Success. Updated the following files")
|
||||
expect(result.output).toContain("Success. Updated the following files")
|
||||
@@ -129,38 +145,34 @@ describe("tool.apply_patch freeform", () => {
|
||||
expect(permissionCall.metadata.files.map((f) => f.type).sort()).toEqual(["add", "delete", "update"])
|
||||
|
||||
const addFile = permissionCall.metadata.files.find((f) => f.type === "add")
|
||||
expect(addFile).toBeDefined()
|
||||
expect(addFile!.relativePath).toBe("nested/new.txt")
|
||||
expect(addFile!.patch).toContain("+created")
|
||||
expect(addFile?.relativePath).toBe("nested/new.txt")
|
||||
expect(addFile?.patch).toContain("+created")
|
||||
|
||||
const updateFile = permissionCall.metadata.files.find((f) => f.type === "update")
|
||||
expect(updateFile).toBeDefined()
|
||||
expect(updateFile!.patch).toContain("-line2")
|
||||
expect(updateFile!.patch).toContain("+changed")
|
||||
expect(updateFile?.patch).toContain("-line2")
|
||||
expect(updateFile?.patch).toContain("+changed")
|
||||
|
||||
const added = await fs.readFile(path.join(fixture.path, "nested", "new.txt"), "utf-8")
|
||||
expect(added).toBe("created\n")
|
||||
expect(await fs.readFile(modifyPath, "utf-8")).toBe("line1\nchanged\n")
|
||||
await expect(fs.readFile(deletePath, "utf-8")).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
})
|
||||
expect(yield* readText(path.join(test.directory, "nested", "new.txt"))).toBe("created\n")
|
||||
expect(yield* readText(modifyPath)).toBe("line1\nchanged\n")
|
||||
yield* expectReadFailure(deletePath)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
test("permission metadata includes move file info", async () => {
|
||||
await using fixture = await tmpdir({ git: true })
|
||||
const { ctx, calls } = makeCtx()
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const original = path.join(fixture.path, "old", "name.txt")
|
||||
await fs.mkdir(path.dirname(original), { recursive: true })
|
||||
await fs.writeFile(original, "old content\n", "utf-8")
|
||||
it.instance(
|
||||
"permission metadata includes move file info",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const { ctx, calls } = makeCtx()
|
||||
const original = path.join(test.directory, "old", "name.txt")
|
||||
yield* makeDir(path.dirname(original))
|
||||
yield* writeText(original, "old content\n")
|
||||
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch"
|
||||
|
||||
await execute({ patchText }, ctx)
|
||||
yield* execute({ patchText }, ctx)
|
||||
|
||||
expect(calls.length).toBe(1)
|
||||
const permissionCall = calls[0]
|
||||
@@ -169,447 +181,353 @@ describe("tool.apply_patch freeform", () => {
|
||||
const moveFile = permissionCall.metadata.files[0]
|
||||
expect(moveFile.type).toBe("move")
|
||||
expect(moveFile.relativePath).toBe("renamed/dir/name.txt")
|
||||
expect(moveFile.movePath).toBe(path.join(fixture.path, "renamed/dir/name.txt"))
|
||||
expect(moveFile.movePath).toBe(path.join(test.directory, "renamed/dir/name.txt"))
|
||||
expect(moveFile.patch).toContain("-old content")
|
||||
expect(moveFile.patch).toContain("+new content")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("applies multiple hunks to one file", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "multi.txt")
|
||||
await fs.writeFile(target, "line1\nline2\nline3\nline4\n", "utf-8")
|
||||
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Update File: multi.txt\n@@\n-line2\n+changed2\n@@\n-line4\n+changed4\n*** End Patch"
|
||||
|
||||
await execute({ patchText }, ctx)
|
||||
|
||||
expect(await fs.readFile(target, "utf-8")).toBe("line1\nchanged2\nline3\nchanged4\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("does not invent a first-line diff for BOM files", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx, calls } = makeCtx()
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const bom = String.fromCharCode(0xfeff)
|
||||
const target = path.join(fixture.path, "example.cs")
|
||||
await fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`, "utf-8")
|
||||
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch"
|
||||
|
||||
await execute({ patchText }, ctx)
|
||||
|
||||
expect(calls.length).toBe(1)
|
||||
const shown = calls[0].metadata.files[0]?.patch ?? ""
|
||||
expect(shown).not.toContain(bom)
|
||||
expect(shown).not.toContain("-using System;")
|
||||
expect(shown).not.toContain("+using System;")
|
||||
|
||||
const content = await fs.readFile(target, "utf-8")
|
||||
expect(content.charCodeAt(0)).toBe(0xfeff)
|
||||
expect(content.slice(1)).toBe("using System;\n\nclass Test {}\nclass Next {}\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("inserts lines with insert-only hunk", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "insert_only.txt")
|
||||
await fs.writeFile(target, "alpha\nomega\n", "utf-8")
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Update File: insert_only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch"
|
||||
|
||||
await execute({ patchText }, ctx)
|
||||
|
||||
expect(await fs.readFile(target, "utf-8")).toBe("alpha\nbeta\nomega\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("appends trailing newline on update", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "no_newline.txt")
|
||||
await fs.writeFile(target, "no newline at end", "utf-8")
|
||||
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Update File: no_newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch"
|
||||
|
||||
await execute({ patchText }, ctx)
|
||||
|
||||
const contents = await fs.readFile(target, "utf-8")
|
||||
expect(contents.endsWith("\n")).toBe(true)
|
||||
expect(contents).toBe("first line\nsecond line\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("moves file to a new directory", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const original = path.join(fixture.path, "old", "name.txt")
|
||||
await fs.mkdir(path.dirname(original), { recursive: true })
|
||||
await fs.writeFile(original, "old content\n", "utf-8")
|
||||
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch"
|
||||
|
||||
await execute({ patchText }, ctx)
|
||||
|
||||
const moved = path.join(fixture.path, "renamed", "dir", "name.txt")
|
||||
await expect(fs.readFile(original, "utf-8")).rejects.toThrow()
|
||||
expect(await fs.readFile(moved, "utf-8")).toBe("new content\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("moves file overwriting existing destination", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const original = path.join(fixture.path, "old", "name.txt")
|
||||
const destination = path.join(fixture.path, "renamed", "dir", "name.txt")
|
||||
await fs.mkdir(path.dirname(original), { recursive: true })
|
||||
await fs.mkdir(path.dirname(destination), { recursive: true })
|
||||
await fs.writeFile(original, "from\n", "utf-8")
|
||||
await fs.writeFile(destination, "existing\n", "utf-8")
|
||||
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-from\n+new\n*** End Patch"
|
||||
|
||||
await execute({ patchText }, ctx)
|
||||
|
||||
await expect(fs.readFile(original, "utf-8")).rejects.toThrow()
|
||||
expect(await fs.readFile(destination, "utf-8")).toBe("new\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("adds file overwriting existing file", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "duplicate.txt")
|
||||
await fs.writeFile(target, "old content\n", "utf-8")
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Add File: duplicate.txt\n+new content\n*** End Patch"
|
||||
|
||||
await execute({ patchText }, ctx)
|
||||
expect(await fs.readFile(target, "utf-8")).toBe("new content\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects update when target file is missing", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const patchText = "*** Begin Patch\n*** Update File: missing.txt\n@@\n-nope\n+better\n*** End Patch"
|
||||
|
||||
await expect(execute({ patchText }, ctx)).rejects.toThrow(
|
||||
"apply_patch verification failed: Failed to read file to update",
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects delete when file is missing", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const patchText = "*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch"
|
||||
|
||||
await expect(execute({ patchText }, ctx)).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects delete when target is a directory", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const dirPath = path.join(fixture.path, "dir")
|
||||
await fs.mkdir(dirPath)
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Delete File: dir\n*** End Patch"
|
||||
|
||||
await expect(execute({ patchText }, ctx)).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects invalid hunk header", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const patchText = "*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"
|
||||
|
||||
await expect(execute({ patchText }, ctx)).rejects.toThrow("apply_patch verification failed")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects update with missing context", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "modify.txt")
|
||||
await fs.writeFile(target, "line1\nline2\n", "utf-8")
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch"
|
||||
|
||||
await expect(execute({ patchText }, ctx)).rejects.toThrow("apply_patch verification failed")
|
||||
expect(await fs.readFile(target, "utf-8")).toBe("line1\nline2\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("verification failure leaves no side effects", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Add File: created.txt\n+hello\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch"
|
||||
|
||||
await expect(execute({ patchText }, ctx)).rejects.toThrow()
|
||||
|
||||
const createdPath = path.join(fixture.path, "created.txt")
|
||||
await expect(fs.readFile(createdPath, "utf-8")).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("supports end of file anchor", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "tail.txt")
|
||||
await fs.writeFile(target, "alpha\nlast\n", "utf-8")
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Update File: tail.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch"
|
||||
|
||||
await execute({ patchText }, ctx)
|
||||
expect(await fs.readFile(target, "utf-8")).toBe("alpha\nend\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects missing second chunk context", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "two_chunks.txt")
|
||||
await fs.writeFile(target, "a\nb\nc\nd\n", "utf-8")
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Update File: two_chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch"
|
||||
|
||||
await expect(execute({ patchText }, ctx)).rejects.toThrow()
|
||||
expect(await fs.readFile(target, "utf-8")).toBe("a\nb\nc\nd\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("disambiguates change context with @@ header", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "multi_ctx.txt")
|
||||
await fs.writeFile(target, "fn a\nx=10\ny=2\nfn b\nx=10\ny=20\n", "utf-8")
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Update File: multi_ctx.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch"
|
||||
|
||||
await execute({ patchText }, ctx)
|
||||
expect(await fs.readFile(target, "utf-8")).toBe("fn a\nx=10\ny=2\nfn b\nx=11\ny=20\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("EOF anchor matches from end of file first", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "eof_anchor.txt")
|
||||
// File has duplicate "marker" lines - one in middle, one at end
|
||||
await fs.writeFile(target, "start\nmarker\nmiddle\nmarker\nend\n", "utf-8")
|
||||
|
||||
// With EOF anchor, should match the LAST "marker" line, not the first
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Update File: eof_anchor.txt\n@@\n-marker\n-end\n+marker-changed\n+end\n*** End of File\n*** End Patch"
|
||||
|
||||
await execute({ patchText }, ctx)
|
||||
// First marker unchanged, second marker changed
|
||||
expect(await fs.readFile(target, "utf-8")).toBe("start\nmarker\nmiddle\nmarker-changed\nend\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("parses heredoc-wrapped patch", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const patchText = `cat <<'EOF'
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance("applies multiple hunks to one file", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const { ctx } = makeCtx()
|
||||
const target = path.join(test.directory, "multi.txt")
|
||||
yield* writeText(target, "line1\nline2\nline3\nline4\n")
|
||||
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Update File: multi.txt\n@@\n-line2\n+changed2\n@@\n-line4\n+changed4\n*** End Patch"
|
||||
|
||||
yield* execute({ patchText }, ctx)
|
||||
|
||||
expect(yield* readText(target)).toBe("line1\nchanged2\nline3\nchanged4\n")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("does not invent a first-line diff for BOM files", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const { ctx, calls } = makeCtx()
|
||||
const bom = String.fromCharCode(0xfeff)
|
||||
const target = path.join(test.directory, "example.cs")
|
||||
yield* writeText(target, `${bom}using System;\n\nclass Test {}\n`)
|
||||
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch"
|
||||
|
||||
yield* execute({ patchText }, ctx)
|
||||
|
||||
expect(calls.length).toBe(1)
|
||||
const shown = calls[0].metadata.files[0]?.patch ?? ""
|
||||
expect(shown).not.toContain(bom)
|
||||
expect(shown).not.toContain("-using System;")
|
||||
expect(shown).not.toContain("+using System;")
|
||||
|
||||
const content = yield* readText(target)
|
||||
expect(content.charCodeAt(0)).toBe(0xfeff)
|
||||
expect(content.slice(1)).toBe("using System;\n\nclass Test {}\nclass Next {}\n")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("inserts lines with insert-only hunk", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const { ctx } = makeCtx()
|
||||
const target = path.join(test.directory, "insert_only.txt")
|
||||
yield* writeText(target, "alpha\nomega\n")
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Update File: insert_only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch"
|
||||
|
||||
yield* execute({ patchText }, ctx)
|
||||
|
||||
expect(yield* readText(target)).toBe("alpha\nbeta\nomega\n")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("appends trailing newline on update", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const { ctx } = makeCtx()
|
||||
const target = path.join(test.directory, "no_newline.txt")
|
||||
yield* writeText(target, "no newline at end")
|
||||
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Update File: no_newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch"
|
||||
|
||||
yield* execute({ patchText }, ctx)
|
||||
|
||||
const contents = yield* readText(target)
|
||||
expect(contents.endsWith("\n")).toBe(true)
|
||||
expect(contents).toBe("first line\nsecond line\n")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("moves file to a new directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const { ctx } = makeCtx()
|
||||
const original = path.join(test.directory, "old", "name.txt")
|
||||
yield* makeDir(path.dirname(original))
|
||||
yield* writeText(original, "old content\n")
|
||||
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch"
|
||||
|
||||
yield* execute({ patchText }, ctx)
|
||||
|
||||
const moved = path.join(test.directory, "renamed", "dir", "name.txt")
|
||||
yield* expectReadFailure(original)
|
||||
expect(yield* readText(moved)).toBe("new content\n")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("moves file overwriting existing destination", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const { ctx } = makeCtx()
|
||||
const original = path.join(test.directory, "old", "name.txt")
|
||||
const destination = path.join(test.directory, "renamed", "dir", "name.txt")
|
||||
yield* makeDir(path.dirname(original))
|
||||
yield* makeDir(path.dirname(destination))
|
||||
yield* writeText(original, "from\n")
|
||||
yield* writeText(destination, "existing\n")
|
||||
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-from\n+new\n*** End Patch"
|
||||
|
||||
yield* execute({ patchText }, ctx)
|
||||
|
||||
yield* expectReadFailure(original)
|
||||
expect(yield* readText(destination)).toBe("new\n")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("adds file overwriting existing file", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const { ctx } = makeCtx()
|
||||
const target = path.join(test.directory, "duplicate.txt")
|
||||
yield* writeText(target, "old content\n")
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Add File: duplicate.txt\n+new content\n*** End Patch"
|
||||
|
||||
yield* execute({ patchText }, ctx)
|
||||
expect(yield* readText(target)).toBe("new content\n")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("rejects update when target file is missing", () =>
|
||||
Effect.gen(function* () {
|
||||
const { ctx } = makeCtx()
|
||||
const patchText = "*** Begin Patch\n*** Update File: missing.txt\n@@\n-nope\n+better\n*** End Patch"
|
||||
|
||||
yield* expectFailure(
|
||||
execute({ patchText }, ctx),
|
||||
"apply_patch verification failed: Failed to read file to update",
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("rejects delete when file is missing", () =>
|
||||
Effect.gen(function* () {
|
||||
const { ctx } = makeCtx()
|
||||
const patchText = "*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch"
|
||||
|
||||
yield* expectFailure(execute({ patchText }, ctx))
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("rejects delete when target is a directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const { ctx } = makeCtx()
|
||||
const dirPath = path.join(test.directory, "dir")
|
||||
yield* makeDir(dirPath)
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Delete File: dir\n*** End Patch"
|
||||
|
||||
yield* expectFailure(execute({ patchText }, ctx))
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("rejects invalid hunk header", () =>
|
||||
Effect.gen(function* () {
|
||||
const { ctx } = makeCtx()
|
||||
const patchText = "*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"
|
||||
|
||||
yield* expectFailure(execute({ patchText }, ctx), "apply_patch verification failed")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("rejects update with missing context", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const { ctx } = makeCtx()
|
||||
const target = path.join(test.directory, "modify.txt")
|
||||
yield* writeText(target, "line1\nline2\n")
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch"
|
||||
|
||||
yield* expectFailure(execute({ patchText }, ctx), "apply_patch verification failed")
|
||||
expect(yield* readText(target)).toBe("line1\nline2\n")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("verification failure leaves no side effects", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const { ctx } = makeCtx()
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Add File: created.txt\n+hello\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch"
|
||||
|
||||
yield* expectFailure(execute({ patchText }, ctx))
|
||||
yield* expectReadFailure(path.join(test.directory, "created.txt"))
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("supports end of file anchor", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const { ctx } = makeCtx()
|
||||
const target = path.join(test.directory, "tail.txt")
|
||||
yield* writeText(target, "alpha\nlast\n")
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Update File: tail.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch"
|
||||
|
||||
yield* execute({ patchText }, ctx)
|
||||
expect(yield* readText(target)).toBe("alpha\nend\n")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("rejects missing second chunk context", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const { ctx } = makeCtx()
|
||||
const target = path.join(test.directory, "two_chunks.txt")
|
||||
yield* writeText(target, "a\nb\nc\nd\n")
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Update File: two_chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch"
|
||||
|
||||
yield* expectFailure(execute({ patchText }, ctx))
|
||||
expect(yield* readText(target)).toBe("a\nb\nc\nd\n")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("disambiguates change context with @@ header", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const { ctx } = makeCtx()
|
||||
const target = path.join(test.directory, "multi_ctx.txt")
|
||||
yield* writeText(target, "fn a\nx=10\ny=2\nfn b\nx=10\ny=20\n")
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Update File: multi_ctx.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch"
|
||||
|
||||
yield* execute({ patchText }, ctx)
|
||||
expect(yield* readText(target)).toBe("fn a\nx=10\ny=2\nfn b\nx=11\ny=20\n")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("EOF anchor matches from end of file first", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const { ctx } = makeCtx()
|
||||
const target = path.join(test.directory, "eof_anchor.txt")
|
||||
// File has duplicate "marker" lines - one in middle, one at end
|
||||
yield* writeText(target, "start\nmarker\nmiddle\nmarker\nend\n")
|
||||
|
||||
// With EOF anchor, should match the LAST "marker" line, not the first
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Update File: eof_anchor.txt\n@@\n-marker\n-end\n+marker-changed\n+end\n*** End of File\n*** End Patch"
|
||||
|
||||
yield* execute({ patchText }, ctx)
|
||||
// First marker unchanged, second marker changed
|
||||
expect(yield* readText(target)).toBe("start\nmarker\nmiddle\nmarker-changed\nend\n")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("parses heredoc-wrapped patch", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const { ctx } = makeCtx()
|
||||
const patchText = `cat <<'EOF'
|
||||
*** Begin Patch
|
||||
*** Add File: heredoc_test.txt
|
||||
+heredoc content
|
||||
*** End Patch
|
||||
EOF`
|
||||
|
||||
await execute({ patchText }, ctx)
|
||||
const content = await fs.readFile(path.join(fixture.path, "heredoc_test.txt"), "utf-8")
|
||||
expect(content).toBe("heredoc content\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
yield* execute({ patchText }, ctx)
|
||||
expect(yield* readText(path.join(test.directory, "heredoc_test.txt"))).toBe("heredoc content\n")
|
||||
}),
|
||||
)
|
||||
|
||||
test("parses heredoc-wrapped patch without cat", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const patchText = `<<EOF
|
||||
it.instance("parses heredoc-wrapped patch without cat", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const { ctx } = makeCtx()
|
||||
const patchText = `<<EOF
|
||||
*** Begin Patch
|
||||
*** Add File: heredoc_no_cat.txt
|
||||
+no cat prefix
|
||||
*** End Patch
|
||||
EOF`
|
||||
|
||||
await execute({ patchText }, ctx)
|
||||
const content = await fs.readFile(path.join(fixture.path, "heredoc_no_cat.txt"), "utf-8")
|
||||
expect(content).toBe("no cat prefix\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
yield* execute({ patchText }, ctx)
|
||||
expect(yield* readText(path.join(test.directory, "heredoc_no_cat.txt"))).toBe("no cat prefix\n")
|
||||
}),
|
||||
)
|
||||
|
||||
test("matches with trailing whitespace differences", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
it.instance("matches with trailing whitespace differences", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const { ctx } = makeCtx()
|
||||
const target = path.join(test.directory, "trailing_ws.txt")
|
||||
// File has trailing spaces on some lines
|
||||
yield* writeText(target, "line1 \nline2\nline3 \n")
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "trailing_ws.txt")
|
||||
// File has trailing spaces on some lines
|
||||
await fs.writeFile(target, "line1 \nline2\nline3 \n", "utf-8")
|
||||
// Patch doesn't have trailing spaces - should still match via rstrip pass
|
||||
const patchText = "*** Begin Patch\n*** Update File: trailing_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
|
||||
|
||||
// Patch doesn't have trailing spaces - should still match via rstrip pass
|
||||
const patchText = "*** Begin Patch\n*** Update File: trailing_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
|
||||
yield* execute({ patchText }, ctx)
|
||||
expect(yield* readText(target)).toBe("line1 \nchanged\nline3 \n")
|
||||
}),
|
||||
)
|
||||
|
||||
await execute({ patchText }, ctx)
|
||||
expect(await fs.readFile(target, "utf-8")).toBe("line1 \nchanged\nline3 \n")
|
||||
},
|
||||
})
|
||||
})
|
||||
it.instance("matches with leading whitespace differences", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const { ctx } = makeCtx()
|
||||
const target = path.join(test.directory, "leading_ws.txt")
|
||||
// File has leading spaces
|
||||
yield* writeText(target, " line1\nline2\n line3\n")
|
||||
|
||||
test("matches with leading whitespace differences", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
// Patch without leading spaces - should match via trim pass
|
||||
const patchText = "*** Begin Patch\n*** Update File: leading_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "leading_ws.txt")
|
||||
// File has leading spaces
|
||||
await fs.writeFile(target, " line1\nline2\n line3\n", "utf-8")
|
||||
yield* execute({ patchText }, ctx)
|
||||
expect(yield* readText(target)).toBe(" line1\nchanged\n line3\n")
|
||||
}),
|
||||
)
|
||||
|
||||
// Patch without leading spaces - should match via trim pass
|
||||
const patchText = "*** Begin Patch\n*** Update File: leading_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
|
||||
it.instance("matches with Unicode punctuation differences", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const { ctx } = makeCtx()
|
||||
const target = path.join(test.directory, "unicode.txt")
|
||||
// File has fancy Unicode quotes (U+201C, U+201D) and em-dash (U+2014)
|
||||
const leftQuote = "\u201C"
|
||||
const rightQuote = "\u201D"
|
||||
const emDash = "\u2014"
|
||||
yield* writeText(target, `He said ${leftQuote}hello${rightQuote}\nsome${emDash}dash\nend\n`)
|
||||
|
||||
await execute({ patchText }, ctx)
|
||||
expect(await fs.readFile(target, "utf-8")).toBe(" line1\nchanged\n line3\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
// Patch uses ASCII equivalents - should match via normalized pass
|
||||
// The replacement uses ASCII quotes from the patch (not preserving Unicode)
|
||||
const patchText =
|
||||
'*** Begin Patch\n*** Update File: unicode.txt\n@@\n-He said "hello"\n+He said "hi"\n*** End Patch'
|
||||
|
||||
test("matches with Unicode punctuation differences", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "unicode.txt")
|
||||
// File has fancy Unicode quotes (U+201C, U+201D) and em-dash (U+2014)
|
||||
const leftQuote = "\u201C"
|
||||
const rightQuote = "\u201D"
|
||||
const emDash = "\u2014"
|
||||
await fs.writeFile(target, `He said ${leftQuote}hello${rightQuote}\nsome${emDash}dash\nend\n`, "utf-8")
|
||||
|
||||
// Patch uses ASCII equivalents - should match via normalized pass
|
||||
// The replacement uses ASCII quotes from the patch (not preserving Unicode)
|
||||
const patchText =
|
||||
'*** Begin Patch\n*** Update File: unicode.txt\n@@\n-He said "hello"\n+He said "hi"\n*** End Patch'
|
||||
|
||||
await execute({ patchText }, ctx)
|
||||
// Result has ASCII quotes because that's what the patch specifies
|
||||
expect(await fs.readFile(target, "utf-8")).toBe(`He said "hi"\nsome${emDash}dash\nend\n`)
|
||||
},
|
||||
})
|
||||
})
|
||||
yield* execute({ patchText }, ctx)
|
||||
// Result has ASCII quotes because that's what the patch specifies
|
||||
expect(yield* readText(target)).toBe(`He said "hi"\nsome${emDash}dash\nend\n`)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user