mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 19:09:49 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60124ac57d |
Binary file not shown.
|
Before Width: | Height: | Size: 62 KiB |
@@ -395,7 +395,6 @@
|
|||||||
"ignore": "7.0.5",
|
"ignore": "7.0.5",
|
||||||
"immer": "11.1.4",
|
"immer": "11.1.4",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"mime-types": "3.0.2",
|
|
||||||
"tree-sitter-bash": "0.25.0",
|
"tree-sitter-bash": "0.25.0",
|
||||||
"tree-sitter-powershell": "0.25.10",
|
"tree-sitter-powershell": "0.25.10",
|
||||||
"turndown": "7.2.0",
|
"turndown": "7.2.0",
|
||||||
|
|||||||
@@ -25,20 +25,8 @@ import { ToolSchemaProjection } from "./utils/tool-schema"
|
|||||||
|
|
||||||
const ADAPTER = "gemini"
|
const ADAPTER = "gemini"
|
||||||
const MEDIA_MIMES = new Set<string>(ProviderShared.MEDIA_MIMES)
|
const MEDIA_MIMES = new Set<string>(ProviderShared.MEDIA_MIMES)
|
||||||
// Google documents this sentinel for replaying Gemini 3 function calls after their original signature was lost.
|
|
||||||
const SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator"
|
|
||||||
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||||
|
|
||||||
// Gemini 3 rejects replayed function calls without a thought signature. Google's SDKs avoid that in normal chats by
|
|
||||||
// retaining complete model responses, but OpenCode reconstructs durable history and may encounter an unsigned call
|
|
||||||
// from an older or external session. Model IDs are open-ended, so unknown Gemini aliases inherit current behavior.
|
|
||||||
const requiresThoughtSignatureFallback = (modelID: string) => {
|
|
||||||
if (!/(^|\/)gemini-/i.test(modelID)) return false
|
|
||||||
if (/(^|\/)gemini-(?:1|2)(?:[.-]|$)/i.test(modelID)) return false
|
|
||||||
if (/(^|\/)gemini-pro(?:-vision)?$/i.test(modelID)) return false
|
|
||||||
return !/(^|\/)gemini-robotics-er-1\.5(?:[.-]|$)/i.test(modelID)
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OptionsInput {
|
export interface OptionsInput {
|
||||||
readonly [key: string]: unknown
|
readonly [key: string]: unknown
|
||||||
readonly cachedContent?: string
|
readonly cachedContent?: string
|
||||||
@@ -157,9 +145,6 @@ const GeminiGenerationConfig = Schema.Struct({
|
|||||||
temperature: Schema.optional(Schema.Number),
|
temperature: Schema.optional(Schema.Number),
|
||||||
topP: Schema.optional(Schema.Number),
|
topP: Schema.optional(Schema.Number),
|
||||||
topK: Schema.optional(Schema.Number),
|
topK: Schema.optional(Schema.Number),
|
||||||
frequencyPenalty: Schema.optional(Schema.Number),
|
|
||||||
presencePenalty: Schema.optional(Schema.Number),
|
|
||||||
seed: Schema.optional(Schema.Number),
|
|
||||||
stopSequences: optionalArray(Schema.String),
|
stopSequences: optionalArray(Schema.String),
|
||||||
thinkingConfig: Schema.optional(GeminiThinkingConfig),
|
thinkingConfig: Schema.optional(GeminiThinkingConfig),
|
||||||
})
|
})
|
||||||
@@ -217,13 +202,11 @@ interface ParserState {
|
|||||||
// keys on non-object scalars. Mirrors OpenCode's historical Gemini rules.
|
// keys on non-object scalars. Mirrors OpenCode's historical Gemini rules.
|
||||||
//
|
//
|
||||||
// 2. Project — lossy mapping from JSON Schema to Gemini's schema dialect:
|
// 2. Project — lossy mapping from JSON Schema to Gemini's schema dialect:
|
||||||
// drop empty root parameter schemas while preserving nested empty objects,
|
// drop empty objects, derive `nullable: true` from `type: [..., "null"]`,
|
||||||
// expand type arrays into `anyOf`, derive `nullable: true` from null members,
|
// coerce `const` to `[const]` enum, recurse properties/items, propagate
|
||||||
// coerce `const` to `[const]` enum, recurse properties/items, and propagate
|
|
||||||
// only an allowlisted set of keys (description, required, format, type,
|
// only an allowlisted set of keys (description, required, format, type,
|
||||||
// nullable, enum, properties, items, allOf, anyOf, oneOf, minLength).
|
// properties, items, allOf, anyOf, oneOf, minLength). Anything outside the
|
||||||
// Anything outside the allowlist (e.g. `additionalProperties`, `$ref`) is
|
// allowlist (e.g. `additionalProperties`, `$ref`) is silently dropped.
|
||||||
// silently dropped.
|
|
||||||
//
|
//
|
||||||
// Sanitize runs first, then project. The implementation lives in
|
// Sanitize runs first, then project. The implementation lives in
|
||||||
// `utils/gemini-tool-schema` so this protocol keeps the same shape as the other
|
// `utils/gemini-tool-schema` so this protocol keeps the same shape as the other
|
||||||
@@ -299,8 +282,6 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
|||||||
|
|
||||||
if (message.role === "assistant") {
|
if (message.role === "assistant") {
|
||||||
const parts: Array<Schema.Schema.Type<typeof GeminiContentPart>> = []
|
const parts: Array<Schema.Schema.Type<typeof GeminiContentPart>> = []
|
||||||
// Parallel Gemini 3 calls may carry one signature on the first call; unsigned sibling calls are valid.
|
|
||||||
let hasSignedToolCall = false
|
|
||||||
for (const part of message.content) {
|
for (const part of message.content) {
|
||||||
if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"]))
|
if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"]))
|
||||||
return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"])
|
return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"])
|
||||||
@@ -313,17 +294,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (part.type === "tool-call") {
|
if (part.type === "tool-call") {
|
||||||
const lowered = lowerToolCall(part)
|
parts.push(lowerToolCall(part))
|
||||||
const signature = lowered.thoughtSignature
|
|
||||||
parts.push({
|
|
||||||
...lowered,
|
|
||||||
thoughtSignature:
|
|
||||||
signature ??
|
|
||||||
(requiresThoughtSignatureFallback(request.model.id) && !hasSignedToolCall
|
|
||||||
? SKIP_THOUGHT_SIGNATURE_VALIDATOR
|
|
||||||
: undefined),
|
|
||||||
})
|
|
||||||
if (signature !== undefined) hasSignedToolCall = true
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -417,9 +388,6 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
|||||||
temperature: generation?.temperature,
|
temperature: generation?.temperature,
|
||||||
topP: generation?.topP,
|
topP: generation?.topP,
|
||||||
topK: generation?.topK,
|
topK: generation?.topK,
|
||||||
frequencyPenalty: generation?.frequencyPenalty,
|
|
||||||
presencePenalty: generation?.presencePenalty,
|
|
||||||
seed: generation?.seed,
|
|
||||||
stopSequences: generation?.stop,
|
stopSequences: generation?.stop,
|
||||||
thinkingConfig: options.thinkingConfig,
|
thinkingConfig: options.thinkingConfig,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,15 +90,10 @@ const OpenResponsesFunctionCallOutput = Schema.Union([
|
|||||||
])
|
])
|
||||||
|
|
||||||
export const InputItem = Schema.Union([
|
export const InputItem = Schema.Union([
|
||||||
Schema.Struct({ role: Schema.tag("system"), id: Schema.optionalKey(Schema.String), content: Schema.String }),
|
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
|
||||||
Schema.Struct({
|
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
|
||||||
role: Schema.tag("user"),
|
|
||||||
id: Schema.optionalKey(Schema.String),
|
|
||||||
content: Schema.Array(OpenResponsesInputContent),
|
|
||||||
}),
|
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
role: Schema.tag("assistant"),
|
role: Schema.tag("assistant"),
|
||||||
id: Schema.optionalKey(Schema.String),
|
|
||||||
content: Schema.Array(OpenResponsesOutputText),
|
content: Schema.Array(OpenResponsesOutputText),
|
||||||
phase: Schema.optionalKey(MessagePhase),
|
phase: Schema.optionalKey(MessagePhase),
|
||||||
}),
|
}),
|
||||||
@@ -106,23 +101,19 @@ export const InputItem = Schema.Union([
|
|||||||
OpenResponsesItemReference,
|
OpenResponsesItemReference,
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
type: Schema.tag("function_call"),
|
type: Schema.tag("function_call"),
|
||||||
id: Schema.optionalKey(Schema.String),
|
|
||||||
call_id: Schema.String,
|
call_id: Schema.String,
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
arguments: Schema.String,
|
arguments: Schema.String,
|
||||||
}),
|
}),
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
type: Schema.tag("function_call_output"),
|
type: Schema.tag("function_call_output"),
|
||||||
id: Schema.optionalKey(Schema.String),
|
|
||||||
call_id: Schema.String,
|
call_id: Schema.String,
|
||||||
output: OpenResponsesFunctionCallOutput,
|
output: OpenResponsesFunctionCallOutput,
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
|
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
|
||||||
type ProviderInputItem = Readonly<Record<string, unknown>> & { readonly type: string; readonly id?: string }
|
|
||||||
type LoweredInputItem =
|
type LoweredInputItem =
|
||||||
| OpenResponsesInputItem
|
| OpenResponsesInputItem
|
||||||
| ProviderInputItem
|
|
||||||
| {
|
| {
|
||||||
readonly role: "assistant"
|
readonly role: "assistant"
|
||||||
readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }>
|
readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }>
|
||||||
@@ -137,7 +128,7 @@ type OpenResponsesReasoningInput = {
|
|||||||
summary: Array<{ type: "summary_text"; text: string }>
|
summary: Array<{ type: "summary_text"; text: string }>
|
||||||
encrypted_content?: string | null
|
encrypted_content?: string | null
|
||||||
}
|
}
|
||||||
type OpenResponsesReasoningReplay = Omit<OpenResponsesReasoningInput, "id"> & { id?: string }
|
type OpenResponsesReasoningReplay = Omit<OpenResponsesReasoningInput, "id">
|
||||||
|
|
||||||
export const Tool = Schema.Struct({
|
export const Tool = Schema.Struct({
|
||||||
type: Schema.tag("function"),
|
type: Schema.tag("function"),
|
||||||
@@ -263,11 +254,6 @@ export interface Extension {
|
|||||||
readonly request: LLMRequest
|
readonly request: LLMRequest
|
||||||
}) => MediaInput | undefined
|
}) => MediaInput | undefined
|
||||||
readonly messagePhase?: (value: unknown) => MessagePhase | null | undefined
|
readonly messagePhase?: (value: unknown) => MessagePhase | null | undefined
|
||||||
readonly lowerProviderItem?: (
|
|
||||||
part: ToolResultPart,
|
|
||||||
providerMetadataKey: string,
|
|
||||||
store: boolean | undefined,
|
|
||||||
) => ProviderInputItem | undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE: Extension = { id: ADAPTER, name: NAME }
|
const BASE: Extension = { id: ADAPTER, name: NAME }
|
||||||
@@ -324,47 +310,35 @@ export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LL
|
|||||||
tool: (toolName) => ({ type: "function" as const, name: toolName }),
|
tool: (toolName) => ({ type: "function" as const, name: toolName }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const metadataItemID = (
|
const lowerToolCall = (part: ToolCallPart): OpenResponsesInputItem => ({
|
||||||
part: { readonly itemId?: string; readonly providerMetadata?: ProviderMetadata },
|
type: "function_call",
|
||||||
providerMetadataKey: string,
|
call_id: part.id,
|
||||||
) => {
|
name: part.name,
|
||||||
if (part.itemId) return part.itemId
|
arguments: ProviderShared.encodeJson(part.input),
|
||||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
})
|
||||||
return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
|
|
||||||
? metadata.itemId
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
|
|
||||||
const itemId = metadataItemID(part, providerMetadataKey)
|
|
||||||
return {
|
|
||||||
type: "function_call",
|
|
||||||
...(itemId === undefined ? {} : { id: itemId }),
|
|
||||||
call_id: part.id,
|
|
||||||
name: part.name,
|
|
||||||
arguments: ProviderShared.encodeJson(part.input),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
|
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
|
||||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||||
const itemId = metadataItemID(part, providerMetadataKey)
|
if (!ProviderShared.isRecord(metadata) || typeof metadata.itemId !== "string" || metadata.itemId.length === 0)
|
||||||
if (!itemId) return undefined
|
return undefined
|
||||||
const encryptedContent =
|
const encryptedContent =
|
||||||
ProviderShared.isRecord(metadata) &&
|
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
|
||||||
(typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null)
|
|
||||||
? metadata.reasoningEncryptedContent
|
? metadata.reasoningEncryptedContent
|
||||||
: undefined
|
: undefined
|
||||||
return {
|
return {
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
id: itemId,
|
id: metadata.itemId,
|
||||||
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
|
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
|
||||||
encrypted_content: encryptedContent,
|
encrypted_content: encryptedContent,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) =>
|
const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) => {
|
||||||
metadataItemID(part, providerMetadataKey)
|
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||||
|
return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
|
||||||
|
? metadata.itemId
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
|
||||||
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
|
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
|
||||||
part: MediaPart,
|
part: MediaPart,
|
||||||
@@ -423,18 +397,17 @@ const lowerToolResultOutput = Effect.fn("OpenResponses.lowerToolResultOutput")(f
|
|||||||
})
|
})
|
||||||
|
|
||||||
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
|
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
|
||||||
const options = OpenResponsesOptions.resolve(request)
|
|
||||||
const system: LoweredInputItem[] =
|
const system: LoweredInputItem[] =
|
||||||
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||||
const input: LoweredInputItem[] = [...system]
|
const input: LoweredInputItem[] = [...system]
|
||||||
const store = options.store
|
const store = OpenResponsesOptions.resolve(request).store
|
||||||
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
|
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
|
||||||
|
|
||||||
for (const message of request.messages) {
|
for (const message of request.messages) {
|
||||||
if (message.role === "system") {
|
if (message.role === "system") {
|
||||||
const part = yield* ProviderShared.wrappedSystemUpdate(extension.name, message)
|
const part = yield* ProviderShared.wrappedSystemUpdate(extension.name, message)
|
||||||
const previous = input.at(-1)
|
const previous = input.at(-1)
|
||||||
if (previous && "role" in previous && previous.role === "user" && Array.isArray(previous.content))
|
if (previous && "role" in previous && previous.role === "user")
|
||||||
input[input.length - 1] = {
|
input[input.length - 1] = {
|
||||||
role: "user",
|
role: "user",
|
||||||
content: [...previous.content, { type: "input_text", text: part.text }],
|
content: [...previous.content, { type: "input_text", text: part.text }],
|
||||||
@@ -454,24 +427,24 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
|||||||
if (message.role === "assistant") {
|
if (message.role === "assistant") {
|
||||||
const content: TextPart[] = []
|
const content: TextPart[] = []
|
||||||
const reasoningItems: Record<string, OpenResponsesReasoningReplay> = {}
|
const reasoningItems: Record<string, OpenResponsesReasoningReplay> = {}
|
||||||
const hostedToolItems = new Set<string>()
|
const reasoningReferences = new Set<string>()
|
||||||
|
const hostedToolReferences = new Set<string>()
|
||||||
const flushText = () => {
|
const flushText = () => {
|
||||||
if (content.length === 0) return
|
if (content.length === 0) return
|
||||||
const groups = content.reduce<
|
const groups = content.reduce<Array<{ phase: MessagePhase | null | undefined; parts: TextPart[] }>>(
|
||||||
Array<{ phase: MessagePhase | null | undefined; itemId: string | undefined; parts: TextPart[] }>
|
(groups, part) => {
|
||||||
>((groups, part) => {
|
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined
|
||||||
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined
|
const group = groups.at(-1)
|
||||||
const itemId = metadataItemID(part, providerMetadataKey)
|
if (group && group.phase === phase) group.parts.push(part)
|
||||||
const group = groups.at(-1)
|
else groups.push({ phase, parts: [part] })
|
||||||
if (group && group.phase === phase && group.itemId === itemId) group.parts.push(part)
|
return groups
|
||||||
else groups.push({ phase, itemId, parts: [part] })
|
},
|
||||||
return groups
|
[],
|
||||||
}, [])
|
)
|
||||||
input.push(
|
input.push(
|
||||||
...groups.map((group) => ({
|
...groups.map((group) => ({
|
||||||
role: "assistant" as const,
|
role: "assistant" as const,
|
||||||
...(group.itemId === undefined ? {} : { id: group.itemId }),
|
|
||||||
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
|
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
|
||||||
...(group.phase === undefined ? {} : { phase: group.phase }),
|
...(group.phase === undefined ? {} : { phase: group.phase }),
|
||||||
})),
|
})),
|
||||||
@@ -487,6 +460,11 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
|||||||
flushText()
|
flushText()
|
||||||
const reasoning = lowerReasoning(part, providerMetadataKey)
|
const reasoning = lowerReasoning(part, providerMetadataKey)
|
||||||
if (!reasoning) continue
|
if (!reasoning) continue
|
||||||
|
if (store !== false) {
|
||||||
|
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
|
||||||
|
reasoningReferences.add(reasoning.id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
const existing = reasoningItems[reasoning.id]
|
const existing = reasoningItems[reasoning.id]
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.summary.push(...reasoning.summary)
|
existing.summary.push(...reasoning.summary)
|
||||||
@@ -496,7 +474,6 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
|||||||
}
|
}
|
||||||
const replay = {
|
const replay = {
|
||||||
type: reasoning.type,
|
type: reasoning.type,
|
||||||
id: reasoning.id,
|
|
||||||
summary: reasoning.summary,
|
summary: reasoning.summary,
|
||||||
encrypted_content: reasoning.encrypted_content,
|
encrypted_content: reasoning.encrypted_content,
|
||||||
}
|
}
|
||||||
@@ -507,24 +484,22 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
|||||||
if (part.type === "tool-call") {
|
if (part.type === "tool-call") {
|
||||||
flushText()
|
flushText()
|
||||||
if (part.providerExecuted === true) continue
|
if (part.providerExecuted === true) continue
|
||||||
input.push(lowerToolCall(part, providerMetadataKey))
|
input.push(lowerToolCall(part))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (part.type === "tool-result" && part.providerExecuted === true) {
|
if (part.type === "tool-result" && part.providerExecuted === true) {
|
||||||
flushText()
|
flushText()
|
||||||
const itemID = hostedToolItemID(part, providerMetadataKey)
|
const itemID = hostedToolItemID(part, providerMetadataKey)
|
||||||
const providerItem = extension.lowerProviderItem?.(part, providerMetadataKey, store)
|
if (store !== false && itemID && !hostedToolReferences.has(itemID))
|
||||||
if (providerItem && itemID && !hostedToolItems.has(itemID)) input.push(providerItem)
|
|
||||||
if (!providerItem && store !== false && itemID && !hostedToolItems.has(itemID))
|
|
||||||
input.push({ type: "item_reference", id: itemID })
|
input.push({ type: "item_reference", id: itemID })
|
||||||
if (!providerItem && store === false && part.result.type === "content") {
|
if (store === false && part.result.type === "content") {
|
||||||
const content: ReadonlyArray<Content> = part.result.value
|
const content: ReadonlyArray<Content> = part.result.value
|
||||||
input.push({
|
input.push({
|
||||||
role: "user",
|
role: "user",
|
||||||
content: yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension)),
|
content: yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (itemID) hostedToolItems.add(itemID)
|
if (itemID) hostedToolReferences.add(itemID)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
|
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
|
||||||
@@ -666,9 +641,9 @@ const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepRe
|
|||||||
if (!event.delta) return [state, NO_EVENTS]
|
if (!event.delta) return [state, NO_EVENTS]
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
const phase = state.messagePhases[id]
|
const phase = state.messagePhases[id]
|
||||||
const metadata = providerMetadata(state, { itemId: id, ...(phase === undefined ? {} : { phase }) })
|
const metadata = phase === undefined ? undefined : providerMetadata(state, { phase })
|
||||||
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata, id)
|
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
|
||||||
return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta, metadata, id) }, events]
|
return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events]
|
||||||
}
|
}
|
||||||
|
|
||||||
const onOutputTextDone = (state: ParserState, event: Event, id: string): StepResult => {
|
const onOutputTextDone = (state: ParserState, event: Event, id: string): StepResult => {
|
||||||
@@ -677,13 +652,7 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
|
|||||||
return onOutputTextDelta(state, { ...event, delta: event.text }, id)
|
return onOutputTextDelta(state, { ...event, delta: event.text }, id)
|
||||||
}
|
}
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
return [
|
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
|
||||||
{
|
|
||||||
...state,
|
|
||||||
lifecycle: Lifecycle.textEnd(state.lifecycle, events, id, providerMetadata(state, { itemId: id }), id),
|
|
||||||
},
|
|
||||||
events,
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
|
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
|
||||||
@@ -694,14 +663,7 @@ export const onReasoningDelta = (state: ParserState, event: Event, itemID: strin
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
...state,
|
...state,
|
||||||
lifecycle: Lifecycle.reasoningDelta(
|
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta),
|
||||||
state.lifecycle,
|
|
||||||
events,
|
|
||||||
id,
|
|
||||||
event.delta,
|
|
||||||
providerMetadata(state, { itemId: itemID }),
|
|
||||||
itemID,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
events,
|
events,
|
||||||
]
|
]
|
||||||
@@ -743,13 +705,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
...state,
|
...state,
|
||||||
lifecycle: Lifecycle.reasoningStart(
|
lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(state, item)),
|
||||||
state.lifecycle,
|
|
||||||
events,
|
|
||||||
`${item.id}:0`,
|
|
||||||
reasoningMetadata(state, item),
|
|
||||||
item.id,
|
|
||||||
),
|
|
||||||
reasoningItems: {
|
reasoningItems: {
|
||||||
...state.reasoningItems,
|
...state.reasoningItems,
|
||||||
[item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } },
|
[item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } },
|
||||||
@@ -768,7 +724,6 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
|||||||
lifecycle,
|
lifecycle,
|
||||||
tools: ToolStream.start(state.tools, item.id, {
|
tools: ToolStream.start(state.tools, item.id, {
|
||||||
id: item.call_id ?? item.id,
|
id: item.call_id ?? item.id,
|
||||||
itemId: item.id,
|
|
||||||
name: item.name ?? "",
|
name: item.name ?? "",
|
||||||
input: item.arguments ?? "",
|
input: item.arguments ?? "",
|
||||||
providerMetadata: metadata,
|
providerMetadata: metadata,
|
||||||
@@ -776,12 +731,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
...events,
|
...events,
|
||||||
LLMEvent.toolInputStart({
|
LLMEvent.toolInputStart({ id: item.call_id ?? item.id, name: item.name ?? "", providerMetadata: metadata }),
|
||||||
id: item.call_id ?? item.id,
|
|
||||||
itemId: item.id,
|
|
||||||
name: item.name ?? "",
|
|
||||||
providerMetadata: metadata,
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -800,7 +750,6 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
|
|||||||
events,
|
events,
|
||||||
`${event.item_id}:0`,
|
`${event.item_id}:0`,
|
||||||
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: null }),
|
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: null }),
|
||||||
event.item_id,
|
|
||||||
),
|
),
|
||||||
reasoningItems: {
|
reasoningItems: {
|
||||||
...state.reasoningItems,
|
...state.reasoningItems,
|
||||||
@@ -821,7 +770,6 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
|
|||||||
events,
|
events,
|
||||||
`${event.item_id}:${entry[0]}`,
|
`${event.item_id}:${entry[0]}`,
|
||||||
providerMetadata(state, { itemId: event.item_id }),
|
providerMetadata(state, { itemId: event.item_id }),
|
||||||
event.item_id,
|
|
||||||
),
|
),
|
||||||
state.lifecycle,
|
state.lifecycle,
|
||||||
)
|
)
|
||||||
@@ -833,7 +781,6 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
|
|||||||
events,
|
events,
|
||||||
`${event.item_id}:${event.summary_index}`,
|
`${event.item_id}:${event.summary_index}`,
|
||||||
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }),
|
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }),
|
||||||
event.item_id,
|
|
||||||
),
|
),
|
||||||
reasoningItems: {
|
reasoningItems: {
|
||||||
...state.reasoningItems,
|
...state.reasoningItems,
|
||||||
@@ -869,7 +816,6 @@ const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResul
|
|||||||
events,
|
events,
|
||||||
`${event.item_id}:${event.summary_index}`,
|
`${event.item_id}:${event.summary_index}`,
|
||||||
providerMetadata(state, { itemId: event.item_id }),
|
providerMetadata(state, { itemId: event.item_id }),
|
||||||
event.item_id,
|
|
||||||
)
|
)
|
||||||
: state.lifecycle,
|
: state.lifecycle,
|
||||||
reasoningItems: {
|
reasoningItems: {
|
||||||
@@ -924,8 +870,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
|||||||
state.lifecycle,
|
state.lifecycle,
|
||||||
events,
|
events,
|
||||||
item.id,
|
item.id,
|
||||||
providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) }),
|
phase === undefined ? undefined : providerMetadata(state, { phase }),
|
||||||
item.id,
|
|
||||||
),
|
),
|
||||||
messageItems,
|
messageItems,
|
||||||
messagePhases,
|
messagePhases,
|
||||||
@@ -936,15 +881,9 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
|||||||
|
|
||||||
if (item.type === "function_call") {
|
if (item.type === "function_call") {
|
||||||
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
|
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
|
||||||
const metadata = providerMetadata(state, { itemId: item.id })
|
|
||||||
const tools = state.tools[item.id]
|
const tools = state.tools[item.id]
|
||||||
? state.tools
|
? state.tools
|
||||||
: ToolStream.start(state.tools, item.id, {
|
: ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name })
|
||||||
id: item.call_id,
|
|
||||||
itemId: item.id,
|
|
||||||
name: item.name,
|
|
||||||
providerMetadata: metadata,
|
|
||||||
})
|
|
||||||
const result =
|
const result =
|
||||||
item.arguments === undefined
|
item.arguments === undefined
|
||||||
? yield* ToolStream.finish(state.id, tools, item.id)
|
? yield* ToolStream.finish(state.id, tools, item.id)
|
||||||
@@ -974,7 +913,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
|||||||
const lifecycle = Object.entries(reasoningItem.summaryParts)
|
const lifecycle = Object.entries(reasoningItem.summaryParts)
|
||||||
.filter((entry) => entry[1] === "active" || entry[1] === "can-conclude")
|
.filter((entry) => entry[1] === "active" || entry[1] === "can-conclude")
|
||||||
.reduce(
|
.reduce(
|
||||||
(lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata, item.id),
|
(lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata),
|
||||||
state.lifecycle,
|
state.lifecycle,
|
||||||
)
|
)
|
||||||
const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems
|
const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems
|
||||||
@@ -982,12 +921,12 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
|||||||
}
|
}
|
||||||
if (!state.lifecycle.reasoning.has(item.id)) {
|
if (!state.lifecycle.reasoning.has(item.id)) {
|
||||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||||
events.push(LLMEvent.reasoningStart({ id: item.id, itemId: item.id, providerMetadata: metadata }))
|
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
|
||||||
events.push(LLMEvent.reasoningEnd({ id: item.id, itemId: item.id, providerMetadata: metadata }))
|
events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata }))
|
||||||
return [{ ...state, lifecycle }, events] satisfies StepResult
|
return [{ ...state, lifecycle }, events] satisfies StepResult
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata, item.id) },
|
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) },
|
||||||
events,
|
events,
|
||||||
] satisfies StepResult
|
] satisfies StepResult
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,14 +38,10 @@ const OpenAIResponsesToolChoice = Schema.Union([
|
|||||||
const OpenAIResponsesInputItem = Schema.Union([
|
const OpenAIResponsesInputItem = Schema.Union([
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
role: Schema.tag("assistant"),
|
role: Schema.tag("assistant"),
|
||||||
id: Schema.optionalKey(Schema.String),
|
|
||||||
content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })),
|
content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })),
|
||||||
phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)),
|
phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)),
|
||||||
}),
|
}),
|
||||||
OpenResponses.InputItem,
|
OpenResponses.InputItem,
|
||||||
Schema.StructWithRest(Schema.Struct({ type: Schema.String, id: Schema.optionalKey(Schema.String) }), [
|
|
||||||
Schema.Record(Schema.String, Schema.Unknown),
|
|
||||||
]),
|
|
||||||
])
|
])
|
||||||
|
|
||||||
const OpenAIResponsesCoreFields = {
|
const OpenAIResponsesCoreFields = {
|
||||||
@@ -84,25 +80,6 @@ const extension = {
|
|||||||
mime_type: media.mime,
|
mime_type: media.mime,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
lowerProviderItem: (part, providerMetadataKey, store) => {
|
|
||||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
|
||||||
if (!ProviderShared.isRecord(metadata) || !ProviderShared.isRecord(metadata.item)) return undefined
|
|
||||||
if (typeof metadata.item.type !== "string") return undefined
|
|
||||||
const id = typeof metadata.item.id === "string" ? metadata.item.id : undefined
|
|
||||||
// The public API requires stored state to replay image-generation items. In
|
|
||||||
// stateless mode, lower the generated file through the existing user-image fallback.
|
|
||||||
if (metadata.item.type === "image_generation_call" && store === false) return undefined
|
|
||||||
if (metadata.item.type === "image_generation_call")
|
|
||||||
return {
|
|
||||||
type: metadata.item.type,
|
|
||||||
...(id === undefined ? {} : { id }),
|
|
||||||
...(typeof metadata.item.status === "string" ? { status: metadata.item.status } : {}),
|
|
||||||
...(typeof metadata.item.revised_prompt === "string" ? { revised_prompt: metadata.item.revised_prompt } : {}),
|
|
||||||
...(typeof metadata.item.result === "string" ? { result: metadata.item.result } : {}),
|
|
||||||
}
|
|
||||||
const item: Record<string, unknown> & { type: string } = { ...metadata.item, type: metadata.item.type }
|
|
||||||
return item
|
|
||||||
},
|
|
||||||
} satisfies OpenResponses.Extension
|
} satisfies OpenResponses.Extension
|
||||||
|
|
||||||
const nativeImageToolInput = (tool: ToolDefinition) => {
|
const nativeImageToolInput = (tool: ToolDefinition) => {
|
||||||
@@ -218,29 +195,23 @@ const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function*
|
|||||||
item: HostedToolItem,
|
item: HostedToolItem,
|
||||||
) {
|
) {
|
||||||
const tool = HOSTED_TOOLS[item.type]
|
const tool = HOSTED_TOOLS[item.type]
|
||||||
const callMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
|
const providerMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
|
||||||
const resultMetadata = OpenResponses.providerMetadata(
|
|
||||||
state,
|
|
||||||
item.type === "image_generation_call" ? { itemId: item.id } : { itemId: item.id, item },
|
|
||||||
)
|
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||||
events.push(
|
events.push(
|
||||||
LLMEvent.toolCall({
|
LLMEvent.toolCall({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
itemId: item.id,
|
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
input: tool.input(item),
|
input: tool.input(item),
|
||||||
providerExecuted: true,
|
providerExecuted: true,
|
||||||
providerMetadata: callMetadata,
|
providerMetadata,
|
||||||
}),
|
}),
|
||||||
LLMEvent.toolResult({
|
LLMEvent.toolResult({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
itemId: item.id,
|
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
result: yield* hostedToolResult(item),
|
result: yield* hostedToolResult(item),
|
||||||
providerExecuted: true,
|
providerExecuted: true,
|
||||||
providerMetadata: resultMetadata,
|
providerMetadata,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
|
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
|
||||||
|
|||||||
@@ -61,57 +61,37 @@ const emptyObjectSchema = (schema: Record<string, unknown>) =>
|
|||||||
(!isRecord(schema.properties) || Object.keys(schema.properties).length === 0) &&
|
(!isRecord(schema.properties) || Object.keys(schema.properties).length === 0) &&
|
||||||
!schema.additionalProperties
|
!schema.additionalProperties
|
||||||
|
|
||||||
const projectNode = (schema: unknown, nested = false): Record<string, unknown> | undefined => {
|
const projectNode = (schema: unknown): Record<string, unknown> | undefined => {
|
||||||
if (!isRecord(schema)) return undefined
|
if (!isRecord(schema)) return undefined
|
||||||
if (!nested && emptyObjectSchema(schema)) return undefined
|
if (emptyObjectSchema(schema)) return undefined
|
||||||
const types = Array.isArray(schema.type) ? schema.type.filter((type) => type !== "null") : undefined
|
return Object.fromEntries(
|
||||||
const anyOf = Array.isArray(schema.anyOf) ? schema.anyOf : undefined
|
|
||||||
const hasNullAnyOf = anyOf?.some((item) => isRecord(item) && item.type === "null") ?? false
|
|
||||||
const anyOfTypes = hasNullAnyOf ? anyOf?.filter((item) => !isRecord(item) || item.type !== "null") : anyOf
|
|
||||||
const flattenedAnyOf = hasNullAnyOf && anyOfTypes?.length === 1 ? projectNode(anyOfTypes[0], true) : undefined
|
|
||||||
const result = Object.fromEntries(
|
|
||||||
[
|
[
|
||||||
["description", schema.description],
|
["description", schema.description],
|
||||||
["required", schema.required],
|
["required", schema.required],
|
||||||
["format", schema.format],
|
["format", schema.format],
|
||||||
["type", types ? (types.length === 0 ? "null" : undefined) : schema.type],
|
["type", Array.isArray(schema.type) ? schema.type.filter((type) => type !== "null")[0] : schema.type],
|
||||||
[
|
["nullable", Array.isArray(schema.type) && schema.type.includes("null") ? true : undefined],
|
||||||
"nullable",
|
|
||||||
(Array.isArray(schema.type) && schema.type.includes("null") && types && types.length > 0) || hasNullAnyOf
|
|
||||||
? true
|
|
||||||
: undefined,
|
|
||||||
],
|
|
||||||
["enum", schema.const !== undefined ? [schema.const] : schema.enum],
|
["enum", schema.const !== undefined ? [schema.const] : schema.enum],
|
||||||
[
|
[
|
||||||
"properties",
|
"properties",
|
||||||
isRecord(schema.properties)
|
isRecord(schema.properties)
|
||||||
? Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => [key, projectNode(value, true)]))
|
? Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => [key, projectNode(value)]))
|
||||||
: undefined,
|
: undefined,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"items",
|
"items",
|
||||||
Array.isArray(schema.items)
|
Array.isArray(schema.items)
|
||||||
? schema.items.map((item) => projectNode(item, true))
|
? schema.items.map(projectNode)
|
||||||
: schema.items === undefined
|
: schema.items === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: projectNode(schema.items, true),
|
: projectNode(schema.items),
|
||||||
],
|
],
|
||||||
["allOf", Array.isArray(schema.allOf) ? schema.allOf.map((item) => projectNode(item, true)) : undefined],
|
["allOf", Array.isArray(schema.allOf) ? schema.allOf.map(projectNode) : undefined],
|
||||||
[
|
["anyOf", Array.isArray(schema.anyOf) ? schema.anyOf.map(projectNode) : undefined],
|
||||||
"anyOf",
|
["oneOf", Array.isArray(schema.oneOf) ? schema.oneOf.map(projectNode) : undefined],
|
||||||
anyOfTypes
|
|
||||||
? hasNullAnyOf && anyOfTypes.length === 1
|
|
||||||
? undefined
|
|
||||||
: anyOfTypes.map((item) => projectNode(item, true))
|
|
||||||
: types && types.length > 0
|
|
||||||
? types.map((type) => ({ type }))
|
|
||||||
: undefined,
|
|
||||||
],
|
|
||||||
["oneOf", Array.isArray(schema.oneOf) ? schema.oneOf.map((item) => projectNode(item, true)) : undefined],
|
|
||||||
["minLength", schema.minLength],
|
["minLength", schema.minLength],
|
||||||
].filter((entry) => entry[1] !== undefined),
|
].filter((entry) => entry[1] !== undefined),
|
||||||
)
|
)
|
||||||
return flattenedAnyOf ? { ...result, ...flattenedAnyOf } : result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const convert = (schema: unknown) => projectNode(sanitizeNode(schema))
|
export const convert = (schema: unknown) => projectNode(sanitizeNode(schema))
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
import {
|
import { LLMEvent, type FinishReasonDetails, type ProviderMetadata, type Usage } from "../../schema"
|
||||||
LLMEvent,
|
|
||||||
type FinishReasonDetails,
|
|
||||||
type ProviderMetadata,
|
|
||||||
type ResponseItemID,
|
|
||||||
type Usage,
|
|
||||||
} from "../../schema"
|
|
||||||
|
|
||||||
export interface State {
|
export interface State {
|
||||||
readonly stepStarted: boolean
|
readonly stepStarted: boolean
|
||||||
@@ -20,29 +14,16 @@ export const stepStart = (state: State, events: LLMEvent[]): State => {
|
|||||||
return { ...state, stepStarted: true }
|
return { ...state, stepStarted: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const textStart = (
|
export const textStart = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
|
||||||
state: State,
|
|
||||||
events: LLMEvent[],
|
|
||||||
id: string,
|
|
||||||
providerMetadata?: ProviderMetadata,
|
|
||||||
itemId?: ResponseItemID,
|
|
||||||
): State => {
|
|
||||||
if (state.text.has(id)) return state
|
if (state.text.has(id)) return state
|
||||||
const stepped = stepStart(state, events)
|
const stepped = stepStart(state, events)
|
||||||
events.push(LLMEvent.textStart({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
|
events.push(LLMEvent.textStart({ id, providerMetadata }))
|
||||||
return { ...stepped, text: new Set([...stepped.text, id]) }
|
return { ...stepped, text: new Set([...stepped.text, id]) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const textDelta = (
|
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
|
||||||
state: State,
|
const started = textStart(state, events, id)
|
||||||
events: LLMEvent[],
|
events.push(LLMEvent.textDelta({ id, text }))
|
||||||
id: string,
|
|
||||||
text: string,
|
|
||||||
providerMetadata?: ProviderMetadata,
|
|
||||||
itemId?: ResponseItemID,
|
|
||||||
): State => {
|
|
||||||
const started = textStart(state, events, id, providerMetadata, itemId)
|
|
||||||
events.push(LLMEvent.textDelta({ id, ...(itemId === undefined ? {} : { itemId }), text, providerMetadata }))
|
|
||||||
return started
|
return started
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,11 +32,10 @@ export const reasoningStart = (
|
|||||||
events: LLMEvent[],
|
events: LLMEvent[],
|
||||||
id: string,
|
id: string,
|
||||||
providerMetadata?: ProviderMetadata,
|
providerMetadata?: ProviderMetadata,
|
||||||
itemId?: ResponseItemID,
|
|
||||||
): State => {
|
): State => {
|
||||||
if (state.reasoning.has(id)) return state
|
if (state.reasoning.has(id)) return state
|
||||||
const stepped = stepStart(state, events)
|
const stepped = stepStart(state, events)
|
||||||
events.push(LLMEvent.reasoningStart({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
|
events.push(LLMEvent.reasoningStart({ id, providerMetadata }))
|
||||||
return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) }
|
return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,10 +45,9 @@ export const reasoningDelta = (
|
|||||||
id: string,
|
id: string,
|
||||||
text: string,
|
text: string,
|
||||||
providerMetadata?: ProviderMetadata,
|
providerMetadata?: ProviderMetadata,
|
||||||
itemId?: ResponseItemID,
|
|
||||||
): State => {
|
): State => {
|
||||||
const started = reasoningStart(state, events, id, providerMetadata, itemId)
|
const started = reasoningStart(state, events, id, providerMetadata)
|
||||||
events.push(LLMEvent.reasoningDelta({ id, ...(itemId === undefined ? {} : { itemId }), text, providerMetadata }))
|
events.push(LLMEvent.reasoningDelta({ id, text, providerMetadata }))
|
||||||
return started
|
return started
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,26 +56,19 @@ export const reasoningEnd = (
|
|||||||
events: LLMEvent[],
|
events: LLMEvent[],
|
||||||
id: string,
|
id: string,
|
||||||
providerMetadata?: ProviderMetadata,
|
providerMetadata?: ProviderMetadata,
|
||||||
itemId?: ResponseItemID,
|
|
||||||
): State => {
|
): State => {
|
||||||
if (!state.reasoning.has(id)) return state
|
if (!state.reasoning.has(id)) return state
|
||||||
const stepped = stepStart(state, events)
|
const stepped = stepStart(state, events)
|
||||||
events.push(LLMEvent.reasoningEnd({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
|
events.push(LLMEvent.reasoningEnd({ id, providerMetadata }))
|
||||||
const reasoning = new Set(stepped.reasoning)
|
const reasoning = new Set(stepped.reasoning)
|
||||||
reasoning.delete(id)
|
reasoning.delete(id)
|
||||||
return { ...stepped, reasoning }
|
return { ...stepped, reasoning }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const textEnd = (
|
export const textEnd = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
|
||||||
state: State,
|
|
||||||
events: LLMEvent[],
|
|
||||||
id: string,
|
|
||||||
providerMetadata?: ProviderMetadata,
|
|
||||||
itemId?: ResponseItemID,
|
|
||||||
): State => {
|
|
||||||
if (!state.text.has(id)) return state
|
if (!state.text.has(id)) return state
|
||||||
const stepped = stepStart(state, events)
|
const stepped = stepStart(state, events)
|
||||||
events.push(LLMEvent.textEnd({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
|
events.push(LLMEvent.textEnd({ id, providerMetadata }))
|
||||||
const text = new Set(stepped.text)
|
const text = new Set(stepped.text)
|
||||||
text.delete(id)
|
text.delete(id)
|
||||||
return { ...stepped, text }
|
return { ...stepped, text }
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import {
|
import { AIError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema"
|
||||||
AIError,
|
|
||||||
LLMEvent,
|
|
||||||
type ProviderMetadata,
|
|
||||||
type ResponseItemID,
|
|
||||||
type ToolCall,
|
|
||||||
type ToolInputError,
|
|
||||||
} from "../../schema"
|
|
||||||
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
|
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
|
||||||
|
|
||||||
type StreamKey = string | number
|
type StreamKey = string | number
|
||||||
@@ -17,7 +10,6 @@ type StreamKey = string | number
|
|||||||
* so far, not the parsed object.
|
* so far, not the parsed object.
|
||||||
*/
|
*/
|
||||||
export interface PendingTool extends ToolAccumulator {
|
export interface PendingTool extends ToolAccumulator {
|
||||||
readonly itemId?: ResponseItemID
|
|
||||||
readonly providerExecuted?: boolean
|
readonly providerExecuted?: boolean
|
||||||
readonly providerMetadata?: ProviderMetadata
|
readonly providerMetadata?: ProviderMetadata
|
||||||
}
|
}
|
||||||
@@ -60,7 +52,6 @@ const withoutTool = <K extends StreamKey>(tools: State<K>, key: K): State<K> =>
|
|||||||
const inputStart = (tool: PendingTool) =>
|
const inputStart = (tool: PendingTool) =>
|
||||||
LLMEvent.toolInputStart({
|
LLMEvent.toolInputStart({
|
||||||
id: tool.id,
|
id: tool.id,
|
||||||
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
|
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
providerExecuted: tool.providerExecuted ? true : undefined,
|
providerExecuted: tool.providerExecuted ? true : undefined,
|
||||||
providerMetadata: tool.providerMetadata,
|
providerMetadata: tool.providerMetadata,
|
||||||
@@ -69,7 +60,6 @@ const inputStart = (tool: PendingTool) =>
|
|||||||
const inputDelta = (tool: PendingTool, text: string) =>
|
const inputDelta = (tool: PendingTool, text: string) =>
|
||||||
LLMEvent.toolInputDelta({
|
LLMEvent.toolInputDelta({
|
||||||
id: tool.id,
|
id: tool.id,
|
||||||
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
|
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
text,
|
text,
|
||||||
})
|
})
|
||||||
@@ -80,7 +70,6 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
|
|||||||
Effect.map((input): ToolCall | ToolInputError =>
|
Effect.map((input): ToolCall | ToolInputError =>
|
||||||
LLMEvent.toolCall({
|
LLMEvent.toolCall({
|
||||||
id: tool.id,
|
id: tool.id,
|
||||||
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
|
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
input,
|
input,
|
||||||
providerExecuted: tool.providerExecuted ? true : undefined,
|
providerExecuted: tool.providerExecuted ? true : undefined,
|
||||||
@@ -93,7 +82,6 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
|
|||||||
: Effect.succeed(
|
: Effect.succeed(
|
||||||
LLMEvent.toolInputError({
|
LLMEvent.toolInputError({
|
||||||
id: tool.id,
|
id: tool.id,
|
||||||
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
|
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
raw,
|
raw,
|
||||||
}),
|
}),
|
||||||
@@ -105,15 +93,7 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
|
|||||||
const finishEvents = (tool: PendingTool, event: ToolCall | ToolInputError): ReadonlyArray<LLMEvent> =>
|
const finishEvents = (tool: PendingTool, event: ToolCall | ToolInputError): ReadonlyArray<LLMEvent> =>
|
||||||
event.type === "tool-input-error"
|
event.type === "tool-input-error"
|
||||||
? [event]
|
? [event]
|
||||||
: [
|
: [LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), event]
|
||||||
LLMEvent.toolInputEnd({
|
|
||||||
id: tool.id,
|
|
||||||
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
|
|
||||||
name: tool.name,
|
|
||||||
providerMetadata: tool.providerMetadata,
|
|
||||||
}),
|
|
||||||
event,
|
|
||||||
]
|
|
||||||
|
|
||||||
/** Store the updated tool and produce the optional public delta event. */
|
/** Store the updated tool and produce the optional public delta event. */
|
||||||
const appendTool = <K extends StreamKey>(
|
const appendTool = <K extends StreamKey>(
|
||||||
@@ -168,7 +148,6 @@ export const appendOrStart = <K extends StreamKey>(
|
|||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
input: `${current?.input ?? ""}${delta.text}`,
|
input: `${current?.input ?? ""}${delta.text}`,
|
||||||
itemId: current?.itemId,
|
|
||||||
providerExecuted: current?.providerExecuted,
|
providerExecuted: current?.providerExecuted,
|
||||||
providerMetadata: current?.providerMetadata,
|
providerMetadata: current?.providerMetadata,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { ContentBlockID, FinishReason, ProviderMetadata, ResponseItemID, ToolCallID } from "./ids"
|
import { ContentBlockID, FinishReason, ProviderMetadata, ToolCallID } from "./ids"
|
||||||
import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages"
|
import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages"
|
||||||
import { ProviderFailureClassification } from "./errors"
|
import { ProviderFailureClassification } from "./errors"
|
||||||
|
|
||||||
@@ -84,7 +84,6 @@ export type StepStart = Schema.Schema.Type<typeof StepStart>
|
|||||||
export const TextStart = Schema.Struct({
|
export const TextStart = Schema.Struct({
|
||||||
type: Schema.tag("text-start"),
|
type: Schema.tag("text-start"),
|
||||||
id: ContentBlockID,
|
id: ContentBlockID,
|
||||||
itemId: Schema.optional(ResponseItemID),
|
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
}).annotate({ identifier: "LLM.Event.TextStart" })
|
}).annotate({ identifier: "LLM.Event.TextStart" })
|
||||||
export type TextStart = Schema.Schema.Type<typeof TextStart>
|
export type TextStart = Schema.Schema.Type<typeof TextStart>
|
||||||
@@ -93,7 +92,6 @@ export const TextDelta = Schema.Struct({
|
|||||||
type: Schema.tag("text-delta"),
|
type: Schema.tag("text-delta"),
|
||||||
id: ContentBlockID,
|
id: ContentBlockID,
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
itemId: Schema.optional(ResponseItemID),
|
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
}).annotate({ identifier: "LLM.Event.TextDelta" })
|
}).annotate({ identifier: "LLM.Event.TextDelta" })
|
||||||
export type TextDelta = Schema.Schema.Type<typeof TextDelta>
|
export type TextDelta = Schema.Schema.Type<typeof TextDelta>
|
||||||
@@ -101,7 +99,6 @@ export type TextDelta = Schema.Schema.Type<typeof TextDelta>
|
|||||||
export const TextEnd = Schema.Struct({
|
export const TextEnd = Schema.Struct({
|
||||||
type: Schema.tag("text-end"),
|
type: Schema.tag("text-end"),
|
||||||
id: ContentBlockID,
|
id: ContentBlockID,
|
||||||
itemId: Schema.optional(ResponseItemID),
|
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
}).annotate({ identifier: "LLM.Event.TextEnd" })
|
}).annotate({ identifier: "LLM.Event.TextEnd" })
|
||||||
export type TextEnd = Schema.Schema.Type<typeof TextEnd>
|
export type TextEnd = Schema.Schema.Type<typeof TextEnd>
|
||||||
@@ -109,7 +106,6 @@ export type TextEnd = Schema.Schema.Type<typeof TextEnd>
|
|||||||
export const ReasoningStart = Schema.Struct({
|
export const ReasoningStart = Schema.Struct({
|
||||||
type: Schema.tag("reasoning-start"),
|
type: Schema.tag("reasoning-start"),
|
||||||
id: ContentBlockID,
|
id: ContentBlockID,
|
||||||
itemId: Schema.optional(ResponseItemID),
|
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
}).annotate({ identifier: "LLM.Event.ReasoningStart" })
|
}).annotate({ identifier: "LLM.Event.ReasoningStart" })
|
||||||
export type ReasoningStart = Schema.Schema.Type<typeof ReasoningStart>
|
export type ReasoningStart = Schema.Schema.Type<typeof ReasoningStart>
|
||||||
@@ -118,7 +114,6 @@ export const ReasoningDelta = Schema.Struct({
|
|||||||
type: Schema.tag("reasoning-delta"),
|
type: Schema.tag("reasoning-delta"),
|
||||||
id: ContentBlockID,
|
id: ContentBlockID,
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
itemId: Schema.optional(ResponseItemID),
|
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
}).annotate({ identifier: "LLM.Event.ReasoningDelta" })
|
}).annotate({ identifier: "LLM.Event.ReasoningDelta" })
|
||||||
export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
|
export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
|
||||||
@@ -126,7 +121,6 @@ export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
|
|||||||
export const ReasoningEnd = Schema.Struct({
|
export const ReasoningEnd = Schema.Struct({
|
||||||
type: Schema.tag("reasoning-end"),
|
type: Schema.tag("reasoning-end"),
|
||||||
id: ContentBlockID,
|
id: ContentBlockID,
|
||||||
itemId: Schema.optional(ResponseItemID),
|
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
}).annotate({ identifier: "LLM.Event.ReasoningEnd" })
|
}).annotate({ identifier: "LLM.Event.ReasoningEnd" })
|
||||||
export type ReasoningEnd = Schema.Schema.Type<typeof ReasoningEnd>
|
export type ReasoningEnd = Schema.Schema.Type<typeof ReasoningEnd>
|
||||||
@@ -135,7 +129,6 @@ export const ToolInputStart = Schema.Struct({
|
|||||||
type: Schema.tag("tool-input-start"),
|
type: Schema.tag("tool-input-start"),
|
||||||
id: ToolCallID,
|
id: ToolCallID,
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
itemId: Schema.optional(ResponseItemID),
|
|
||||||
providerExecuted: Schema.optional(Schema.Boolean),
|
providerExecuted: Schema.optional(Schema.Boolean),
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
}).annotate({ identifier: "LLM.Event.ToolInputStart" })
|
}).annotate({ identifier: "LLM.Event.ToolInputStart" })
|
||||||
@@ -144,7 +137,6 @@ export type ToolInputStart = Schema.Schema.Type<typeof ToolInputStart>
|
|||||||
export const ToolInputDelta = Schema.Struct({
|
export const ToolInputDelta = Schema.Struct({
|
||||||
type: Schema.tag("tool-input-delta"),
|
type: Schema.tag("tool-input-delta"),
|
||||||
id: ToolCallID,
|
id: ToolCallID,
|
||||||
itemId: Schema.optional(ResponseItemID),
|
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
}).annotate({ identifier: "LLM.Event.ToolInputDelta" })
|
}).annotate({ identifier: "LLM.Event.ToolInputDelta" })
|
||||||
@@ -154,7 +146,6 @@ export const ToolInputEnd = Schema.Struct({
|
|||||||
type: Schema.tag("tool-input-end"),
|
type: Schema.tag("tool-input-end"),
|
||||||
id: ToolCallID,
|
id: ToolCallID,
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
itemId: Schema.optional(ResponseItemID),
|
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
}).annotate({ identifier: "LLM.Event.ToolInputEnd" })
|
}).annotate({ identifier: "LLM.Event.ToolInputEnd" })
|
||||||
export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
|
export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
|
||||||
@@ -163,7 +154,6 @@ export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
|
|||||||
export const ToolInputError = Schema.Struct({
|
export const ToolInputError = Schema.Struct({
|
||||||
type: Schema.tag("tool-input-error"),
|
type: Schema.tag("tool-input-error"),
|
||||||
id: ToolCallID,
|
id: ToolCallID,
|
||||||
itemId: Schema.optional(ResponseItemID),
|
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
raw: Schema.String,
|
raw: Schema.String,
|
||||||
}).annotate({ identifier: "LLM.Event.ToolInputError" })
|
}).annotate({ identifier: "LLM.Event.ToolInputError" })
|
||||||
@@ -172,7 +162,6 @@ export type ToolInputError = Schema.Schema.Type<typeof ToolInputError>
|
|||||||
export const ToolCall = Schema.Struct({
|
export const ToolCall = Schema.Struct({
|
||||||
type: Schema.tag("tool-call"),
|
type: Schema.tag("tool-call"),
|
||||||
id: ToolCallID,
|
id: ToolCallID,
|
||||||
itemId: Schema.optional(ResponseItemID),
|
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
input: Schema.Unknown,
|
input: Schema.Unknown,
|
||||||
providerExecuted: Schema.optional(Schema.Boolean),
|
providerExecuted: Schema.optional(Schema.Boolean),
|
||||||
@@ -183,7 +172,6 @@ export type ToolCall = Schema.Schema.Type<typeof ToolCall>
|
|||||||
export const ToolResult = Schema.Struct({
|
export const ToolResult = Schema.Struct({
|
||||||
type: Schema.tag("tool-result"),
|
type: Schema.tag("tool-result"),
|
||||||
id: ToolCallID,
|
id: ToolCallID,
|
||||||
itemId: Schema.optional(ResponseItemID),
|
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
result: ToolResultValue,
|
result: ToolResultValue,
|
||||||
output: Schema.optional(ToolOutput),
|
output: Schema.optional(ToolOutput),
|
||||||
@@ -195,7 +183,6 @@ export type ToolResult = Schema.Schema.Type<typeof ToolResult>
|
|||||||
export const ToolError = Schema.Struct({
|
export const ToolError = Schema.Struct({
|
||||||
type: Schema.tag("tool-error"),
|
type: Schema.tag("tool-error"),
|
||||||
id: ToolCallID,
|
id: ToolCallID,
|
||||||
itemId: Schema.optional(ResponseItemID),
|
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
error: Schema.optional(Schema.Defect()),
|
error: Schema.optional(Schema.Defect()),
|
||||||
@@ -347,14 +334,12 @@ const responseUsage = (events: ReadonlyArray<LLMEvent>) =>
|
|||||||
interface ContentAssembly {
|
interface ContentAssembly {
|
||||||
readonly contentIndex: number
|
readonly contentIndex: number
|
||||||
readonly text: string
|
readonly text: string
|
||||||
readonly itemId?: ResponseItemID
|
|
||||||
readonly providerMetadata?: ProviderMetadata
|
readonly providerMetadata?: ProviderMetadata
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToolInputAssembly {
|
interface ToolInputAssembly {
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly text: string
|
readonly text: string
|
||||||
readonly itemId?: ResponseItemID
|
|
||||||
readonly providerMetadata?: ProviderMetadata
|
readonly providerMetadata?: ProviderMetadata
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,27 +385,11 @@ const appendEvent = (state: ResponseState, event: LLMEvent): ResponseState => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const textContent = (
|
const textContent = (text: string, providerMetadata: ProviderMetadata | undefined): ContentPart =>
|
||||||
text: string,
|
providerMetadata === undefined ? { type: "text", text } : { type: "text", text, providerMetadata }
|
||||||
itemId: ResponseItemID | undefined,
|
|
||||||
providerMetadata: ProviderMetadata | undefined,
|
|
||||||
): ContentPart => ({
|
|
||||||
type: "text",
|
|
||||||
text,
|
|
||||||
...(itemId === undefined ? {} : { itemId }),
|
|
||||||
...(providerMetadata === undefined ? {} : { providerMetadata }),
|
|
||||||
})
|
|
||||||
|
|
||||||
const reasoningContent = (
|
const reasoningContent = (text: string, providerMetadata: ProviderMetadata | undefined): ContentPart =>
|
||||||
text: string,
|
providerMetadata === undefined ? { type: "reasoning", text } : { type: "reasoning", text, providerMetadata }
|
||||||
itemId: ResponseItemID | undefined,
|
|
||||||
providerMetadata: ProviderMetadata | undefined,
|
|
||||||
): ContentPart => ({
|
|
||||||
type: "reasoning",
|
|
||||||
text,
|
|
||||||
...(itemId === undefined ? {} : { itemId }),
|
|
||||||
...(providerMetadata === undefined ? {} : { providerMetadata }),
|
|
||||||
})
|
|
||||||
|
|
||||||
const contentWith = (state: ResponseState, content: ReadonlyArray<ContentPart>): ResponseState => ({
|
const contentWith = (state: ResponseState, content: ReadonlyArray<ContentPart>): ResponseState => ({
|
||||||
...state,
|
...state,
|
||||||
@@ -435,32 +404,26 @@ const replaceContent = (state: ResponseState, index: number, part: ContentPart)
|
|||||||
state.message.content.map((item, itemIndex) => (itemIndex === index ? part : item)),
|
state.message.content.map((item, itemIndex) => (itemIndex === index ? part : item)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const ensureText = (
|
const ensureText = (state: ResponseState, id: string, providerMetadata?: ProviderMetadata): ResponseState => {
|
||||||
state: ResponseState,
|
|
||||||
id: string,
|
|
||||||
itemId?: ResponseItemID,
|
|
||||||
providerMetadata?: ProviderMetadata,
|
|
||||||
): ResponseState => {
|
|
||||||
if (state.textParts[id]) return state
|
if (state.textParts[id]) return state
|
||||||
return {
|
return {
|
||||||
...appendContent(state, textContent("", itemId, providerMetadata)),
|
...appendContent(state, textContent("", providerMetadata)),
|
||||||
textParts: {
|
textParts: {
|
||||||
...state.textParts,
|
...state.textParts,
|
||||||
[id]: { contentIndex: state.message.content.length, text: "", itemId, providerMetadata },
|
[id]: { contentIndex: state.message.content.length, text: "", providerMetadata },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const reduceTextDelta = (state: ResponseState, event: TextDelta): ResponseState => {
|
const reduceTextDelta = (state: ResponseState, event: TextDelta): ResponseState => {
|
||||||
const started = ensureText(state, event.id, event.itemId, event.providerMetadata)
|
const started = ensureText(state, event.id, event.providerMetadata)
|
||||||
const current = started.textParts[event.id]
|
const current = started.textParts[event.id]
|
||||||
if (!current) return started
|
if (!current) return started
|
||||||
const text = current.text + event.text
|
const text = current.text + event.text
|
||||||
const itemId = event.itemId ?? current.itemId
|
|
||||||
const providerMetadata = event.providerMetadata ?? current.providerMetadata
|
const providerMetadata = event.providerMetadata ?? current.providerMetadata
|
||||||
return {
|
return {
|
||||||
...replaceContent(started, current.contentIndex, textContent(text, itemId, providerMetadata)),
|
...replaceContent(started, current.contentIndex, textContent(text, providerMetadata)),
|
||||||
textParts: { ...started.textParts, [event.id]: { ...current, text, itemId, providerMetadata } },
|
textParts: { ...started.textParts, [event.id]: { ...current, text, providerMetadata } },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,39 +431,32 @@ const reduceTextEnd = (state: ResponseState, event: TextEnd): ResponseState => {
|
|||||||
const current = state.textParts[event.id]
|
const current = state.textParts[event.id]
|
||||||
if (!current) return state
|
if (!current) return state
|
||||||
const providerMetadata = event.providerMetadata ?? current.providerMetadata
|
const providerMetadata = event.providerMetadata ?? current.providerMetadata
|
||||||
const itemId = event.itemId ?? current.itemId
|
|
||||||
return {
|
return {
|
||||||
...replaceContent(state, current.contentIndex, textContent(current.text, itemId, providerMetadata)),
|
...replaceContent(state, current.contentIndex, textContent(current.text, providerMetadata)),
|
||||||
textParts: { ...state.textParts, [event.id]: { ...current, itemId, providerMetadata } },
|
textParts: { ...state.textParts, [event.id]: { ...current, providerMetadata } },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ensureReasoning = (
|
const ensureReasoning = (state: ResponseState, id: string, providerMetadata?: ProviderMetadata): ResponseState => {
|
||||||
state: ResponseState,
|
|
||||||
id: string,
|
|
||||||
itemId?: ResponseItemID,
|
|
||||||
providerMetadata?: ProviderMetadata,
|
|
||||||
): ResponseState => {
|
|
||||||
if (state.reasoningParts[id]) return state
|
if (state.reasoningParts[id]) return state
|
||||||
return {
|
return {
|
||||||
...appendContent(state, reasoningContent("", itemId, providerMetadata)),
|
...appendContent(state, reasoningContent("", providerMetadata)),
|
||||||
reasoningParts: {
|
reasoningParts: {
|
||||||
...state.reasoningParts,
|
...state.reasoningParts,
|
||||||
[id]: { contentIndex: state.message.content.length, text: "", itemId, providerMetadata },
|
[id]: { contentIndex: state.message.content.length, text: "", providerMetadata },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const reduceReasoningDelta = (state: ResponseState, event: ReasoningDelta): ResponseState => {
|
const reduceReasoningDelta = (state: ResponseState, event: ReasoningDelta): ResponseState => {
|
||||||
const started = ensureReasoning(state, event.id, event.itemId, event.providerMetadata)
|
const started = ensureReasoning(state, event.id, event.providerMetadata)
|
||||||
const current = started.reasoningParts[event.id]
|
const current = started.reasoningParts[event.id]
|
||||||
if (!current) return started
|
if (!current) return started
|
||||||
const text = current.text + event.text
|
const text = current.text + event.text
|
||||||
const itemId = event.itemId ?? current.itemId
|
|
||||||
const providerMetadata = event.providerMetadata ?? current.providerMetadata
|
const providerMetadata = event.providerMetadata ?? current.providerMetadata
|
||||||
return {
|
return {
|
||||||
...replaceContent(started, current.contentIndex, reasoningContent(text, itemId, providerMetadata)),
|
...replaceContent(started, current.contentIndex, reasoningContent(text, providerMetadata)),
|
||||||
reasoningParts: { ...started.reasoningParts, [event.id]: { ...current, text, itemId, providerMetadata } },
|
reasoningParts: { ...started.reasoningParts, [event.id]: { ...current, text, providerMetadata } },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,10 +464,9 @@ const reduceReasoningEnd = (state: ResponseState, event: ReasoningEnd): Response
|
|||||||
const current = state.reasoningParts[event.id]
|
const current = state.reasoningParts[event.id]
|
||||||
if (!current) return state
|
if (!current) return state
|
||||||
const providerMetadata = event.providerMetadata ?? current.providerMetadata
|
const providerMetadata = event.providerMetadata ?? current.providerMetadata
|
||||||
const itemId = event.itemId ?? current.itemId
|
|
||||||
return {
|
return {
|
||||||
...replaceContent(state, current.contentIndex, reasoningContent(current.text, itemId, providerMetadata)),
|
...replaceContent(state, current.contentIndex, reasoningContent(current.text, providerMetadata)),
|
||||||
reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, itemId, providerMetadata } },
|
reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, providerMetadata } },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,7 +474,7 @@ const reduceToolInputStart = (state: ResponseState, event: ToolInputStart): Resp
|
|||||||
...state,
|
...state,
|
||||||
toolInputs: {
|
toolInputs: {
|
||||||
...state.toolInputs,
|
...state.toolInputs,
|
||||||
[event.id]: { name: event.name, text: "", itemId: event.itemId, providerMetadata: event.providerMetadata },
|
[event.id]: { name: event.name, text: "", providerMetadata: event.providerMetadata },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -540,7 +495,6 @@ const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): Response
|
|||||||
[event.id]: {
|
[event.id]: {
|
||||||
...current,
|
...current,
|
||||||
name: event.name,
|
name: event.name,
|
||||||
itemId: event.itemId ?? current.itemId,
|
|
||||||
providerMetadata: event.providerMetadata ?? current.providerMetadata,
|
providerMetadata: event.providerMetadata ?? current.providerMetadata,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -550,7 +504,6 @@ const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): Response
|
|||||||
const toolCallContent = (event: ToolCall): ContentPart =>
|
const toolCallContent = (event: ToolCall): ContentPart =>
|
||||||
ToolCallPart.make({
|
ToolCallPart.make({
|
||||||
id: event.id,
|
id: event.id,
|
||||||
...(event.itemId === undefined ? {} : { itemId: event.itemId }),
|
|
||||||
name: event.name,
|
name: event.name,
|
||||||
input: event.input,
|
input: event.input,
|
||||||
...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }),
|
...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }),
|
||||||
@@ -560,7 +513,6 @@ const toolCallContent = (event: ToolCall): ContentPart =>
|
|||||||
const toolResultContent = (event: ToolResult): ContentPart =>
|
const toolResultContent = (event: ToolResult): ContentPart =>
|
||||||
ToolResultPart.make({
|
ToolResultPart.make({
|
||||||
id: event.id,
|
id: event.id,
|
||||||
...(event.itemId === undefined ? {} : { itemId: event.itemId }),
|
|
||||||
name: event.name,
|
name: event.name,
|
||||||
result: event.result,
|
result: event.result,
|
||||||
...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }),
|
...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }),
|
||||||
@@ -576,13 +528,13 @@ const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseSta
|
|||||||
const next = appendEvent(state, event)
|
const next = appendEvent(state, event)
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "text-start":
|
case "text-start":
|
||||||
return ensureText(next, event.id, event.itemId, event.providerMetadata)
|
return ensureText(next, event.id, event.providerMetadata)
|
||||||
case "text-delta":
|
case "text-delta":
|
||||||
return reduceTextDelta(next, event)
|
return reduceTextDelta(next, event)
|
||||||
case "text-end":
|
case "text-end":
|
||||||
return reduceTextEnd(next, event)
|
return reduceTextEnd(next, event)
|
||||||
case "reasoning-start":
|
case "reasoning-start":
|
||||||
return ensureReasoning(next, event.id, event.itemId, event.providerMetadata)
|
return ensureReasoning(next, event.id, event.providerMetadata)
|
||||||
case "reasoning-delta":
|
case "reasoning-delta":
|
||||||
return reduceReasoningDelta(next, event)
|
return reduceReasoningDelta(next, event)
|
||||||
case "reasoning-end":
|
case "reasoning-end":
|
||||||
|
|||||||
@@ -21,9 +21,6 @@ export type ProviderID = typeof ProviderID.Type
|
|||||||
export const ResponseID = Schema.String
|
export const ResponseID = Schema.String
|
||||||
export type ResponseID = Schema.Schema.Type<typeof ResponseID>
|
export type ResponseID = Schema.Schema.Type<typeof ResponseID>
|
||||||
|
|
||||||
export const ResponseItemID = Schema.String
|
|
||||||
export type ResponseItemID = Schema.Schema.Type<typeof ResponseItemID>
|
|
||||||
|
|
||||||
export const ContentBlockID = Schema.String
|
export const ContentBlockID = Schema.String
|
||||||
export type ContentBlockID = Schema.Schema.Type<typeof ContentBlockID>
|
export type ContentBlockID = Schema.Schema.Type<typeof ContentBlockID>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { Tool } from "@opencode-ai/schema/tool"
|
import { Tool } from "@opencode-ai/schema/tool"
|
||||||
import { JsonSchema, MessageRole, ProviderMetadata, ResponseItemID } from "./ids"
|
import { JsonSchema, MessageRole, ProviderMetadata } from "./ids"
|
||||||
import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, LanguageModelSchema, ProviderOptions } from "./options"
|
import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, LanguageModelSchema, ProviderOptions } from "./options"
|
||||||
import { isRecord } from "../utils/record"
|
import { isRecord } from "../utils/record"
|
||||||
|
|
||||||
@@ -25,7 +25,6 @@ export const SystemPart = Object.assign(systemPartSchema, {
|
|||||||
export const TextPart = Schema.Struct({
|
export const TextPart = Schema.Struct({
|
||||||
type: Schema.Literal("text"),
|
type: Schema.Literal("text"),
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
itemId: Schema.optional(ResponseItemID),
|
|
||||||
cache: Schema.optional(CacheHint),
|
cache: Schema.optional(CacheHint),
|
||||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
@@ -122,7 +121,6 @@ export const ToolCallPart = Object.assign(
|
|||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
type: Schema.Literal("tool-call"),
|
type: Schema.Literal("tool-call"),
|
||||||
id: Schema.String,
|
id: Schema.String,
|
||||||
itemId: Schema.optional(ResponseItemID),
|
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
input: Schema.Unknown,
|
input: Schema.Unknown,
|
||||||
providerExecuted: Schema.optional(Schema.Boolean),
|
providerExecuted: Schema.optional(Schema.Boolean),
|
||||||
@@ -140,7 +138,6 @@ export const ToolResultPart = Object.assign(
|
|||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
type: Schema.Literal("tool-result"),
|
type: Schema.Literal("tool-result"),
|
||||||
id: Schema.String,
|
id: Schema.String,
|
||||||
itemId: Schema.optional(ResponseItemID),
|
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
result: ToolResultValue,
|
result: ToolResultValue,
|
||||||
providerExecuted: Schema.optional(Schema.Boolean),
|
providerExecuted: Schema.optional(Schema.Boolean),
|
||||||
@@ -157,7 +154,6 @@ export const ToolResultPart = Object.assign(
|
|||||||
): ToolResultPart => ({
|
): ToolResultPart => ({
|
||||||
type: "tool-result",
|
type: "tool-result",
|
||||||
id: input.id,
|
id: input.id,
|
||||||
...(input.itemId === undefined ? {} : { itemId: input.itemId }),
|
|
||||||
name: input.name,
|
name: input.name,
|
||||||
result: ToolResultValue.make(input.result, input.resultType),
|
result: ToolResultValue.make(input.result, input.resultType),
|
||||||
providerExecuted: input.providerExecuted,
|
providerExecuted: input.providerExecuted,
|
||||||
@@ -172,7 +168,6 @@ export type ToolResultPart = Schema.Schema.Type<typeof ToolResultPart>
|
|||||||
export const ReasoningPart = Schema.Struct({
|
export const ReasoningPart = Schema.Struct({
|
||||||
type: Schema.Literal("reasoning"),
|
type: Schema.Literal("reasoning"),
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
itemId: Schema.optional(ResponseItemID),
|
|
||||||
encrypted: Schema.optional(Schema.String),
|
encrypted: Schema.optional(Schema.String),
|
||||||
cache: Schema.optional(CacheHint),
|
cache: Schema.optional(CacheHint),
|
||||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||||
@@ -186,7 +181,7 @@ export const ContentPart = Schema.Union([TextPart, MediaPart, ToolCallPart, Tool
|
|||||||
export type ContentPart = Schema.Schema.Type<typeof ContentPart>
|
export type ContentPart = Schema.Schema.Type<typeof ContentPart>
|
||||||
|
|
||||||
export class Message extends Schema.Class<Message>("LLM.Message")({
|
export class Message extends Schema.Class<Message>("LLM.Message")({
|
||||||
id: Schema.optional(ResponseItemID),
|
id: Schema.optional(Schema.String),
|
||||||
role: MessageRole,
|
role: MessageRole,
|
||||||
content: Schema.Array(ContentPart),
|
content: Schema.Array(ContentPart),
|
||||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement,
|
|||||||
id: call.id,
|
id: call.id,
|
||||||
name: call.name,
|
name: call.name,
|
||||||
result: settlement.result,
|
result: settlement.result,
|
||||||
...(call.providerMetadata === undefined ? {} : { providerMetadata: call.providerMetadata }),
|
providerMetadata: call.providerMetadata,
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
@@ -88,7 +88,7 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement,
|
|||||||
name: call.name,
|
name: call.name,
|
||||||
result: settlement.result,
|
result: settlement.result,
|
||||||
output: settlement.output,
|
output: settlement.output,
|
||||||
...(call.providerMetadata === undefined ? {} : { providerMetadata: call.providerMetadata }),
|
providerMetadata: call.providerMetadata,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-11
File diff suppressed because one or more lines are too long
+4
-4
File diff suppressed because one or more lines are too long
+54
File diff suppressed because one or more lines are too long
+28
File diff suppressed because one or more lines are too long
+28
File diff suppressed because one or more lines are too long
+4
-4
File diff suppressed because one or more lines are too long
+6
-6
File diff suppressed because one or more lines are too long
Vendored
+4
-10
File diff suppressed because one or more lines are too long
-38
File diff suppressed because one or more lines are too long
Vendored
-39
File diff suppressed because one or more lines are too long
Vendored
-57
File diff suppressed because one or more lines are too long
@@ -8,6 +8,7 @@ import {
|
|||||||
type ProviderMetadata,
|
type ProviderMetadata,
|
||||||
type ToolCallPart,
|
type ToolCallPart,
|
||||||
ToolResultPart,
|
ToolResultPart,
|
||||||
|
type ToolResultValue,
|
||||||
type Usage,
|
type Usage,
|
||||||
} from "../../src/schema"
|
} from "../../src/schema"
|
||||||
import { type Tools, toDefinitions } from "../../src/tool"
|
import { type Tools, toDefinitions } from "../../src/tool"
|
||||||
@@ -60,10 +61,9 @@ export const runTools = <T extends Tools>(options: RunOptions<T>) =>
|
|||||||
...dispatched.map(([call, dispatched]) =>
|
...dispatched.map(([call, dispatched]) =>
|
||||||
Message.tool({
|
Message.tool({
|
||||||
id: call.id,
|
id: call.id,
|
||||||
itemId: dispatched.events.find(LLMEvent.is.toolResult)?.itemId,
|
|
||||||
name: call.name,
|
name: call.name,
|
||||||
result: dispatched.result,
|
result: dispatched.result,
|
||||||
providerMetadata: dispatched.events.find(LLMEvent.is.toolResult)?.providerMetadata,
|
providerMetadata: call.providerMetadata,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -89,15 +89,9 @@ const stepState = (events: ReadonlyArray<LLMEvent>) => {
|
|||||||
|
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
if (event.type === "text-delta" || event.type === "reasoning-delta") {
|
if (event.type === "text-delta" || event.type === "reasoning-delta") {
|
||||||
appendText(assistantContent, event.type === "text-delta" ? "text" : "reasoning", event.text, event.itemId)
|
appendText(assistantContent, event.type === "text-delta" ? "text" : "reasoning", event.text)
|
||||||
} else if (event.type === "text-end" || event.type === "reasoning-end") {
|
} else if (event.type === "text-end" || event.type === "reasoning-end") {
|
||||||
appendText(
|
appendText(assistantContent, event.type === "text-end" ? "text" : "reasoning", "", event.providerMetadata)
|
||||||
assistantContent,
|
|
||||||
event.type === "text-end" ? "text" : "reasoning",
|
|
||||||
"",
|
|
||||||
event.itemId,
|
|
||||||
event.providerMetadata,
|
|
||||||
)
|
|
||||||
} else if (event.type === "tool-call") {
|
} else if (event.type === "tool-call") {
|
||||||
assistantContent.push(event)
|
assistantContent.push(event)
|
||||||
if (!event.providerExecuted) toolCalls.push(event)
|
if (!event.providerExecuted) toolCalls.push(event)
|
||||||
@@ -105,7 +99,6 @@ const stepState = (events: ReadonlyArray<LLMEvent>) => {
|
|||||||
assistantContent.push(
|
assistantContent.push(
|
||||||
ToolResultPart.make({
|
ToolResultPart.make({
|
||||||
id: event.id,
|
id: event.id,
|
||||||
itemId: event.itemId,
|
|
||||||
name: event.name,
|
name: event.name,
|
||||||
result: event.result,
|
result: event.result,
|
||||||
providerExecuted: true,
|
providerExecuted: true,
|
||||||
@@ -125,7 +118,6 @@ const appendText = (
|
|||||||
content: ContentPart[],
|
content: ContentPart[],
|
||||||
type: "text" | "reasoning",
|
type: "text" | "reasoning",
|
||||||
text: string,
|
text: string,
|
||||||
itemId?: string,
|
|
||||||
providerMetadata?: ProviderMetadata,
|
providerMetadata?: ProviderMetadata,
|
||||||
) => {
|
) => {
|
||||||
const last = content.at(-1)
|
const last = content.at(-1)
|
||||||
@@ -133,12 +125,11 @@ const appendText = (
|
|||||||
content[content.length - 1] = {
|
content[content.length - 1] = {
|
||||||
...last,
|
...last,
|
||||||
text: `${last.text}${text}`,
|
text: `${last.text}${text}`,
|
||||||
itemId: itemId ?? last.itemId,
|
|
||||||
providerMetadata: providerMetadata ?? last.providerMetadata,
|
providerMetadata: providerMetadata ?? last.providerMetadata,
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
content.push({ type, text, itemId, providerMetadata })
|
content.push({ type, text, providerMetadata })
|
||||||
}
|
}
|
||||||
|
|
||||||
const addUsage = (left: Usage | undefined, right: Usage | undefined): Usage | undefined => {
|
const addUsage = (left: Usage | undefined, right: Usage | undefined): Usage | undefined => {
|
||||||
|
|||||||
@@ -16,13 +16,6 @@ const model = Gemini.route
|
|||||||
})
|
})
|
||||||
.model({ id: "gemini-2.5-flash" })
|
.model({ id: "gemini-2.5-flash" })
|
||||||
|
|
||||||
const gemini3 = Gemini.route
|
|
||||||
.with({
|
|
||||||
endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
|
|
||||||
auth: Auth.header("x-goog-api-key", "test"),
|
|
||||||
})
|
|
||||||
.model({ id: "gemini-3-flash-preview" })
|
|
||||||
|
|
||||||
const request = LLM.request({
|
const request = LLM.request({
|
||||||
id: "req_1",
|
id: "req_1",
|
||||||
model,
|
model,
|
||||||
@@ -93,39 +86,6 @@ describe("Gemini route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("forwards standard Gemini generation options", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const prepared = yield* compileRequest(
|
|
||||||
LLM.request({
|
|
||||||
model,
|
|
||||||
prompt: "Say hello.",
|
|
||||||
generation: {
|
|
||||||
maxTokens: 40,
|
|
||||||
temperature: 0.2,
|
|
||||||
topP: 0.8,
|
|
||||||
topK: 12,
|
|
||||||
frequencyPenalty: 0.3,
|
|
||||||
presencePenalty: 0.4,
|
|
||||||
seed: 42,
|
|
||||||
stop: ["done"],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(prepared.body.generationConfig).toEqual({
|
|
||||||
maxOutputTokens: 40,
|
|
||||||
temperature: 0.2,
|
|
||||||
topP: 0.8,
|
|
||||||
topK: 12,
|
|
||||||
frequencyPenalty: 0.3,
|
|
||||||
presencePenalty: 0.4,
|
|
||||||
seed: 42,
|
|
||||||
stopSequences: ["done"],
|
|
||||||
thinkingConfig: undefined,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("lowers chronological system updates to wrapped user text in order", () =>
|
it.effect("lowers chronological system updates to wrapped user text in order", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* compileRequest(
|
const prepared = yield* compileRequest(
|
||||||
@@ -390,100 +350,6 @@ describe("Gemini route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("preserves nested empty object tool schemas", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const prepared = yield* compileRequest(
|
|
||||||
LLM.request({
|
|
||||||
model,
|
|
||||||
prompt: "Use the tool.",
|
|
||||||
tools: [
|
|
||||||
{
|
|
||||||
name: "configure",
|
|
||||||
description: "Configure the operation",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
required: ["options"],
|
|
||||||
properties: {
|
|
||||||
options: { type: "object", description: "Optional provider settings", properties: {} },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(prepared.body.tools).toEqual([
|
|
||||||
{
|
|
||||||
functionDeclarations: [
|
|
||||||
{
|
|
||||||
name: "configure",
|
|
||||||
description: "Configure the operation",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
required: ["options"],
|
|
||||||
properties: {
|
|
||||||
options: { type: "object", description: "Optional provider settings", properties: {} },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("projects Gemini type arrays without narrowing their allowed values", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const prepared = yield* compileRequest(
|
|
||||||
LLM.request({
|
|
||||||
model,
|
|
||||||
prompt: "Use the tool.",
|
|
||||||
tools: [
|
|
||||||
{
|
|
||||||
name: "filter",
|
|
||||||
description: "Filter values",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
status: { type: ["number", "string"], description: "Status filter" },
|
|
||||||
maybe: { type: ["string", "null"] },
|
|
||||||
nothing: { type: ["null"] },
|
|
||||||
explicit: { anyOf: [{ type: "string" }, { type: "null" }] },
|
|
||||||
choice: { anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }] },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(prepared.body.tools?.[0]?.functionDeclarations[0]?.parameters).toEqual({
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
status: {
|
|
||||||
description: "Status filter",
|
|
||||||
anyOf: [{ type: "number" }, { type: "string" }],
|
|
||||||
},
|
|
||||||
maybe: {
|
|
||||||
nullable: true,
|
|
||||||
anyOf: [{ type: "string" }],
|
|
||||||
},
|
|
||||||
nothing: {
|
|
||||||
type: "null",
|
|
||||||
},
|
|
||||||
explicit: {
|
|
||||||
type: "string",
|
|
||||||
nullable: true,
|
|
||||||
},
|
|
||||||
choice: {
|
|
||||||
anyOf: [{ type: "string" }, { type: "number" }],
|
|
||||||
nullable: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("parses text, reasoning, and usage stream fixtures", () =>
|
it.effect("parses text, reasoning, and usage stream fixtures", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const body = sseEvents(
|
const body = sseEvents(
|
||||||
@@ -670,44 +536,6 @@ describe("Gemini route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("replays unsigned Gemini 3 tool calls with the validator bypass sentinel", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const prepared = yield* compileRequest(
|
|
||||||
LLM.request({
|
|
||||||
model: gemini3,
|
|
||||||
messages: [
|
|
||||||
Message.assistant([ToolCallPart.make({ id: "tool_0", name: "lookup", input: { query: "weather" } })]),
|
|
||||||
Message.tool({ id: "tool_0", name: "lookup", result: "done", resultType: "text" }),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(prepared.body.contents).toEqual([
|
|
||||||
{
|
|
||||||
role: "model",
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
|
|
||||||
thoughtSignature: "skip_thought_signature_validator",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
functionResponse: {
|
|
||||||
id: undefined,
|
|
||||||
name: "lookup",
|
|
||||||
response: { name: "lookup", content: "done" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("emits streamed tool calls and maps finish reason", () =>
|
it.effect("emits streamed tool calls and maps finish reason", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const body = sseEvents({
|
const body = sseEvents({
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ describe("Open Responses-compatible route", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(prepared.body).toMatchObject({
|
expect(prepared.body).toEqual({
|
||||||
model: "example-model",
|
model: "example-model",
|
||||||
input: [
|
input: [
|
||||||
{ role: "system", content: "You are concise." },
|
{ role: "system", content: "You are concise." },
|
||||||
@@ -53,8 +53,6 @@ describe("Open Responses-compatible route", () => {
|
|||||||
],
|
],
|
||||||
stream: true,
|
stream: true,
|
||||||
})
|
})
|
||||||
expect(prepared.body.input[0]).not.toHaveProperty("id")
|
|
||||||
expect(prepared.body.input[1]).not.toHaveProperty("id")
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -69,9 +69,6 @@ describe("OpenAI Responses route", () => {
|
|||||||
stream: true,
|
stream: true,
|
||||||
max_output_tokens: 20,
|
max_output_tokens: 20,
|
||||||
temperature: 0,
|
temperature: 0,
|
||||||
tool_choice: undefined,
|
|
||||||
tools: undefined,
|
|
||||||
top_p: undefined,
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -332,7 +329,7 @@ describe("OpenAI Responses route", () => {
|
|||||||
yield* LLMClient.generate(
|
yield* LLMClient.generate(
|
||||||
LLMRequest.update(request, {
|
LLMRequest.update(request, {
|
||||||
model: Azure.configure({
|
model: Azure.configure({
|
||||||
baseURL: "https://opencode-test.openai.azure.com/openai/",
|
baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
|
||||||
apiKey: "azure-key",
|
apiKey: "azure-key",
|
||||||
headers: { authorization: "Bearer stale" },
|
headers: { authorization: "Bearer stale" },
|
||||||
}).responses("gpt-4.1-mini"),
|
}).responses("gpt-4.1-mini"),
|
||||||
@@ -413,7 +410,7 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(prepared.body).toMatchObject({
|
expect(prepared.body).toEqual({
|
||||||
model: "gpt-4.1-mini",
|
model: "gpt-4.1-mini",
|
||||||
input: [
|
input: [
|
||||||
{ role: "user", content: [{ type: "input_text", text: "What is the weather?" }] },
|
{ role: "user", content: [{ type: "input_text", text: "What is the weather?" }] },
|
||||||
@@ -428,65 +425,6 @@ describe("OpenAI Responses route", () => {
|
|||||||
tools: undefined,
|
tools: undefined,
|
||||||
top_p: undefined,
|
top_p: undefined,
|
||||||
})
|
})
|
||||||
const call = prepared.body.input.find((item) => "type" in item && item.type === "function_call")
|
|
||||||
const output = prepared.body.input.find((item) => "type" in item && item.type === "function_call_output")
|
|
||||||
expect(call?.id).toBeUndefined()
|
|
||||||
expect(output?.id).toBeUndefined()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("does not generate response item ids for client-created history", () =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
const canonical = LLM.request({
|
|
||||||
model,
|
|
||||||
messages: [
|
|
||||||
Message.assistant([
|
|
||||||
{ type: "text", text: "Working." },
|
|
||||||
{ type: "reasoning", text: "Thinking." },
|
|
||||||
ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }),
|
|
||||||
]),
|
|
||||||
Message.tool({ id: "call_1", name: "lookup", result: "done" }),
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(canonical.messages[0]?.id).toBeUndefined()
|
|
||||||
expect(canonical.messages[1]?.id).toBeUndefined()
|
|
||||||
expect(canonical.messages[0]?.content.every((part) => part.type === "media" || part.itemId === undefined)).toBe(
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
expect(canonical.messages[1]?.content[0]).not.toHaveProperty("itemId")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("replays provider function call item ids without assigning output ids", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const canonical = LLM.request({
|
|
||||||
model,
|
|
||||||
messages: [
|
|
||||||
Message.assistant([
|
|
||||||
{ type: "text", text: "Calling.", itemId: "plain-text" },
|
|
||||||
ToolCallPart.make({
|
|
||||||
id: "call_1",
|
|
||||||
name: "lookup",
|
|
||||||
input: {},
|
|
||||||
providerMetadata: { openai: { itemId: "plain-call" } },
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
Message.tool({ id: "call_1", itemId: "plain-output", name: "lookup", result: "done" }),
|
|
||||||
],
|
|
||||||
})
|
|
||||||
const prepared = yield* compileRequest(canonical)
|
|
||||||
|
|
||||||
expect(canonical.messages[0]?.content.map((part) => (part.type === "media" ? undefined : part.itemId))).toEqual([
|
|
||||||
"plain-text",
|
|
||||||
undefined,
|
|
||||||
])
|
|
||||||
expect(canonical.messages[1]?.content[0]).toMatchObject({ itemId: "plain-output" })
|
|
||||||
expect(prepared.body.input).toEqual([
|
|
||||||
{ role: "assistant", id: "plain-text", content: [{ type: "output_text", text: "Calling." }] },
|
|
||||||
{ type: "function_call", id: "plain-call", call_id: "call_1", name: "lookup", arguments: "{}" },
|
|
||||||
{ type: "function_call_output", call_id: "call_1", output: '"done"' },
|
|
||||||
])
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -926,21 +864,9 @@ describe("OpenAI Responses route", () => {
|
|||||||
expect(response.text).toBe("Hello!")
|
expect(response.text).toBe("Hello!")
|
||||||
expect(response.events).toEqual([
|
expect(response.events).toEqual([
|
||||||
{ type: "step-start", index: 0 },
|
{ type: "step-start", index: 0 },
|
||||||
{ type: "text-start", id: "msg_1", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
|
{ type: "text-start", id: "msg_1" },
|
||||||
{
|
{ type: "text-delta", id: "msg_1", text: "Hello" },
|
||||||
type: "text-delta",
|
{ type: "text-delta", id: "msg_1", text: "!" },
|
||||||
id: "msg_1",
|
|
||||||
itemId: "msg_1",
|
|
||||||
text: "Hello",
|
|
||||||
providerMetadata: { openai: { itemId: "msg_1" } },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "text-delta",
|
|
||||||
id: "msg_1",
|
|
||||||
itemId: "msg_1",
|
|
||||||
text: "!",
|
|
||||||
providerMetadata: { openai: { itemId: "msg_1" } },
|
|
||||||
},
|
|
||||||
{ type: "text-end", id: "msg_1" },
|
{ type: "text-end", id: "msg_1" },
|
||||||
{
|
{
|
||||||
type: "step-finish",
|
type: "step-finish",
|
||||||
@@ -997,20 +923,17 @@ describe("OpenAI Responses route", () => {
|
|||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: "Checking.",
|
text: "Checking.",
|
||||||
itemId: "msg_commentary",
|
providerMetadata: { openai: { phase: "commentary" } },
|
||||||
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: "Finished.",
|
text: "Finished.",
|
||||||
itemId: "msg_final",
|
providerMetadata: { openai: { phase: "final_answer" } },
|
||||||
providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: "Unclassified.",
|
text: "Unclassified.",
|
||||||
itemId: "msg_null",
|
providerMetadata: { openai: { phase: null } },
|
||||||
providerMetadata: { openai: { itemId: "msg_null", phase: null } },
|
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -1018,19 +941,16 @@ describe("OpenAI Responses route", () => {
|
|||||||
expect(prepared.body.input).toEqual([
|
expect(prepared.body.input).toEqual([
|
||||||
{
|
{
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
id: "msg_commentary",
|
|
||||||
content: [{ type: "output_text", text: "Checking." }],
|
content: [{ type: "output_text", text: "Checking." }],
|
||||||
phase: "commentary",
|
phase: "commentary",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
id: "msg_final",
|
|
||||||
content: [{ type: "output_text", text: "Finished." }],
|
content: [{ type: "output_text", text: "Finished." }],
|
||||||
phase: "final_answer",
|
phase: "final_answer",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
id: "msg_null",
|
|
||||||
content: [{ type: "output_text", text: "Unclassified." }],
|
content: [{ type: "output_text", text: "Unclassified." }],
|
||||||
phase: null,
|
phase: null,
|
||||||
},
|
},
|
||||||
@@ -1123,24 +1043,12 @@ describe("OpenAI Responses route", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
|
expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
|
||||||
{ type: "text-start", id: "msg_1", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
|
{ type: "text-start", id: "msg_1" },
|
||||||
{
|
{ type: "text-delta", id: "msg_1", text: "First" },
|
||||||
type: "text-delta",
|
{ type: "text-end", id: "msg_1" },
|
||||||
id: "msg_1",
|
{ type: "text-start", id: "msg_2" },
|
||||||
itemId: "msg_1",
|
{ type: "text-delta", id: "msg_2", text: "Second" },
|
||||||
text: "First",
|
{ type: "text-end", id: "msg_2" },
|
||||||
providerMetadata: { openai: { itemId: "msg_1" } },
|
|
||||||
},
|
|
||||||
{ type: "text-end", id: "msg_1", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
|
|
||||||
{ type: "text-start", id: "msg_2", itemId: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
|
|
||||||
{
|
|
||||||
type: "text-delta",
|
|
||||||
id: "msg_2",
|
|
||||||
itemId: "msg_2",
|
|
||||||
text: "Second",
|
|
||||||
providerMetadata: { openai: { itemId: "msg_2" } },
|
|
||||||
},
|
|
||||||
{ type: "text-end", id: "msg_2", itemId: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
|
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -1160,15 +1068,9 @@ describe("OpenAI Responses route", () => {
|
|||||||
expect(response.text).toBe("Hello")
|
expect(response.text).toBe("Hello")
|
||||||
expect(response.events).toMatchObject([
|
expect(response.events).toMatchObject([
|
||||||
{ type: "step-start", index: 0 },
|
{ type: "step-start", index: 0 },
|
||||||
{ type: "reasoning-start", id: "rs_1", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
|
{ type: "reasoning-start", id: "rs_1" },
|
||||||
{
|
{ type: "reasoning-delta", id: "rs_1", text: "thinking" },
|
||||||
type: "reasoning-delta",
|
{ type: "text-start", id: "msg_1" },
|
||||||
id: "rs_1",
|
|
||||||
itemId: "rs_1",
|
|
||||||
text: "thinking",
|
|
||||||
providerMetadata: { openai: { itemId: "rs_1" } },
|
|
||||||
},
|
|
||||||
{ type: "text-start", id: "msg_1", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
|
|
||||||
{ type: "text-delta", id: "msg_1", text: "Hello" },
|
{ type: "text-delta", id: "msg_1", text: "Hello" },
|
||||||
{ type: "reasoning-end", id: "rs_1" },
|
{ type: "reasoning-end", id: "rs_1" },
|
||||||
{ type: "text-end", id: "msg_1" },
|
{ type: "text-end", id: "msg_1" },
|
||||||
@@ -1177,8 +1079,8 @@ describe("OpenAI Responses route", () => {
|
|||||||
])
|
])
|
||||||
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
|
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
|
||||||
expect(response.message.content).toEqual([
|
expect(response.message.content).toEqual([
|
||||||
{ type: "reasoning", text: "thinking", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
|
{ type: "reasoning", text: "thinking" },
|
||||||
{ type: "text", text: "Hello", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
|
{ type: "text", text: "Hello" },
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -1209,7 +1111,6 @@ describe("OpenAI Responses route", () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: "reasoning-end",
|
type: "reasoning-end",
|
||||||
id: "rs_1",
|
id: "rs_1",
|
||||||
itemId: "rs_1",
|
|
||||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -1250,34 +1151,19 @@ describe("OpenAI Responses route", () => {
|
|||||||
{
|
{
|
||||||
type: "reasoning-start",
|
type: "reasoning-start",
|
||||||
id: "rs_1:0",
|
id: "rs_1:0",
|
||||||
itemId: "rs_1",
|
|
||||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
|
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||||
},
|
},
|
||||||
{
|
{ type: "reasoning-delta", id: "rs_1:0", text: "First" },
|
||||||
type: "reasoning-delta",
|
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
|
||||||
id: "rs_1:0",
|
|
||||||
itemId: "rs_1",
|
|
||||||
text: "First",
|
|
||||||
providerMetadata: { openai: { itemId: "rs_1" } },
|
|
||||||
},
|
|
||||||
{ type: "reasoning-end", id: "rs_1:0", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
|
|
||||||
{
|
{
|
||||||
type: "reasoning-start",
|
type: "reasoning-start",
|
||||||
id: "rs_1:1",
|
id: "rs_1:1",
|
||||||
itemId: "rs_1",
|
|
||||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
|
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||||
},
|
},
|
||||||
{
|
{ type: "reasoning-delta", id: "rs_1:1", text: "Second" },
|
||||||
type: "reasoning-delta",
|
|
||||||
id: "rs_1:1",
|
|
||||||
itemId: "rs_1",
|
|
||||||
text: "Second",
|
|
||||||
providerMetadata: { openai: { itemId: "rs_1" } },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
type: "reasoning-end",
|
type: "reasoning-end",
|
||||||
id: "rs_1:1",
|
id: "rs_1:1",
|
||||||
itemId: "rs_1",
|
|
||||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||||
},
|
},
|
||||||
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
|
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
|
||||||
@@ -1315,8 +1201,8 @@ describe("OpenAI Responses route", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
|
expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
|
||||||
{ type: "reasoning-end", id: "rs_1:0", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
|
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
|
||||||
{ type: "reasoning-end", id: "rs_1:1", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
|
{ type: "reasoning-end", id: "rs_1:1", providerMetadata: { openai: { itemId: "rs_1" } } },
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -1364,7 +1250,7 @@ describe("OpenAI Responses route", () => {
|
|||||||
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
|
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
expect(body.input[1]).toHaveProperty("id", "rs_1")
|
expect(body.input[1]).not.toHaveProperty("id")
|
||||||
return input.respond(
|
return input.respond(
|
||||||
sseEvents(
|
sseEvents(
|
||||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." },
|
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." },
|
||||||
@@ -1411,7 +1297,6 @@ describe("OpenAI Responses route", () => {
|
|||||||
{ role: "assistant", content: [{ type: "output_text", text: "Before." }] },
|
{ role: "assistant", content: [{ type: "output_text", text: "Before." }] },
|
||||||
{
|
{
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
id: "rs_1",
|
|
||||||
encrypted_content: "encrypted-state",
|
encrypted_content: "encrypted-state",
|
||||||
summary: [{ type: "summary_text", text: "Checked order." }],
|
summary: [{ type: "summary_text", text: "Checked order." }],
|
||||||
},
|
},
|
||||||
@@ -1420,7 +1305,7 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("replays complete stored reasoning items with their id", () =>
|
it.effect("references stored reasoning items by id", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* compileRequest(
|
const prepared = yield* compileRequest(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
@@ -1438,14 +1323,7 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(prepared.body.input).toEqual([
|
expect(prepared.body.input).toEqual([{ type: "item_reference", id: "rs_1" }])
|
||||||
{
|
|
||||||
type: "reasoning",
|
|
||||||
id: "rs_1",
|
|
||||||
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
|
|
||||||
encrypted_content: undefined,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1554,7 +1432,6 @@ describe("OpenAI Responses route", () => {
|
|||||||
expect(prepared.body.input).toEqual([
|
expect(prepared.body.input).toEqual([
|
||||||
{
|
{
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
id: "rs_1",
|
|
||||||
encrypted_content: "encrypted-state",
|
encrypted_content: "encrypted-state",
|
||||||
summary: [
|
summary: [
|
||||||
{ type: "summary_text", text: "First" },
|
{ type: "summary_text", text: "First" },
|
||||||
@@ -1634,7 +1511,6 @@ describe("OpenAI Responses route", () => {
|
|||||||
outputTokens: 1,
|
outputTokens: 1,
|
||||||
nonCachedInputTokens: 5,
|
nonCachedInputTokens: 5,
|
||||||
cacheReadInputTokens: undefined,
|
cacheReadInputTokens: undefined,
|
||||||
cacheWriteInputTokens: undefined,
|
|
||||||
reasoningTokens: undefined,
|
reasoningTokens: undefined,
|
||||||
totalTokens: 6,
|
totalTokens: 6,
|
||||||
providerMetadata: { openai: { input_tokens: 5, output_tokens: 1 } },
|
providerMetadata: { openai: { input_tokens: 5, output_tokens: 1 } },
|
||||||
@@ -1645,35 +1521,30 @@ describe("OpenAI Responses route", () => {
|
|||||||
{
|
{
|
||||||
type: "tool-input-start",
|
type: "tool-input-start",
|
||||||
id: "call_1",
|
id: "call_1",
|
||||||
itemId: "item_1",
|
|
||||||
name: "lookup",
|
name: "lookup",
|
||||||
providerMetadata: { openai: { itemId: "item_1" } },
|
providerMetadata: { openai: { itemId: "item_1" } },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "tool-input-delta",
|
type: "tool-input-delta",
|
||||||
id: "call_1",
|
id: "call_1",
|
||||||
itemId: "item_1",
|
|
||||||
name: "lookup",
|
name: "lookup",
|
||||||
text: '{"query"',
|
text: '{"query"',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "tool-input-delta",
|
type: "tool-input-delta",
|
||||||
id: "call_1",
|
id: "call_1",
|
||||||
itemId: "item_1",
|
|
||||||
name: "lookup",
|
name: "lookup",
|
||||||
text: ':"weather"}',
|
text: ':"weather"}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "tool-input-end",
|
type: "tool-input-end",
|
||||||
id: "call_1",
|
id: "call_1",
|
||||||
itemId: "item_1",
|
|
||||||
name: "lookup",
|
name: "lookup",
|
||||||
providerMetadata: { openai: { itemId: "item_1" } },
|
providerMetadata: { openai: { itemId: "item_1" } },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "tool-call",
|
type: "tool-call",
|
||||||
id: "call_1",
|
id: "call_1",
|
||||||
itemId: "item_1",
|
|
||||||
name: "lookup",
|
name: "lookup",
|
||||||
input: { query: "weather" },
|
input: { query: "weather" },
|
||||||
providerExecuted: undefined,
|
providerExecuted: undefined,
|
||||||
@@ -1693,17 +1564,6 @@ describe("OpenAI Responses route", () => {
|
|||||||
usage,
|
usage,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
expect(response.message.content).toEqual([
|
|
||||||
{
|
|
||||||
type: "tool-call",
|
|
||||||
id: "call_1",
|
|
||||||
itemId: "item_1",
|
|
||||||
name: "lookup",
|
|
||||||
input: { query: "weather" },
|
|
||||||
providerExecuted: undefined,
|
|
||||||
providerMetadata: { openai: { itemId: "item_1" } },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1736,7 +1596,6 @@ describe("OpenAI Responses route", () => {
|
|||||||
expect(response.events.find(LLMEvent.is.toolInputError)).toEqual({
|
expect(response.events.find(LLMEvent.is.toolInputError)).toEqual({
|
||||||
type: "tool-input-error",
|
type: "tool-input-error",
|
||||||
id: "call_1",
|
id: "call_1",
|
||||||
itemId: "item_1",
|
|
||||||
name: "lookup",
|
name: "lookup",
|
||||||
raw: '{"query":"partial',
|
raw: '{"query":"partial',
|
||||||
})
|
})
|
||||||
@@ -1793,7 +1652,6 @@ describe("OpenAI Responses route", () => {
|
|||||||
{
|
{
|
||||||
type: "tool-call",
|
type: "tool-call",
|
||||||
id: "ws_1",
|
id: "ws_1",
|
||||||
itemId: "ws_1",
|
|
||||||
name: "web_search",
|
name: "web_search",
|
||||||
input: { type: "search", query: "effect 4" },
|
input: { type: "search", query: "effect 4" },
|
||||||
providerExecuted: true,
|
providerExecuted: true,
|
||||||
@@ -1802,35 +1660,11 @@ describe("OpenAI Responses route", () => {
|
|||||||
{
|
{
|
||||||
type: "tool-result",
|
type: "tool-result",
|
||||||
id: "ws_1",
|
id: "ws_1",
|
||||||
itemId: "ws_1",
|
|
||||||
name: "web_search",
|
name: "web_search",
|
||||||
result: { type: "json", value: item },
|
result: { type: "json", value: item },
|
||||||
providerExecuted: true,
|
providerExecuted: true,
|
||||||
providerMetadata: { openai: { itemId: "ws_1", item } },
|
|
||||||
output: undefined,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(response.message.content).toEqual([
|
|
||||||
{
|
|
||||||
type: "tool-call",
|
|
||||||
id: "ws_1",
|
|
||||||
itemId: "ws_1",
|
|
||||||
name: "web_search",
|
|
||||||
input: { type: "search", query: "effect 4" },
|
|
||||||
providerExecuted: true,
|
|
||||||
providerMetadata: { openai: { itemId: "ws_1" } },
|
providerMetadata: { openai: { itemId: "ws_1" } },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
type: "tool-result",
|
|
||||||
id: "ws_1",
|
|
||||||
itemId: "ws_1",
|
|
||||||
name: "web_search",
|
|
||||||
result: { type: "json", value: item },
|
|
||||||
providerExecuted: true,
|
|
||||||
providerMetadata: { openai: { itemId: "ws_1", item } },
|
|
||||||
metadata: undefined,
|
|
||||||
cache: undefined,
|
|
||||||
},
|
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -1908,7 +1742,6 @@ describe("OpenAI Responses route", () => {
|
|||||||
expect(toolCall).toEqual({
|
expect(toolCall).toEqual({
|
||||||
type: "tool-call",
|
type: "tool-call",
|
||||||
id: "ci_1",
|
id: "ci_1",
|
||||||
itemId: "ci_1",
|
|
||||||
name: "code_interpreter",
|
name: "code_interpreter",
|
||||||
input: { code: "print(1+1)", container_id: "cnt_xyz" },
|
input: { code: "print(1+1)", container_id: "cnt_xyz" },
|
||||||
providerExecuted: true,
|
providerExecuted: true,
|
||||||
@@ -1918,12 +1751,10 @@ describe("OpenAI Responses route", () => {
|
|||||||
expect(toolResult).toEqual({
|
expect(toolResult).toEqual({
|
||||||
type: "tool-result",
|
type: "tool-result",
|
||||||
id: "ci_1",
|
id: "ci_1",
|
||||||
itemId: "ci_1",
|
|
||||||
name: "code_interpreter",
|
name: "code_interpreter",
|
||||||
result: { type: "json", value: item },
|
result: { type: "json", value: item },
|
||||||
providerExecuted: true,
|
providerExecuted: true,
|
||||||
providerMetadata: { openai: { itemId: "ci_1", item } },
|
providerMetadata: { openai: { itemId: "ci_1" } },
|
||||||
output: undefined,
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -49,43 +49,6 @@ describe("LLMResponse reducer", () => {
|
|||||||
expect(state.message.content).toEqual([{ type: "text", text: "partial" }])
|
expect(state.message.content).toEqual([{ type: "text", text: "partial" }])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("assembles response item identity and provider metadata", () => {
|
|
||||||
const response = LLMResponse.fromEvents([
|
|
||||||
LLMEvent.textStart({ id: "text-block", itemId: "msg_existing" }),
|
|
||||||
LLMEvent.textDelta({
|
|
||||||
id: "text-block",
|
|
||||||
itemId: "msg_existing",
|
|
||||||
text: "Answer",
|
|
||||||
providerMetadata: { openai: { itemId: "msg_existing" } },
|
|
||||||
}),
|
|
||||||
LLMEvent.textEnd({ id: "text-block", itemId: "msg_existing" }),
|
|
||||||
LLMEvent.reasoningStart({ id: "reasoning-block", itemId: "rs_existing" }),
|
|
||||||
LLMEvent.reasoningDelta({
|
|
||||||
id: "reasoning-block",
|
|
||||||
itemId: "rs_existing",
|
|
||||||
text: "Thought",
|
|
||||||
providerMetadata: { openai: { itemId: "rs_existing" } },
|
|
||||||
}),
|
|
||||||
LLMEvent.reasoningEnd({ id: "reasoning-block", itemId: "rs_existing" }),
|
|
||||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
|
||||||
])
|
|
||||||
|
|
||||||
expect(response?.message.content).toEqual([
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "Answer",
|
|
||||||
itemId: "msg_existing",
|
|
||||||
providerMetadata: { openai: { itemId: "msg_existing" } },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "reasoning",
|
|
||||||
text: "Thought",
|
|
||||||
itemId: "rs_existing",
|
|
||||||
providerMetadata: { openai: { itemId: "rs_existing" } },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not complete ended content without a terminal finish", () => {
|
test("does not complete ended content without a terminal finish", () => {
|
||||||
const state = reduce([
|
const state = reduce([
|
||||||
LLMEvent.textStart({ id: "t1" }),
|
LLMEvent.textStart({ id: "t1" }),
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ describe("LLMClient tools", () => {
|
|||||||
expect(calls).toEqual([{ id: "call_projected", parameters: { prefix: "count" }, output: { count: "2" } }])
|
expect(calls).toEqual([{ id: "call_projected", parameters: { prefix: "count" }, output: { count: "2" } }])
|
||||||
expect(dispatched.result).toEqual({ type: "text", value: "count:2" })
|
expect(dispatched.result).toEqual({ type: "text", value: "count:2" })
|
||||||
expect(dispatched.output).toEqual({ structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] })
|
expect(dispatched.output).toEqual({ structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] })
|
||||||
expect(dispatched.events).toMatchObject([
|
expect(dispatched.events).toEqual([
|
||||||
LLMEvent.toolResult({
|
LLMEvent.toolResult({
|
||||||
id: "call_projected",
|
id: "call_projected",
|
||||||
name: "projected",
|
name: "projected",
|
||||||
@@ -180,7 +180,6 @@ describe("LLMClient tools", () => {
|
|||||||
output: { structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] },
|
output: { structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] },
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
expect(dispatched.events[0]?.itemId).toBeUndefined()
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -198,7 +197,7 @@ describe("LLMClient tools", () => {
|
|||||||
LLMEvent.toolCall({ id: "call_1", name: "tool", input: {}, providerMetadata }),
|
LLMEvent.toolCall({ id: "call_1", name: "tool", input: {}, providerMetadata }),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(dispatched.events).toMatchObject([
|
expect(dispatched.events).toEqual([
|
||||||
LLMEvent.toolResult({
|
LLMEvent.toolResult({
|
||||||
id: "call_1",
|
id: "call_1",
|
||||||
name: "tool",
|
name: "tool",
|
||||||
@@ -207,13 +206,12 @@ describe("LLMClient tools", () => {
|
|||||||
providerMetadata,
|
providerMetadata,
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
expect(dispatched.events[0]?.itemId).toBeUndefined()
|
|
||||||
|
|
||||||
const failed = yield* ToolRuntime.dispatch(
|
const failed = yield* ToolRuntime.dispatch(
|
||||||
{},
|
{},
|
||||||
LLMEvent.toolCall({ id: "call_2", itemId: "fc_failed", name: "missing", input: {}, providerMetadata }),
|
LLMEvent.toolCall({ id: "call_2", name: "missing", input: {}, providerMetadata }),
|
||||||
)
|
)
|
||||||
expect(failed.events).toMatchObject([
|
expect(failed.events).toEqual([
|
||||||
LLMEvent.toolError({
|
LLMEvent.toolError({
|
||||||
id: "call_2",
|
id: "call_2",
|
||||||
name: "missing",
|
name: "missing",
|
||||||
@@ -227,27 +225,6 @@ describe("LLMClient tools", () => {
|
|||||||
providerMetadata,
|
providerMetadata,
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
const errorItemID = failed.events.find(LLMEvent.is.toolError)?.itemId
|
|
||||||
const resultItemID = failed.events.find(LLMEvent.is.toolResult)?.itemId
|
|
||||||
expect(errorItemID).toBeUndefined()
|
|
||||||
expect(resultItemID).toBeUndefined()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("does not derive a function output item id from the function call item id", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const tool = Tool.make({
|
|
||||||
description: "Return text.",
|
|
||||||
parameters: Schema.Struct({}),
|
|
||||||
success: Schema.String,
|
|
||||||
execute: () => Effect.succeed("hello"),
|
|
||||||
})
|
|
||||||
const dispatched = yield* ToolRuntime.dispatch(
|
|
||||||
{ tool },
|
|
||||||
LLMEvent.toolCall({ id: "call_1", itemId: "fc_existing", name: "tool", input: {} }),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(dispatched.events.find(LLMEvent.is.toolResult)?.itemId).toBeUndefined()
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -460,7 +437,7 @@ describe("LLMClient tools", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(dispatched.result).toEqual(callerOwned)
|
expect(dispatched.result).toEqual(callerOwned)
|
||||||
expect(dispatched.events).toMatchObject([
|
expect(dispatched.events).toEqual([
|
||||||
LLMEvent.toolResult({
|
LLMEvent.toolResult({
|
||||||
id: "call_1",
|
id: "call_1",
|
||||||
name: "eventful",
|
name: "eventful",
|
||||||
@@ -468,7 +445,6 @@ describe("LLMClient tools", () => {
|
|||||||
output: { structured: { ok: true }, content: [] },
|
output: { structured: { ok: true }, content: [] },
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
expect(dispatched.events[0]?.itemId).toBeUndefined()
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -688,8 +688,6 @@ export default function Page() {
|
|||||||
return {
|
return {
|
||||||
queryKey: [...vcsKey(), mode] as const,
|
queryKey: [...vcsKey(), mode] as const,
|
||||||
enabled,
|
enabled,
|
||||||
refetchOnMount: "always" as const,
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
queryFn: mode
|
queryFn: mode
|
||||||
? () =>
|
? () =>
|
||||||
sdk()
|
sdk()
|
||||||
@@ -703,16 +701,6 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100)
|
const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100)
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => desktopReviewOpen() || mobileChanges(),
|
|
||||||
(open, previous) => {
|
|
||||||
if (!open || previous || !desktopFileTreeOpen() || vcsQuery.isFetching) return
|
|
||||||
refreshVcs()
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const reviewDiffs = () => {
|
const reviewDiffs = () => {
|
||||||
if (reviewMode() === "git" || reviewMode() === "branch")
|
if (reviewMode() === "git" || reviewMode() === "branch")
|
||||||
// avoids suspense
|
// avoids suspense
|
||||||
@@ -959,6 +947,19 @@ export default function Page() {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const stopVcs = sdk().event.listen((evt) => {
|
||||||
|
const details = evt.details as { type: string; properties?: unknown }
|
||||||
|
if (details.type !== "file.watcher.updated" && details.type !== "filesystem.changed") return
|
||||||
|
const props =
|
||||||
|
typeof details.properties === "object" && details.properties
|
||||||
|
? (details.properties as Record<string, unknown>)
|
||||||
|
: undefined
|
||||||
|
const file = typeof props?.file === "string" ? props.file : undefined
|
||||||
|
if (!file || file.startsWith(".git/")) return
|
||||||
|
refreshVcs()
|
||||||
|
})
|
||||||
|
onCleanup(stopVcs)
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => sdk().directory,
|
() => sdk().directory,
|
||||||
|
|||||||
@@ -118,7 +118,6 @@
|
|||||||
"immer": "11.1.4",
|
"immer": "11.1.4",
|
||||||
"ignore": "7.0.5",
|
"ignore": "7.0.5",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"mime-types": "3.0.2",
|
|
||||||
"turndown": "7.2.0",
|
"turndown": "7.2.0",
|
||||||
"tree-sitter-bash": "0.25.0",
|
"tree-sitter-bash": "0.25.0",
|
||||||
"tree-sitter-powershell": "0.25.10",
|
"tree-sitter-powershell": "0.25.10",
|
||||||
|
|||||||
@@ -13,14 +13,12 @@ export const Plugin = define({
|
|||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const loaded = { entries: yield* config.entries() }
|
const loaded = { entries: yield* config.entries() }
|
||||||
yield* ctx.integration.transform((integrations) => {
|
yield* ctx.integration.transform((integrations) => {
|
||||||
|
const configuredIntegrations = new Set(
|
||||||
|
configuredProviders(loaded.entries).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])),
|
||||||
|
)
|
||||||
for (const [id, provider] of configuredProviders(loaded.entries)) {
|
for (const [id, provider] of configuredProviders(loaded.entries)) {
|
||||||
const integrationID = id
|
const integrationID = id
|
||||||
if (!integrations.get(integrationID)) {
|
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
|
||||||
integrations.method.update({
|
|
||||||
integrationID,
|
|
||||||
method: { type: "key", label: "Manually enter API Key" },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
integrations.update(integrationID, (integration) => {
|
integrations.update(integrationID, (integration) => {
|
||||||
integration.name = provider.name ?? integration.name
|
integration.name = provider.name ?? integration.name
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
|
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
|
||||||
import { Context, Effect, Layer } from "effect"
|
|
||||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
|
||||||
import type { Files } from "./files"
|
|
||||||
import { makeFiles } from "./index"
|
|
||||||
import { makeLocalDriver } from "./local"
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly files: Files
|
|
||||||
readonly spawner: ChildProcessSpawner["Service"]
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Environment") {}
|
|
||||||
|
|
||||||
const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const spawner = yield* ChildProcessSpawner
|
|
||||||
return Service.of({ files: makeFiles(makeLocalDriver(spawner)), spawner })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const node = makeLocationNode({ service: Service, layer, deps: [CrossSpawnSpawner.node] })
|
|
||||||
|
|
||||||
export * as EnvironmentService from "./environment"
|
|
||||||
@@ -50,13 +50,13 @@ fi
|
|||||||
`
|
`
|
||||||
|
|
||||||
const listScript = `
|
const listScript = `
|
||||||
${loadMetadata("-L")}
|
${loadMetadata()}
|
||||||
kind=\${metadata%%${TAB}*}
|
kind=\${metadata%%${TAB}*}
|
||||||
if [ "$kind" != directory ]; then
|
if [ "$kind" != directory ]; then
|
||||||
printf '%s' "$kind" >&2
|
printf '%s' "$kind" >&2
|
||||||
exit ${WRONG_KIND}
|
exit ${WRONG_KIND}
|
||||||
fi
|
fi
|
||||||
find -H "$1" -mindepth 1 -maxdepth 1 -printf '%y\\0%f\\0'
|
find "$1" -mindepth 1 -maxdepth 1 -printf '%y\\0%f\\0'
|
||||||
`
|
`
|
||||||
|
|
||||||
const moveScript = `
|
const moveScript = `
|
||||||
|
|||||||
@@ -30,8 +30,7 @@ export class Failed extends Schema.TaggedErrorClass<Failed>()("Environment.Faile
|
|||||||
|
|
||||||
export interface FilesImpl {
|
export interface FilesImpl {
|
||||||
/**
|
/**
|
||||||
* Content operations (`read`, `list`) follow final symlinks; metadata operations (`stat` and entry
|
* Reads a file, following a final symlink so `info` describes the target whose bytes are returned.
|
||||||
* tags returned by `list`) do not. `info` describes the target file whose bytes are returned.
|
|
||||||
* The process-backed default caps collected output at 64 MiB; larger whole-file reads fail with
|
* The process-backed default caps collected output at 64 MiB; larger whole-file reads fail with
|
||||||
* `Failed`, so callers must use ranges for larger files.
|
* `Failed`, so callers must use ranges for larger files.
|
||||||
*/
|
*/
|
||||||
@@ -42,7 +41,7 @@ export interface FilesImpl {
|
|||||||
readonly write: (path: string, bytes: Uint8Array) => Effect.Effect<void, Failed>
|
readonly write: (path: string, bytes: Uint8Array) => Effect.Effect<void, Failed>
|
||||||
/** Describes the path entry itself, so a final symlink is reported as `symlink` rather than followed. */
|
/** Describes the path entry itself, so a final symlink is reported as `symlink` rather than followed. */
|
||||||
readonly stat: (path: string) => Effect.Effect<FileInfo, NotFound | Failed>
|
readonly stat: (path: string) => Effect.Effect<FileInfo, NotFound | Failed>
|
||||||
/** Follows a final symlink to the listed directory while preserving each returned entry's own type. */
|
/** Lists a directory entry without following a final symlink; intermediate symlinks are traversed. */
|
||||||
readonly list: (path: string) => Effect.Effect<ReadonlyArray<DirEntry>, NotFound | WrongKind | Failed>
|
readonly list: (path: string) => Effect.Effect<ReadonlyArray<DirEntry>, NotFound | WrongKind | Failed>
|
||||||
readonly remove: (path: string) => Effect.Effect<void, Failed>
|
readonly remove: (path: string) => Effect.Effect<void, Failed>
|
||||||
readonly move: (from: string, to: string) => Effect.Effect<void, NotFound | Failed>
|
readonly move: (from: string, to: string) => Effect.Effect<void, NotFound | Failed>
|
||||||
@@ -51,20 +50,4 @@ export interface FilesImpl {
|
|||||||
|
|
||||||
export interface Files extends FilesImpl {}
|
export interface Files extends FilesImpl {}
|
||||||
|
|
||||||
/**
|
|
||||||
* Derives a follow-stat kind from the lstat-like Files contract. A dangling
|
|
||||||
* symlink fails with `NotFound`.
|
|
||||||
*/
|
|
||||||
export const typeFollowing = (files: Files, path: string) =>
|
|
||||||
files.stat(path).pipe(
|
|
||||||
Effect.flatMap((info) =>
|
|
||||||
info.type === "symlink"
|
|
||||||
? files.read(path, { offset: 0, length: 0 }).pipe(
|
|
||||||
Effect.map((result) => result.info.type),
|
|
||||||
Effect.catchTag("Environment.WrongKind", (error) => Effect.succeed(error.actual)),
|
|
||||||
)
|
|
||||||
: Effect.succeed(info.type),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
export * as EnvironmentFiles from "./files"
|
export * as EnvironmentFiles from "./files"
|
||||||
|
|||||||
@@ -9,13 +9,10 @@ export {
|
|||||||
type FilesImpl,
|
type FilesImpl,
|
||||||
type FileType,
|
type FileType,
|
||||||
NotFound,
|
NotFound,
|
||||||
typeFollowing,
|
|
||||||
WrongKind,
|
WrongKind,
|
||||||
} from "./files"
|
} from "./files"
|
||||||
export { execDefaults } from "./exec-defaults"
|
export { execDefaults } from "./exec-defaults"
|
||||||
export { makeLocalDriver } from "./local"
|
|
||||||
export { makeMemoryDriver, type MemoryDriver } from "./memory"
|
export { makeMemoryDriver, type MemoryDriver } from "./memory"
|
||||||
export { type Interface, node, Service } from "./environment"
|
|
||||||
|
|
||||||
import type { Driver } from "./driver"
|
import type { Driver } from "./driver"
|
||||||
import { execDefaults } from "./exec-defaults"
|
import { execDefaults } from "./exec-defaults"
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
import fs from "node:fs/promises"
|
|
||||||
import path from "node:path"
|
|
||||||
import { Effect } from "effect"
|
|
||||||
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
|
||||||
import type { Driver } from "./driver"
|
|
||||||
import { Failed, NotFound, WrongKind, type FileInfo, type FilesImpl, type FileType } from "./files"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The host filesystem binding. Deliberately raw node:fs rather than effect's
|
|
||||||
* FileSystem service or FSUtil: the contract needs lstat semantics (stat
|
|
||||||
* reports "symlink") and typed directory entries, and effect's node
|
|
||||||
* FileSystem provides neither — its stat always follows symlinks and
|
|
||||||
* readDirectory returns names only. FSUtil hits the same gap and its
|
|
||||||
* readDirectoryEntries already bypasses to raw node readdir internally.
|
|
||||||
* Nothing above the environment seam touches node:fs.
|
|
||||||
*/
|
|
||||||
export const makeLocalDriver = (spawner: ChildProcessSpawner["Service"]): Driver => {
|
|
||||||
const overrides: FilesImpl = {
|
|
||||||
read: (value, range) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const info = yield* stat(value, true)
|
|
||||||
if (info.type !== "file") return yield* new WrongKind({ path: value, actual: info.type })
|
|
||||||
if (range === undefined) {
|
|
||||||
const bytes = yield* attempt(value, () => fs.readFile(value), true)
|
|
||||||
return { info, bytes }
|
|
||||||
}
|
|
||||||
const bytes = yield* attempt(
|
|
||||||
value,
|
|
||||||
async () => {
|
|
||||||
const handle = await fs.open(value, "r")
|
|
||||||
try {
|
|
||||||
const buffer = new Uint8Array(range.length)
|
|
||||||
const result = await handle.read(buffer, 0, range.length, range.offset)
|
|
||||||
return buffer.subarray(0, result.bytesRead)
|
|
||||||
} finally {
|
|
||||||
await handle.close()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
return { info, bytes }
|
|
||||||
}),
|
|
||||||
stat: (value) => stat(value, false),
|
|
||||||
list: (value) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const info = yield* stat(value, true)
|
|
||||||
if (info.type !== "directory") return yield* new WrongKind({ path: value, actual: info.type })
|
|
||||||
const entries = yield* attempt(value, () => fs.readdir(value, { withFileTypes: true }), true)
|
|
||||||
return entries.map((entry) => ({ name: entry.name, type: fileType(entry) }))
|
|
||||||
}),
|
|
||||||
write: (value, bytes) =>
|
|
||||||
attempt(value, async () => {
|
|
||||||
await fs.mkdir(path.dirname(value), { recursive: true })
|
|
||||||
await fs.writeFile(value, bytes)
|
|
||||||
}),
|
|
||||||
remove: (value) => attempt(value, () => fs.rm(value, { recursive: true, force: true })),
|
|
||||||
move: (from, to) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* stat(from, false)
|
|
||||||
const destination = yield* stat(to, false).pipe(
|
|
||||||
Effect.map((info) => (info.type === "directory" ? path.join(to, path.basename(from)) : to)),
|
|
||||||
Effect.catchIf(
|
|
||||||
(error) => error instanceof NotFound,
|
|
||||||
() => Effect.succeed(to),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
yield* attempt(from, () => fs.rename(from, destination))
|
|
||||||
}),
|
|
||||||
mkdir: (value) => attempt(value, () => fs.mkdir(value, { recursive: true }).then(() => undefined)),
|
|
||||||
}
|
|
||||||
|
|
||||||
return { spawner, overrides }
|
|
||||||
}
|
|
||||||
|
|
||||||
const stat = (value: string, follow: boolean) =>
|
|
||||||
attempt(value, () => (follow ? fs.stat(value) : fs.lstat(value)), true).pipe(
|
|
||||||
Effect.map((stats): FileInfo => ({ type: fileType(stats), size: stats.size, mtimeMs: stats.mtimeMs })),
|
|
||||||
)
|
|
||||||
|
|
||||||
const fileType = (entry: { isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean }): FileType => {
|
|
||||||
if (entry.isFile()) return "file"
|
|
||||||
if (entry.isDirectory()) return "directory"
|
|
||||||
if (entry.isSymbolicLink()) return "symlink"
|
|
||||||
return "other"
|
|
||||||
}
|
|
||||||
|
|
||||||
function attempt<A>(value: string, run: () => Promise<A>): Effect.Effect<A, Failed>
|
|
||||||
function attempt<A>(value: string, run: () => Promise<A>, missing: true): Effect.Effect<A, NotFound | Failed>
|
|
||||||
function attempt<A>(value: string, run: () => Promise<A>, missing = false) {
|
|
||||||
return Effect.tryPromise({
|
|
||||||
try: run,
|
|
||||||
catch: (cause) =>
|
|
||||||
missing && isMissing(cause) ? new NotFound({ path: value }) : new Failed({ path: value, cause }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMissing = (cause: unknown) =>
|
|
||||||
cause !== null &&
|
|
||||||
typeof cause === "object" &&
|
|
||||||
"code" in cause &&
|
|
||||||
(cause.code === "ENOENT" || cause.code === "ENOTDIR")
|
|
||||||
|
|
||||||
export * as EnvironmentLocal from "./local"
|
|
||||||
@@ -90,7 +90,7 @@ export const makeMemoryDriver = (): MemoryDriver => {
|
|||||||
catch: (cause) => failed(value, cause),
|
catch: (cause) => failed(value, cause),
|
||||||
}),
|
}),
|
||||||
list: (value) => {
|
list: (value) => {
|
||||||
const target = resolveKey(value, true) ?? key(value)
|
const target = resolveKey(value, false) ?? key(value)
|
||||||
const node = nodes.get(target)
|
const node = nodes.get(target)
|
||||||
if (!node) return Effect.fail(new NotFound({ path: value }))
|
if (!node) return Effect.fail(new NotFound({ path: value }))
|
||||||
if (node.type !== "directory") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
|
if (node.type !== "directory") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import { Context, Effect, Layer } from "effect"
|
|||||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { Bom } from "@opencode-ai/util/bom"
|
import { Bom } from "@opencode-ai/util/bom"
|
||||||
import { Environment } from "./environment"
|
|
||||||
import type { Files } from "./environment"
|
|
||||||
|
|
||||||
export interface Target {
|
export interface Target {
|
||||||
readonly absolute: string
|
readonly absolute: string
|
||||||
@@ -31,36 +29,13 @@ export interface WriteResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
/** Serialize a complete read/prepare/write mutation transaction by resolved path. */
|
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
|
||||||
readonly withLock: (
|
|
||||||
targets: ReadonlyArray<string>,
|
|
||||||
) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
|
|
||||||
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, Environment.Failed>
|
|
||||||
/** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
|
/** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
|
||||||
readonly writeTextPreservingBom: (
|
readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
|
||||||
input: TextWriteInput,
|
|
||||||
) => Effect.Effect<WriteResult, Environment.WrongKind | Environment.Failed>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
|
||||||
|
|
||||||
export const readText = Effect.fn("FileMutation.readText")(function* (files: Files, target: string) {
|
|
||||||
return Bom.decodeBytes((yield* files.read(target)).bytes)
|
|
||||||
})
|
|
||||||
|
|
||||||
export const syncTextBom = Effect.fn("FileMutation.syncTextBom")(function* (
|
|
||||||
files: Files,
|
|
||||||
target: string,
|
|
||||||
bom: boolean,
|
|
||||||
) {
|
|
||||||
const synced = Bom.syncBytes((yield* files.read(target)).bytes, bom)
|
|
||||||
if (synced.bytes) yield* files.write(target, synced.bytes)
|
|
||||||
return synced.text
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Share transaction locks across Location graphs that address the same file. */
|
|
||||||
const transactionLocks = KeyedMutex.makeUnsafe<string>()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serialize file changes by absolute target. Conditional writes compare and
|
* Serialize file changes by absolute target. Conditional writes compare and
|
||||||
* write under the same process-local lock so cooperating OpenCode mutations do
|
* write under the same process-local lock so cooperating OpenCode mutations do
|
||||||
@@ -69,12 +44,8 @@ const transactionLocks = KeyedMutex.makeUnsafe<string>()
|
|||||||
const layer = Layer.effect(
|
const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const environment = yield* Environment.Service
|
const fs = yield* FSUtil.Service
|
||||||
const locks = KeyedMutex.makeUnsafe<string>()
|
const locks = KeyedMutex.makeUnsafe<string>()
|
||||||
const withLock: Interface["withLock"] = (targets) => (effect) =>
|
|
||||||
[...new Set(targets.map(FSUtil.resolve))]
|
|
||||||
.sort()
|
|
||||||
.reduceRight((result, target) => transactionLocks.withLock(target)(result), effect)
|
|
||||||
const withTargetLock =
|
const withTargetLock =
|
||||||
(target: Target) =>
|
(target: Target) =>
|
||||||
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||||
@@ -90,14 +61,8 @@ const layer = Layer.effect(
|
|||||||
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
|
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
|
||||||
withTargetLock(input.target)(
|
withTargetLock(input.target)(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const existed = yield* environment.files.stat(input.target.absolute).pipe(
|
const existed = yield* fs.exists(input.target.absolute)
|
||||||
Effect.as(true),
|
yield* fs.writeWithDirs(input.target.absolute, input.content)
|
||||||
Effect.catchTag("Environment.NotFound", () => Effect.succeed(false)),
|
|
||||||
)
|
|
||||||
yield* environment.files.write(
|
|
||||||
input.target.absolute,
|
|
||||||
typeof input.content === "string" ? new TextEncoder().encode(input.content) : input.content,
|
|
||||||
)
|
|
||||||
return writeResult(input.target, existed)
|
return writeResult(input.target, existed)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -107,24 +72,23 @@ const layer = Layer.effect(
|
|||||||
withTargetLock(input.target)(
|
withTargetLock(input.target)(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const next = Bom.split(input.content)
|
const next = Bom.split(input.content)
|
||||||
const current = yield* environment.files.read(input.target.absolute, { offset: 0, length: 3 }).pipe(
|
const current = yield* fs
|
||||||
Effect.map((result) => result.bytes),
|
.readFile(input.target.absolute)
|
||||||
Effect.catchTag("Environment.NotFound", () => Effect.succeed(undefined)),
|
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
||||||
)
|
yield* fs.writeWithDirs(
|
||||||
yield* environment.files.write(
|
|
||||||
input.target.absolute,
|
input.target.absolute,
|
||||||
new TextEncoder().encode(Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom)),
|
Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom),
|
||||||
)
|
)
|
||||||
return writeResult(input.target, current !== undefined)
|
return writeResult(input.target, current !== undefined)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
return Service.of({ withLock, write, writeTextPreservingBom })
|
return Service.of({ write, writeTextPreservingBom })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const node = makeLocationNode({ service: Service, layer, deps: [Environment.node] })
|
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deferred until the corresponding integrations exist.
|
* Deferred until the corresponding integrations exist.
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
|||||||
import { Git } from "../git"
|
import { Git } from "../git"
|
||||||
import { Location } from "../location"
|
import { Location } from "../location"
|
||||||
import { Watcher } from "./watcher"
|
import { Watcher } from "./watcher"
|
||||||
|
import { Ignore } from "./ignore"
|
||||||
|
import { Protected } from "./protected"
|
||||||
|
|
||||||
|
function protecteds(dir: string) {
|
||||||
|
return Protected.paths().filter((item) => {
|
||||||
|
const relative = path.relative(dir, item)
|
||||||
|
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export interface Interface {}
|
export interface Interface {}
|
||||||
|
|
||||||
@@ -35,6 +44,19 @@ const layer = Layer.effect(
|
|||||||
const config = (yield* configService.entries())
|
const config = (yield* configService.entries())
|
||||||
.filter((entry): entry is Document => entry.type === "document")
|
.filter((entry): entry is Document => entry.type === "document")
|
||||||
.flatMap((item) => item.info.watcher?.ignore ?? [])
|
.flatMap((item) => item.info.watcher?.ignore ?? [])
|
||||||
|
const home = Protected.isHome(location.directory)
|
||||||
|
|
||||||
|
if (!home && location.vcs) {
|
||||||
|
const updates = yield* watcher.subscribe({
|
||||||
|
path: location.directory,
|
||||||
|
type: "directory",
|
||||||
|
ignore: [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)],
|
||||||
|
})
|
||||||
|
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
|
||||||
|
}
|
||||||
|
if (home) {
|
||||||
|
yield* Effect.logInfo("location watcher skipped home directory", { directory: location.directory })
|
||||||
|
}
|
||||||
|
|
||||||
if (location.vcs?.type === "git") {
|
if (location.vcs?.type === "git") {
|
||||||
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
|
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
|
||||||
@@ -42,7 +64,10 @@ const layer = Layer.effect(
|
|||||||
? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved)))
|
? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved)))
|
||||||
: undefined
|
: undefined
|
||||||
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
|
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
|
||||||
const updates = yield* watcher.subscribe({ path: path.join(vcs, "HEAD"), type: "file" })
|
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
|
||||||
|
(entry) => (entry.name === "HEAD" ? [] : [entry.name]),
|
||||||
|
)
|
||||||
|
const updates = yield* watcher.subscribe({ path: vcs, type: "directory", ignore })
|
||||||
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
|
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
|||||||
import { Node } from "@opencode-ai/util/effect/app-node"
|
import { Node } from "@opencode-ai/util/effect/app-node"
|
||||||
import { Bus } from "./bus"
|
import { Bus } from "./bus"
|
||||||
import { FileMutation } from "./file-mutation"
|
import { FileMutation } from "./file-mutation"
|
||||||
import { Environment } from "./environment"
|
|
||||||
import { Formatter } from "./formatter"
|
import { Formatter } from "./formatter"
|
||||||
import { FileSystem } from "./filesystem"
|
import { FileSystem } from "./filesystem"
|
||||||
import { FileSystemSearch } from "./filesystem/search"
|
import { FileSystemSearch } from "./filesystem/search"
|
||||||
@@ -54,7 +53,6 @@ export { LocationServiceMap } from "./location-service-map"
|
|||||||
|
|
||||||
const locationServiceNodes = [
|
const locationServiceNodes = [
|
||||||
Location.node,
|
Location.node,
|
||||||
Environment.node,
|
|
||||||
Config.node,
|
Config.node,
|
||||||
Agent.node,
|
Agent.node,
|
||||||
Command.node,
|
Command.node,
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { ConfigReferencePlugin } from "../config/plugin/reference"
|
|||||||
import { ConfigSkillPlugin } from "../config/plugin/skill"
|
import { ConfigSkillPlugin } from "../config/plugin/skill"
|
||||||
import { ConfigWebSearchPlugin } from "../config/plugin/websearch"
|
import { ConfigWebSearchPlugin } from "../config/plugin/websearch"
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
import { Environment } from "../environment"
|
|
||||||
import { FileMutation } from "../file-mutation"
|
import { FileMutation } from "../file-mutation"
|
||||||
import { Formatter } from "../formatter"
|
import { Formatter } from "../formatter"
|
||||||
import { Form } from "../form"
|
import { Form } from "../form"
|
||||||
@@ -71,7 +70,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
|||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const credential = yield* Credential.Service
|
const credential = yield* Credential.Service
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
const environment = yield* Environment.Service
|
|
||||||
const mutation = yield* FileMutation.Service
|
const mutation = yield* FileMutation.Service
|
||||||
const formatter = yield* Formatter.Service
|
const formatter = yield* Formatter.Service
|
||||||
const filesystem = yield* FileSystem.Service
|
const filesystem = yield* FileSystem.Service
|
||||||
@@ -104,7 +102,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
|||||||
Context.make(Config.Service, config),
|
Context.make(Config.Service, config),
|
||||||
Context.make(Credential.Service, credential),
|
Context.make(Credential.Service, credential),
|
||||||
Context.make(Bus.Service, bus),
|
Context.make(Bus.Service, bus),
|
||||||
Context.make(Environment.Service, environment),
|
|
||||||
Context.make(FileMutation.Service, mutation),
|
Context.make(FileMutation.Service, mutation),
|
||||||
Context.make(Formatter.Service, formatter),
|
Context.make(Formatter.Service, formatter),
|
||||||
Context.make(FileSystem.Service, filesystem),
|
Context.make(FileSystem.Service, filesystem),
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { Credential } from "../credential"
|
|||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
import { Environment } from "../environment"
|
|
||||||
import { FileMutation } from "../file-mutation"
|
import { FileMutation } from "../file-mutation"
|
||||||
import { Formatter } from "../formatter"
|
import { Formatter } from "../formatter"
|
||||||
import { FileSystem } from "../filesystem"
|
import { FileSystem } from "../filesystem"
|
||||||
@@ -283,9 +282,7 @@ const layer = Layer.effect(
|
|||||||
})
|
})
|
||||||
const updates = Stream.merge(
|
const updates = Stream.merge(
|
||||||
config.changes().pipe(
|
config.changes().pipe(
|
||||||
Stream.filterEffect((update) =>
|
Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path))),
|
||||||
Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)),
|
|
||||||
),
|
|
||||||
Stream.merge(Stream.fromPubSub(configuredChanges)),
|
Stream.merge(Stream.fromPubSub(configuredChanges)),
|
||||||
),
|
),
|
||||||
bus.subscribe([Event.Updated, SdkPlugins.Updated]),
|
bus.subscribe([Event.Updated, SdkPlugins.Updated]),
|
||||||
@@ -323,7 +320,6 @@ export const node = makeLocationNode({
|
|||||||
Config.node,
|
Config.node,
|
||||||
Credential.node,
|
Credential.node,
|
||||||
Bus.node,
|
Bus.node,
|
||||||
Environment.node,
|
|
||||||
FileMutation.node,
|
FileMutation.node,
|
||||||
Formatter.node,
|
Formatter.node,
|
||||||
FileSystem.node,
|
FileSystem.node,
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ export * as Ripgrep from "./ripgrep"
|
|||||||
import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||||
import { ChildProcess } from "effect/unstable/process"
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
import { Entry, Match } from "@opencode-ai/schema/filesystem"
|
import { Entry, Match } from "@opencode-ai/schema/filesystem"
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { collectStream, waitForAbort } from "@opencode-ai/util/process"
|
import { AppProcess, collectStream, waitForAbort } from "@opencode-ai/util/process"
|
||||||
import { Environment } from "./environment"
|
|
||||||
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
|
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
|
||||||
import { RipgrepBinary } from "./ripgrep/binary"
|
import { RipgrepBinary } from "./ripgrep/binary"
|
||||||
|
|
||||||
@@ -94,7 +93,7 @@ const isInvalidPattern = (stderr: string) =>
|
|||||||
const layer = Layer.effect(
|
const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const environment = yield* Environment.Service
|
const process = yield* AppProcess.Service
|
||||||
const binary = yield* RipgrepBinary.Service
|
const binary = yield* RipgrepBinary.Service
|
||||||
|
|
||||||
const run = <A>(input: {
|
const run = <A>(input: {
|
||||||
@@ -108,8 +107,7 @@ const layer = Layer.effect(
|
|||||||
}) => {
|
}) => {
|
||||||
const program = Effect.scoped(
|
const program = Effect.scoped(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
// Hosted environments will resolve rg through their driver image; the spawner is the execution seam.
|
const handle = yield* process.spawn(
|
||||||
const handle = yield* environment.spawner.spawn(
|
|
||||||
ChildProcess.make(yield* binary.filepath, input.args, { cwd: input.cwd, extendEnv: true, stdin: "ignore" }),
|
ChildProcess.make(yield* binary.filepath, input.args, { cwd: input.cwd, extendEnv: true, stdin: "ignore" }),
|
||||||
)
|
)
|
||||||
const stderrFiber = yield* collectStream(handle.stderr, ERROR_BYTES).pipe(
|
const stderrFiber = yield* collectStream(handle.stderr, ERROR_BYTES).pipe(
|
||||||
@@ -277,4 +275,4 @@ const layer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const node = makeLocationNode({ service: Service, layer, deps: [Environment.node, RipgrepBinary.node] })
|
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] })
|
||||||
|
|||||||
@@ -2,14 +2,9 @@ export * as SessionRestart from "./restart"
|
|||||||
|
|
||||||
import { Context, Effect, Layer } from "effect"
|
import { Context, Effect, Layer } from "effect"
|
||||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { Bus } from "../../bus"
|
|
||||||
import { SessionEvent } from "../event"
|
|
||||||
import { SessionExecution } from "../execution"
|
import { SessionExecution } from "../execution"
|
||||||
import { SessionStore } from "../store"
|
import { SessionStore } from "../store"
|
||||||
|
|
||||||
const CONTINUE_AFTER_SERVER_RESTART =
|
|
||||||
"The server restarted while you were working. Continue from where you left off without repeating completed work."
|
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
/**
|
/**
|
||||||
* Marks every execution active in this process for resumption by the next server start.
|
* Marks every execution active in this process for resumption by the next server start.
|
||||||
@@ -31,7 +26,6 @@ export const layer = Layer.effect(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const store = yield* SessionStore.Service
|
const store = yield* SessionStore.Service
|
||||||
const execution = yield* SessionExecution.Service
|
const execution = yield* SessionExecution.Service
|
||||||
const bus = yield* Bus.Service
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
suspendActiveSessions: Effect.gen(function* () {
|
suspendActiveSessions: Effect.gen(function* () {
|
||||||
yield* store.suspend(yield* execution.active)
|
yield* store.suspend(yield* execution.active)
|
||||||
@@ -43,11 +37,6 @@ export const layer = Layer.effect(
|
|||||||
(sessionID) =>
|
(sessionID) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (!(yield* store.consumeSuspended(sessionID))) return
|
if (!(yield* store.consumeSuspended(sessionID))) return
|
||||||
yield* bus.publish(SessionEvent.Synthetic, {
|
|
||||||
sessionID,
|
|
||||||
text: CONTINUE_AFTER_SERVER_RESTART,
|
|
||||||
description: "Continuing after restart",
|
|
||||||
})
|
|
||||||
// Drain failures are already logged and durably recorded by the execution layer.
|
// Drain failures are already logged and durably recorded by the execution layer.
|
||||||
yield* Effect.ignore(execution.resume(sessionID))
|
yield* Effect.ignore(execution.resume(sessionID))
|
||||||
}),
|
}),
|
||||||
@@ -58,8 +47,4 @@ export const layer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const node = makeGlobalNode({
|
export const node = makeGlobalNode({ service: Service, layer, deps: [SessionStore.node, SessionExecution.node] })
|
||||||
service: Service,
|
|
||||||
layer,
|
|
||||||
deps: [SessionStore.node, SessionExecution.node, Bus.node],
|
|
||||||
})
|
|
||||||
|
|||||||
+270
-269
@@ -6,9 +6,9 @@ import { ChildProcess } from "effect/unstable/process"
|
|||||||
import { produce } from "immer"
|
import { produce } from "immer"
|
||||||
import { Shell } from "@opencode-ai/schema/shell"
|
import { Shell } from "@opencode-ai/schema/shell"
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
|
import { AppProcess } from "@opencode-ai/util/process"
|
||||||
import { Config } from "./config"
|
import { Config } from "./config"
|
||||||
import { Bus } from "./bus"
|
import { Bus } from "./bus"
|
||||||
import { Environment } from "./environment"
|
|
||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { Global } from "@opencode-ai/util/global"
|
import { Global } from "@opencode-ai/util/global"
|
||||||
import { ShellSelect } from "./shell/select"
|
import { ShellSelect } from "./shell/select"
|
||||||
@@ -65,284 +65,285 @@ export interface Interface {
|
|||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Shell") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Shell") {}
|
||||||
|
|
||||||
export const layer = (options?: ShellSelect.Options) =>
|
export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
||||||
Layer.effect(
|
Service,
|
||||||
Service,
|
Effect.gen(function* () {
|
||||||
Effect.gen(function* () {
|
const bus = yield* Bus.Service
|
||||||
const bus = yield* Bus.Service
|
const location = yield* Location.Service
|
||||||
const location = yield* Location.Service
|
const config = yield* Config.Service
|
||||||
const config = yield* Config.Service
|
const global = yield* Global.Service
|
||||||
const global = yield* Global.Service
|
const appProcess = yield* AppProcess.Service
|
||||||
const environment = yield* Environment.Service
|
const hooks = yield* PluginHooks.Service
|
||||||
const hooks = yield* PluginHooks.Service
|
const context = yield* Effect.context()
|
||||||
const context = yield* Effect.context()
|
const runFork = Effect.runForkWith(context)
|
||||||
const runFork = Effect.runForkWith(context)
|
const sessions = new Map<string, Active>()
|
||||||
const sessions = new Map<string, Active>()
|
const exitOrder: string[] = []
|
||||||
const exitOrder: string[] = []
|
|
||||||
|
|
||||||
const outputDir = path.join(global.data, "shell", location.project.id)
|
const outputDir = path.join(global.data, "shell", location.project.id)
|
||||||
const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises"))
|
const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises"))
|
||||||
const { createWriteStream, createReadStream } = yield* Effect.promise(() => import("fs"))
|
const { createWriteStream, createReadStream } = yield* Effect.promise(() => import("fs"))
|
||||||
yield* Effect.promise(() => mkdir(outputDir, { recursive: true }))
|
yield* Effect.promise(() => mkdir(outputDir, { recursive: true }))
|
||||||
|
|
||||||
yield* Effect.addFinalizer(() =>
|
yield* Effect.addFinalizer(() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
for (const session of sessions.values()) {
|
for (const session of sessions.values()) {
|
||||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||||
// Unblock waiters still pending at teardown; succeed is a no-op once already resolved.
|
// Unblock waiters still pending at teardown; succeed is a no-op once already resolved.
|
||||||
yield* Deferred.fail(session.done, new NotFoundError({ id: Shell.ID.make(session.info.id) }))
|
yield* Deferred.fail(session.done, new NotFoundError({ id: Shell.ID.make(session.info.id) }))
|
||||||
}
|
}
|
||||||
sessions.clear()
|
sessions.clear()
|
||||||
exitOrder.length = 0
|
exitOrder.length = 0
|
||||||
}),
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const require = Effect.fn("Shell.require")(function* (id: Shell.ID) {
|
||||||
|
const session = sessions.get(id)
|
||||||
|
if (!session) return yield* new NotFoundError({ id })
|
||||||
|
return session
|
||||||
|
})
|
||||||
|
|
||||||
|
const removeSession = Effect.fnUntraced(function* (id: Shell.ID) {
|
||||||
|
const session = sessions.get(id)
|
||||||
|
if (!session) return
|
||||||
|
sessions.delete(id)
|
||||||
|
const index = exitOrder.indexOf(id)
|
||||||
|
if (index !== -1) exitOrder.splice(index, 1)
|
||||||
|
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||||
|
// Unblock any wait still pending when the command is removed before it terminated.
|
||||||
|
yield* Deferred.fail(session.done, new NotFoundError({ id }))
|
||||||
|
yield* Effect.promise(() => unlink(session.file).catch(() => {}))
|
||||||
|
yield* bus.publish(Shell.Event.Deleted, { id })
|
||||||
|
})
|
||||||
|
|
||||||
|
const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) {
|
||||||
|
yield* require(id)
|
||||||
|
yield* removeSession(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = Effect.fn("Shell.list")(function* () {
|
||||||
|
return Array.from(sessions.values())
|
||||||
|
.filter((session) => session.info.status === "running")
|
||||||
|
.map((session) => session.info)
|
||||||
|
})
|
||||||
|
|
||||||
|
const get = Effect.fn("Shell.get")(function* (id: Shell.ID) {
|
||||||
|
return (yield* require(id)).info
|
||||||
|
})
|
||||||
|
|
||||||
|
const wait = Effect.fn("Shell.wait")(function* (id: Shell.ID) {
|
||||||
|
return yield* Deferred.await((yield* require(id)).done)
|
||||||
|
})
|
||||||
|
|
||||||
|
const timeout = Effect.fn("Shell.timeout")(function* (id: Shell.ID, duration: number) {
|
||||||
|
const session = yield* require(id)
|
||||||
|
if (session.info.status !== "running" || !session.timeout) return session.info
|
||||||
|
yield* session.timeout(duration)
|
||||||
|
return session.info
|
||||||
|
})
|
||||||
|
|
||||||
|
const resolve = () =>
|
||||||
|
config
|
||||||
|
.entries()
|
||||||
|
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options)))
|
||||||
|
|
||||||
|
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
|
||||||
|
|
||||||
|
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
|
||||||
|
const session = yield* require(id)
|
||||||
|
const cursor = input?.cursor ?? 0
|
||||||
|
const limit = input?.limit ?? 65536
|
||||||
|
if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false }
|
||||||
|
const start = Math.max(0, cursor)
|
||||||
|
const length = Math.min(limit, session.size - start)
|
||||||
|
const buffer = Buffer.alloc(length)
|
||||||
|
const bytesRead = yield* Effect.promise(
|
||||||
|
() =>
|
||||||
|
new Promise<number>((resolve) => {
|
||||||
|
const stream = createReadStream(session.file, { start, end: start + length - 1 })
|
||||||
|
let offset = 0
|
||||||
|
stream.on("data", (chunk: string | Buffer) => {
|
||||||
|
const bytes = Buffer.from(chunk)
|
||||||
|
bytes.copy(buffer, offset)
|
||||||
|
offset += bytes.length
|
||||||
|
})
|
||||||
|
stream.on("end", () => resolve(offset))
|
||||||
|
stream.on("error", () => resolve(0))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
output: buffer.subarray(0, bytesRead).toString("utf8"),
|
||||||
|
cursor: start + bytesRead,
|
||||||
|
size: session.size,
|
||||||
|
truncated: false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
|
||||||
|
input: Shell.CreateInput,
|
||||||
|
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
|
||||||
|
) {
|
||||||
|
const invocation: ShellCreateBefore = {
|
||||||
|
command: input.command,
|
||||||
|
cwd: input.cwd ?? location.directory,
|
||||||
|
timeout: input.timeout,
|
||||||
|
shell: yield* resolve(),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
TERM: "xterm-256color",
|
||||||
|
OPENCODE_TERMINAL: "1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
yield* hooks.trigger("shell", "create.before", invocation)
|
||||||
|
if (before) yield* before(invocation)
|
||||||
|
|
||||||
|
const id = Shell.ID.ascending()
|
||||||
|
const args = ShellSelect.args(invocation.shell, invocation.command)
|
||||||
|
const file = path.join(outputDir, `${id}.out`)
|
||||||
|
|
||||||
|
const info: Info = {
|
||||||
|
id,
|
||||||
|
status: "running",
|
||||||
|
command: invocation.command,
|
||||||
|
cwd: invocation.cwd,
|
||||||
|
shell: invocation.shell,
|
||||||
|
file,
|
||||||
|
metadata: input.metadata ?? {},
|
||||||
|
time: { started: Date.now() },
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn via AppProcess and stream combined output to the file. The handle is scope-bound, so
|
||||||
|
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
|
||||||
|
// end). `create` returns once `ready` resolves with the registered session.
|
||||||
|
const ready = Deferred.makeUnsafe<Active>()
|
||||||
|
runFork(
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const handle = yield* appProcess.spawn(
|
||||||
|
ChildProcess.make(invocation.shell, args, {
|
||||||
|
cwd: invocation.cwd,
|
||||||
|
env: invocation.env,
|
||||||
|
stdin: "ignore",
|
||||||
|
detached: process.platform !== "win32",
|
||||||
|
forceKillAfter: Duration.seconds(3),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const session: Active = {
|
||||||
|
info: produce(info, (draft) => {
|
||||||
|
draft.pid = handle.pid
|
||||||
|
}),
|
||||||
|
file,
|
||||||
|
size: 0,
|
||||||
|
done: Deferred.makeUnsafe<Info, NotFoundError>(),
|
||||||
|
}
|
||||||
|
sessions.set(id, session)
|
||||||
|
|
||||||
|
const stream = createWriteStream(file)
|
||||||
|
const outputDone = Deferred.makeUnsafe<void>()
|
||||||
|
const pump = handle.all.pipe(
|
||||||
|
Stream.runForEach((chunk: Uint8Array) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
stream.write(chunk)
|
||||||
|
session.size += chunk.length
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
runFork(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* pump.pipe(Effect.catch(() => Effect.void))
|
||||||
|
yield* Effect.promise(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
stream.end(() => resolve())
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
yield* Deferred.succeed(outputDone, undefined)
|
||||||
|
}).pipe(Effect.catch(() => Deferred.succeed(outputDone, undefined))),
|
||||||
|
)
|
||||||
|
yield* Effect.promise(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
stream.once("open", () => resolve())
|
||||||
|
stream.once("error", () => resolve())
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (session.info.status !== "running") return
|
||||||
|
session.info = produce(session.info, (draft) => {
|
||||||
|
draft.status = status
|
||||||
|
if (exit !== undefined) draft.exit = exit
|
||||||
|
draft.time.completed = Date.now()
|
||||||
|
})
|
||||||
|
yield* beforeWait
|
||||||
|
yield* Deferred.await(outputDone)
|
||||||
|
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
|
||||||
|
// session still reports success rather than the removal NotFoundError. This runs before
|
||||||
|
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
|
||||||
|
// this very fiber (finish is invoked by the timeout fiber) before waiters are resolved.
|
||||||
|
yield* Deferred.succeed(session.done, session.info)
|
||||||
|
yield* bus.publish(Shell.Event.Exited, {
|
||||||
|
id,
|
||||||
|
...(exit !== undefined ? { exit } : {}),
|
||||||
|
status,
|
||||||
|
})
|
||||||
|
exitOrder.push(id)
|
||||||
|
while (exitOrder.length > EXITED_LIMIT) {
|
||||||
|
const oldest = exitOrder[0]
|
||||||
|
if (!oldest) break
|
||||||
|
yield* removeSession(Shell.ID.make(oldest))
|
||||||
|
}
|
||||||
|
// Cancel a pending timeout once the command exits on its own. Interrupting last avoids
|
||||||
|
// aborting finish when finish itself runs on the timeout fiber.
|
||||||
|
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||||
|
})
|
||||||
|
|
||||||
|
session.timeout = (duration) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||||
|
session.timeoutFiber = undefined
|
||||||
|
if (duration === 0 || session.info.status !== "running") return
|
||||||
|
session.timeoutFiber = runFork(
|
||||||
|
Effect.sleep(Duration.millis(duration)).pipe(
|
||||||
|
Effect.flatMap(() =>
|
||||||
|
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
|
||||||
|
),
|
||||||
|
Effect.catch(() => Effect.void),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* session.timeout(invocation.timeout)
|
||||||
|
|
||||||
|
runFork(
|
||||||
|
handle.exitCode.pipe(
|
||||||
|
Effect.flatMap((code) => finish("exited", code)),
|
||||||
|
Effect.catch(() => Effect.void),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* bus.publish(Shell.Event.Created, { info })
|
||||||
|
yield* Deferred.succeed(ready, session)
|
||||||
|
// Hold the handle's scope open until the command terminates; closing it earlier would
|
||||||
|
// release (kill) the process before its exit is observed.
|
||||||
|
yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void))
|
||||||
|
}),
|
||||||
|
).pipe(Effect.catch(() => Effect.void)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const require = Effect.fn("Shell.require")(function* (id: Shell.ID) {
|
const session = yield* Deferred.await(ready)
|
||||||
const session = sessions.get(id)
|
return session.info
|
||||||
if (!session) return yield* new NotFoundError({ id })
|
})
|
||||||
return session
|
|
||||||
})
|
|
||||||
|
|
||||||
const removeSession = Effect.fnUntraced(function* (id: Shell.ID) {
|
return Service.of({ name, create, list, get, wait, timeout, output, remove })
|
||||||
const session = sessions.get(id)
|
}),
|
||||||
if (!session) return
|
)
|
||||||
sessions.delete(id)
|
|
||||||
const index = exitOrder.indexOf(id)
|
|
||||||
if (index !== -1) exitOrder.splice(index, 1)
|
|
||||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
|
||||||
// Unblock any wait still pending when the command is removed before it terminated.
|
|
||||||
yield* Deferred.fail(session.done, new NotFoundError({ id }))
|
|
||||||
yield* Effect.promise(() => unlink(session.file).catch(() => {}))
|
|
||||||
yield* bus.publish(Shell.Event.Deleted, { id })
|
|
||||||
})
|
|
||||||
|
|
||||||
const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) {
|
|
||||||
yield* require(id)
|
|
||||||
yield* removeSession(id)
|
|
||||||
})
|
|
||||||
|
|
||||||
const list = Effect.fn("Shell.list")(function* () {
|
|
||||||
return Array.from(sessions.values())
|
|
||||||
.filter((session) => session.info.status === "running")
|
|
||||||
.map((session) => session.info)
|
|
||||||
})
|
|
||||||
|
|
||||||
const get = Effect.fn("Shell.get")(function* (id: Shell.ID) {
|
|
||||||
return (yield* require(id)).info
|
|
||||||
})
|
|
||||||
|
|
||||||
const wait = Effect.fn("Shell.wait")(function* (id: Shell.ID) {
|
|
||||||
return yield* Deferred.await((yield* require(id)).done)
|
|
||||||
})
|
|
||||||
|
|
||||||
const timeout = Effect.fn("Shell.timeout")(function* (id: Shell.ID, duration: number) {
|
|
||||||
const session = yield* require(id)
|
|
||||||
if (session.info.status !== "running" || !session.timeout) return session.info
|
|
||||||
yield* session.timeout(duration)
|
|
||||||
return session.info
|
|
||||||
})
|
|
||||||
|
|
||||||
const resolve = () =>
|
|
||||||
config.entries().pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options)))
|
|
||||||
|
|
||||||
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
|
|
||||||
|
|
||||||
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
|
|
||||||
const session = yield* require(id)
|
|
||||||
const cursor = input?.cursor ?? 0
|
|
||||||
const limit = input?.limit ?? 65536
|
|
||||||
if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false }
|
|
||||||
const start = Math.max(0, cursor)
|
|
||||||
const length = Math.min(limit, session.size - start)
|
|
||||||
const buffer = Buffer.alloc(length)
|
|
||||||
const bytesRead = yield* Effect.promise(
|
|
||||||
() =>
|
|
||||||
new Promise<number>((resolve) => {
|
|
||||||
const stream = createReadStream(session.file, { start, end: start + length - 1 })
|
|
||||||
let offset = 0
|
|
||||||
stream.on("data", (chunk: string | Buffer) => {
|
|
||||||
const bytes = Buffer.from(chunk)
|
|
||||||
bytes.copy(buffer, offset)
|
|
||||||
offset += bytes.length
|
|
||||||
})
|
|
||||||
stream.on("end", () => resolve(offset))
|
|
||||||
stream.on("error", () => resolve(0))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
output: buffer.subarray(0, bytesRead).toString("utf8"),
|
|
||||||
cursor: start + bytesRead,
|
|
||||||
size: session.size,
|
|
||||||
truncated: false,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
|
|
||||||
input: Shell.CreateInput,
|
|
||||||
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
|
|
||||||
) {
|
|
||||||
const invocation: ShellCreateBefore = {
|
|
||||||
command: input.command,
|
|
||||||
cwd: input.cwd ?? location.directory,
|
|
||||||
timeout: input.timeout,
|
|
||||||
shell: yield* resolve(),
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
TERM: "xterm-256color",
|
|
||||||
OPENCODE_TERMINAL: "1",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
yield* hooks.trigger("shell", "create.before", invocation)
|
|
||||||
if (before) yield* before(invocation)
|
|
||||||
|
|
||||||
const id = Shell.ID.ascending()
|
|
||||||
const args = ShellSelect.args(invocation.shell, invocation.command)
|
|
||||||
const file = path.join(outputDir, `${id}.out`)
|
|
||||||
|
|
||||||
const info: Info = {
|
|
||||||
id,
|
|
||||||
status: "running",
|
|
||||||
command: invocation.command,
|
|
||||||
cwd: invocation.cwd,
|
|
||||||
shell: invocation.shell,
|
|
||||||
file,
|
|
||||||
metadata: input.metadata ?? {},
|
|
||||||
time: { started: Date.now() },
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spawn through the Environment and stream combined output to the file. The handle is scope-bound, so
|
|
||||||
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
|
|
||||||
// end). `create` returns once `ready` resolves with the registered session.
|
|
||||||
const ready = Deferred.makeUnsafe<Active>()
|
|
||||||
runFork(
|
|
||||||
Effect.scoped(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const handle = yield* environment.spawner.spawn(
|
|
||||||
ChildProcess.make(invocation.shell, args, {
|
|
||||||
cwd: invocation.cwd,
|
|
||||||
env: invocation.env,
|
|
||||||
stdin: "ignore",
|
|
||||||
detached: process.platform !== "win32",
|
|
||||||
forceKillAfter: Duration.seconds(3),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const session: Active = {
|
|
||||||
info: produce(info, (draft) => {
|
|
||||||
draft.pid = handle.pid
|
|
||||||
}),
|
|
||||||
file,
|
|
||||||
size: 0,
|
|
||||||
done: Deferred.makeUnsafe<Info, NotFoundError>(),
|
|
||||||
}
|
|
||||||
sessions.set(id, session)
|
|
||||||
|
|
||||||
const stream = createWriteStream(file)
|
|
||||||
const outputDone = Deferred.makeUnsafe<void>()
|
|
||||||
const pump = handle.all.pipe(
|
|
||||||
Stream.runForEach((chunk: Uint8Array) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
stream.write(chunk)
|
|
||||||
session.size += chunk.length
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
runFork(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* pump.pipe(Effect.catch(() => Effect.void))
|
|
||||||
yield* Effect.promise(
|
|
||||||
() =>
|
|
||||||
new Promise<void>((resolve) => {
|
|
||||||
stream.end(() => resolve())
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
yield* Deferred.succeed(outputDone, undefined)
|
|
||||||
}).pipe(Effect.catch(() => Deferred.succeed(outputDone, undefined))),
|
|
||||||
)
|
|
||||||
yield* Effect.promise(
|
|
||||||
() =>
|
|
||||||
new Promise<void>((resolve) => {
|
|
||||||
stream.once("open", () => resolve())
|
|
||||||
stream.once("error", () => resolve())
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
if (session.info.status !== "running") return
|
|
||||||
session.info = produce(session.info, (draft) => {
|
|
||||||
draft.status = status
|
|
||||||
if (exit !== undefined) draft.exit = exit
|
|
||||||
draft.time.completed = Date.now()
|
|
||||||
})
|
|
||||||
yield* beforeWait
|
|
||||||
yield* Deferred.await(outputDone)
|
|
||||||
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
|
|
||||||
// session still reports success rather than the removal NotFoundError. This runs before
|
|
||||||
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
|
|
||||||
// this very fiber (finish is invoked by the timeout fiber) before waiters are resolved.
|
|
||||||
yield* Deferred.succeed(session.done, session.info)
|
|
||||||
yield* bus.publish(Shell.Event.Exited, {
|
|
||||||
id,
|
|
||||||
...(exit !== undefined ? { exit } : {}),
|
|
||||||
status,
|
|
||||||
})
|
|
||||||
exitOrder.push(id)
|
|
||||||
while (exitOrder.length > EXITED_LIMIT) {
|
|
||||||
const oldest = exitOrder[0]
|
|
||||||
if (!oldest) break
|
|
||||||
yield* removeSession(Shell.ID.make(oldest))
|
|
||||||
}
|
|
||||||
// Cancel a pending timeout once the command exits on its own. Interrupting last avoids
|
|
||||||
// aborting finish when finish itself runs on the timeout fiber.
|
|
||||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
|
||||||
})
|
|
||||||
|
|
||||||
session.timeout = (duration) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
|
||||||
session.timeoutFiber = undefined
|
|
||||||
if (duration === 0 || session.info.status !== "running") return
|
|
||||||
session.timeoutFiber = runFork(
|
|
||||||
Effect.sleep(Duration.millis(duration)).pipe(
|
|
||||||
Effect.flatMap(() =>
|
|
||||||
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
|
|
||||||
),
|
|
||||||
Effect.catch(() => Effect.void),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
yield* session.timeout(invocation.timeout)
|
|
||||||
|
|
||||||
runFork(
|
|
||||||
handle.exitCode.pipe(
|
|
||||||
Effect.flatMap((code) => finish("exited", code)),
|
|
||||||
Effect.catch(() => Effect.void),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
yield* bus.publish(Shell.Event.Created, { info })
|
|
||||||
yield* Deferred.succeed(ready, session)
|
|
||||||
// Hold the handle's scope open until the command terminates; closing it earlier would
|
|
||||||
// release (kill) the process before its exit is observed.
|
|
||||||
yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void))
|
|
||||||
}),
|
|
||||||
).pipe(Effect.catch(() => Effect.void)),
|
|
||||||
)
|
|
||||||
|
|
||||||
const session = yield* Deferred.await(ready)
|
|
||||||
return session.info
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ name, create, list, get, wait, timeout, output, remove })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export function configured(options?: ShellSelect.Options) {
|
export function configured(options?: ShellSelect.Options) {
|
||||||
return makeLocationNode({
|
return makeLocationNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer: layer(options),
|
layer: layer(options),
|
||||||
deps: [Bus.node, Location.node, Config.node, Global.node, Environment.node, PluginHooks.node],
|
deps: [Bus.node, Location.node, Config.node, Global.node, AppProcess.node, PluginHooks.node],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+34
-105
@@ -2,7 +2,8 @@ export * as Skill from "./skill"
|
|||||||
|
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Context, Effect, FiberMap, Layer, PubSub, Schema, Semaphore, Stream, Types } from "effect"
|
import { Context, Effect, Layer, Schema, Stream, Types } from "effect"
|
||||||
|
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||||
import { Skill } from "@opencode-ai/schema/skill"
|
import { Skill } from "@opencode-ai/schema/skill"
|
||||||
import { Agent } from "./agent"
|
import { Agent } from "./agent"
|
||||||
import { ConfigMarkdown } from "./config/markdown"
|
import { ConfigMarkdown } from "./config/markdown"
|
||||||
@@ -12,7 +13,6 @@ import { Permission } from "./permission"
|
|||||||
import { AbsolutePath } from "./schema"
|
import { AbsolutePath } from "./schema"
|
||||||
import { SkillDiscovery } from "./skill/discovery"
|
import { SkillDiscovery } from "./skill/discovery"
|
||||||
import { State } from "./state"
|
import { State } from "./state"
|
||||||
import { Watcher } from "./filesystem/watcher"
|
|
||||||
|
|
||||||
export const DirectorySource = Skill.DirectorySource
|
export const DirectorySource = Skill.DirectorySource
|
||||||
export type DirectorySource = Skill.DirectorySource
|
export type DirectorySource = Skill.DirectorySource
|
||||||
@@ -81,82 +81,6 @@ const layer = Layer.effect(
|
|||||||
const discovery = yield* SkillDiscovery.Service
|
const discovery = yield* SkillDiscovery.Service
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
const watcher = yield* Watcher.Service
|
|
||||||
const cache = new Map<string, { skills: Info[]; paths: readonly string[] }>()
|
|
||||||
const watches = yield* FiberMap.make<string>()
|
|
||||||
const lock = Semaphore.makeUnsafe(1)
|
|
||||||
const changes = yield* PubSub.unbounded<string>()
|
|
||||||
|
|
||||||
const invalidate = Effect.fn("Skill.invalidateFromWatcher")(function* (file: string) {
|
|
||||||
const changed = yield* lock.withPermit(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const invalidated = Array.from(cache.entries()).filter(([, loaded]) =>
|
|
||||||
loaded.paths.some((item) => FSUtil.overlaps(item, file)),
|
|
||||||
)
|
|
||||||
if (invalidated.length === 0) return false
|
|
||||||
cache.clear()
|
|
||||||
yield* FiberMap.clear(watches)
|
|
||||||
yield* Effect.logInfo("skill cache invalidated", {
|
|
||||||
file,
|
|
||||||
sources: invalidated.map(([key]) => key),
|
|
||||||
skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.id)),
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
if (!changed) return
|
|
||||||
yield* bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid)
|
|
||||||
})
|
|
||||||
|
|
||||||
yield* Stream.fromPubSub(changes).pipe(Stream.runForEach(invalidate), Effect.forkScoped({ startImmediately: true }))
|
|
||||||
|
|
||||||
const watch = Effect.fn("Skill.watch")(function* (directory: string, type: Watcher.WatchInput["type"]) {
|
|
||||||
const target = path.resolve(directory)
|
|
||||||
const updates = yield* watcher.subscribe(
|
|
||||||
type === "file" ? { path: target, type: "file" } : { path: target, type: "directory" },
|
|
||||||
)
|
|
||||||
yield* FiberMap.run(
|
|
||||||
watches,
|
|
||||||
`${type}:${target}`,
|
|
||||||
updates.pipe(Stream.runForEach((update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid))),
|
|
||||||
{
|
|
||||||
onlyIfMissing: true,
|
|
||||||
startImmediately: true,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
function firstMissing(target: string): Effect.Effect<string | undefined> {
|
|
||||||
const parent = path.dirname(target)
|
|
||||||
if (parent === target) return Effect.succeed(undefined)
|
|
||||||
return fs.isDir(parent).pipe(Effect.flatMap((exists) => (exists ? Effect.succeed(target) : firstMissing(parent))))
|
|
||||||
}
|
|
||||||
|
|
||||||
const watchDirectory: (directory: string) => Effect.Effect<string[]> = Effect.fn("Skill.watchDirectory")(function* (
|
|
||||||
directory: string,
|
|
||||||
) {
|
|
||||||
const target = path.resolve(directory)
|
|
||||||
const resolved = yield* fs.realPath(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
||||||
if (resolved) {
|
|
||||||
yield* watch(resolved, "directory")
|
|
||||||
if (resolved !== target) {
|
|
||||||
yield* watch(target, "file")
|
|
||||||
}
|
|
||||||
return resolved === target ? [target] : [target, resolved]
|
|
||||||
}
|
|
||||||
const missing = yield* firstMissing(target)
|
|
||||||
if (missing) yield* watch(missing, "file")
|
|
||||||
if (
|
|
||||||
yield* fs.realPath(directory).pipe(
|
|
||||||
Effect.as(true),
|
|
||||||
Effect.catch(() => Effect.succeed(false)),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
if (missing) yield* FiberMap.remove(watches, `file:${path.resolve(missing)}`)
|
|
||||||
return yield* watchDirectory(directory)
|
|
||||||
}
|
|
||||||
return [target]
|
|
||||||
})
|
|
||||||
|
|
||||||
const state = State.create<Data, Draft>({
|
const state = State.create<Data, Draft>({
|
||||||
name: "skill",
|
name: "skill",
|
||||||
@@ -168,10 +92,7 @@ const layer = Layer.effect(
|
|||||||
},
|
},
|
||||||
list: () => draft.sources as Source[],
|
list: () => draft.sources as Source[],
|
||||||
}),
|
}),
|
||||||
finalize: () =>
|
finalize: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
|
||||||
lock
|
|
||||||
.withPermit(FiberMap.clear(watches).pipe(Effect.andThen(Effect.sync(() => cache.clear())), Effect.asVoid))
|
|
||||||
.pipe(Effect.andThen(bus.publish(Skill.Event.Updated, {})), Effect.asVoid),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const load = Effect.fn("Skill.load")(function* (source: Source) {
|
const load = Effect.fn("Skill.load")(function* (source: Source) {
|
||||||
@@ -183,22 +104,14 @@ const layer = Layer.effect(
|
|||||||
directories: [],
|
directories: [],
|
||||||
skills: [source.skill.id],
|
skills: [source.skill.id],
|
||||||
})
|
})
|
||||||
return { skills: [source.skill], paths: [] }
|
return { skills: [source.skill], directories: [] }
|
||||||
}
|
}
|
||||||
const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url)
|
const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url)
|
||||||
const roots = (yield* Effect.forEach(directories, watchDirectory)).flat()
|
|
||||||
const paths = [...roots]
|
|
||||||
for (const directory of directories) {
|
for (const directory of directories) {
|
||||||
const files = yield* fs
|
const files = yield* fs
|
||||||
.scan("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true })
|
.scan("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true })
|
||||||
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||||
for (const filepath of files.toSorted()) {
|
for (const filepath of files.toSorted()) {
|
||||||
const resolved = yield* fs.realPath(filepath).pipe(Effect.catch(() => Effect.succeed(filepath)))
|
|
||||||
if (!roots.some((root) => FSUtil.contains(root, resolved))) {
|
|
||||||
const external = path.dirname(resolved)
|
|
||||||
paths.push(external)
|
|
||||||
yield* watch(external, "directory")
|
|
||||||
}
|
|
||||||
const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
if (!content) continue
|
if (!content) continue
|
||||||
const markdown = ConfigMarkdown.parseOption(content)
|
const markdown = ConfigMarkdown.parseOption(content)
|
||||||
@@ -226,22 +139,38 @@ const layer = Layer.effect(
|
|||||||
directories,
|
directories,
|
||||||
skills: skills.map((skill) => skill.id),
|
skills: skills.map((skill) => skill.id),
|
||||||
})
|
})
|
||||||
return { skills, paths }
|
return { skills, directories }
|
||||||
})
|
})
|
||||||
|
|
||||||
const list = Effect.fn("Skill.list")(function* () {
|
const cache = new Map<string, { skills: Info[]; directories: readonly string[] }>()
|
||||||
return yield* lock.withPermit(
|
const invalidate = Effect.fn("Skill.invalidateFromWatcher")(function* (file: string) {
|
||||||
Effect.gen(function* () {
|
const invalidated = Array.from(cache.entries()).filter(([, loaded]) =>
|
||||||
const skills = new Map<ID, Info>()
|
loaded.directories.some((directory) => FSUtil.contains(directory, file)),
|
||||||
for (const source of state.get().sources) {
|
|
||||||
const key = Source.key(source)
|
|
||||||
const loaded = cache.get(key) ?? (yield* load(source))
|
|
||||||
cache.set(key, loaded)
|
|
||||||
for (const skill of loaded.skills) skills.set(skill.id, skill)
|
|
||||||
}
|
|
||||||
return Array.from(skills.values())
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
if (invalidated.length === 0) return
|
||||||
|
for (const [key] of invalidated) cache.delete(key)
|
||||||
|
yield* Effect.logInfo("skill cache invalidated", {
|
||||||
|
file,
|
||||||
|
sources: invalidated.map(([key]) => key),
|
||||||
|
skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.id)),
|
||||||
|
})
|
||||||
|
yield* bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid)
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* bus.subscribe(FileSystem.Event.Changed).pipe(
|
||||||
|
Stream.runForEach((event) => invalidate(event.data.file)),
|
||||||
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
|
)
|
||||||
|
|
||||||
|
const list = Effect.fn("Skill.list")(function* () {
|
||||||
|
const skills = new Map<ID, Info>()
|
||||||
|
for (const source of state.get().sources) {
|
||||||
|
const key = Source.key(source)
|
||||||
|
const loaded = cache.get(key) ?? (yield* load(source))
|
||||||
|
cache.set(key, loaded)
|
||||||
|
for (const skill of loaded.skills) skills.set(skill.id, skill)
|
||||||
|
}
|
||||||
|
return Array.from(skills.values())
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
@@ -258,5 +187,5 @@ const layer = Layer.effect(
|
|||||||
export const node = makeLocationNode({
|
export const node = makeLocationNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer,
|
layer,
|
||||||
deps: [SkillDiscovery.node, FSUtil.node, Bus.node, Watcher.node],
|
deps: [SkillDiscovery.node, FSUtil.node, Bus.node],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -118,12 +118,13 @@ const layer = Layer.effect(
|
|||||||
yield* hooks.trigger("tool", "execute.after", afterEvent)
|
yield* hooks.trigger("tool", "execute.after", afterEvent)
|
||||||
return yield* afterEvent.error
|
return yield* afterEvent.error
|
||||||
}
|
}
|
||||||
|
const content = yield* normalizeImages(execution.value.content)
|
||||||
const afterEvent: PluginHooks.Domains["tool"]["execute.after"] = {
|
const afterEvent: PluginHooks.Domains["tool"]["execute.after"] = {
|
||||||
...base,
|
...base,
|
||||||
status: "completed",
|
status: "completed",
|
||||||
result: {
|
result: {
|
||||||
...(execution.value.output === undefined ? {} : { output: execution.value.output }),
|
...(execution.value.output === undefined ? {} : { output: execution.value.output }),
|
||||||
content: execution.value.content,
|
content: content.length > 0 ? content : execution.value.content,
|
||||||
...(execution.value.metadata === undefined ? {} : { metadata: execution.value.metadata }),
|
...(execution.value.metadata === undefined ? {} : { metadata: execution.value.metadata }),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,9 @@ import { ToolFailure } from "@opencode-ai/ai"
|
|||||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||||
import { Bom } from "@opencode-ai/util/bom"
|
import { Bom } from "@opencode-ai/util/bom"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import path from "path"
|
|
||||||
import { Environment } from "../../environment"
|
|
||||||
import { FileMutation } from "../../file-mutation"
|
import { FileMutation } from "../../file-mutation"
|
||||||
import { Formatter } from "../../formatter"
|
import { Formatter } from "../../formatter"
|
||||||
import { Location } from "../../location"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { LocationMutation } from "../../location-mutation"
|
import { LocationMutation } from "../../location-mutation"
|
||||||
import { Permission } from "../../permission"
|
import { Permission } from "../../permission"
|
||||||
import { fileDiff } from "./file-diff"
|
import { fileDiff } from "./file-diff"
|
||||||
@@ -111,10 +109,9 @@ export const Plugin = {
|
|||||||
id: "opencode.tool.edit",
|
id: "opencode.tool.edit",
|
||||||
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
|
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
|
||||||
const mutation = yield* LocationMutation.Service
|
const mutation = yield* LocationMutation.Service
|
||||||
const fileMutation = yield* FileMutation.Service
|
const files = yield* FileMutation.Service
|
||||||
const environment = yield* Environment.Service
|
|
||||||
const formatter = yield* Formatter.Service
|
const formatter = yield* Formatter.Service
|
||||||
const location = yield* Location.Service
|
const fs = yield* FSUtil.Service
|
||||||
const permission = yield* Permission.Service
|
const permission = yield* Permission.Service
|
||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
@@ -155,16 +152,17 @@ export const Plugin = {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const original = yield* FileMutation.readText(environment.files, target.absolute).pipe(
|
const info = yield* fs
|
||||||
Effect.catchTag("Environment.NotFound", () =>
|
.stat(target.absolute)
|
||||||
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
|
.pipe(
|
||||||
),
|
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||||
Effect.catchTag("Environment.WrongKind", (error) =>
|
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
|
||||||
error.actual === "directory"
|
),
|
||||||
? Effect.fail(new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` }))
|
)
|
||||||
: Effect.fail(new ToolFailure({ message: `Unable to edit ${input.path}`, error })),
|
if (info.type === "Directory") {
|
||||||
),
|
return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })
|
||||||
)
|
}
|
||||||
|
const original = yield* Bom.readFile(fs, target.absolute)
|
||||||
const source = original.text
|
const source = original.text
|
||||||
const ending = source.includes(crlf) ? crlf : "\n"
|
const ending = source.includes(crlf) ? crlf : "\n"
|
||||||
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
||||||
@@ -206,20 +204,19 @@ export const Plugin = {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
const replacementBom = replaced.startsWith("\uFEFF")
|
const replacementBom = replaced.startsWith("\uFEFF")
|
||||||
const result = yield* fileMutation.write({
|
const result = yield* files.write({
|
||||||
target,
|
target,
|
||||||
content: Bom.join(replaced, original.bom || replacementBom),
|
content: Bom.join(replaced, original.bom || replacementBom),
|
||||||
})
|
})
|
||||||
const bom = original.bom || replacementBom
|
const bom = original.bom || replacementBom
|
||||||
const formatted = (yield* formatter.file(target.absolute))
|
const formatted = (yield* formatter.file(target.absolute))
|
||||||
? yield* FileMutation.syncTextBom(environment.files, target.absolute, bom)
|
? yield* Bom.syncFile(fs, target.absolute, bom)
|
||||||
: (yield* FileMutation.readText(environment.files, target.absolute)).text
|
: (yield* Bom.readFile(fs, target.absolute)).text
|
||||||
return {
|
return {
|
||||||
files: [fileDiff(result.resource, source, formatted)],
|
files: [fileDiff(result.resource, source, formatted)],
|
||||||
replacements,
|
replacements,
|
||||||
} satisfies Output
|
} satisfies Output
|
||||||
}).pipe(
|
}).pipe(
|
||||||
fileMutation.withLock([path.resolve(location.directory, input.path)]),
|
|
||||||
Effect.map((output) => ({
|
Effect.map((output) => ({
|
||||||
output,
|
output,
|
||||||
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
|
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { ToolFailure } from "@opencode-ai/ai"
|
|||||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Environment } from "../../environment"
|
|
||||||
import { FileSystem } from "../../filesystem"
|
import { FileSystem } from "../../filesystem"
|
||||||
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { Location } from "../../location"
|
import { Location } from "../../location"
|
||||||
import { LocationMutation } from "../../location-mutation"
|
import { LocationMutation } from "../../location-mutation"
|
||||||
import { Ripgrep } from "../../ripgrep"
|
import { Ripgrep } from "../../ripgrep"
|
||||||
@@ -42,7 +42,7 @@ export const toModelContent = (entries: EncodedOutput, truncated = false) => {
|
|||||||
export const Plugin = {
|
export const Plugin = {
|
||||||
id: "opencode.tool.glob",
|
id: "opencode.tool.glob",
|
||||||
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) {
|
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) {
|
||||||
const environment = yield* Environment.Service
|
const fs = yield* FSUtil.Service
|
||||||
const ripgrep = yield* Ripgrep.Service
|
const ripgrep = yield* Ripgrep.Service
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
const mutation = yield* LocationMutation.Service
|
const mutation = yield* LocationMutation.Service
|
||||||
@@ -82,20 +82,22 @@ export const Plugin = {
|
|||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
const type = yield* Environment.typeFollowing(environment.files, target.absolute).pipe(
|
const info = yield* fs
|
||||||
Effect.catchTag("Environment.NotFound", () =>
|
.stat(target.absolute)
|
||||||
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
|
.pipe(
|
||||||
),
|
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||||
)
|
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
|
||||||
if (type !== "directory")
|
),
|
||||||
|
)
|
||||||
|
if (info.type !== "Directory")
|
||||||
return yield* Effect.fail(
|
return yield* Effect.fail(
|
||||||
new ToolFailure({ message: `Search path is not a directory: ${searchPath ?? "."}` }),
|
new ToolFailure({ message: `Search path is not a directory: ${searchPath ?? "."}` }),
|
||||||
)
|
)
|
||||||
const root = target.absolute
|
const root = path.resolve(location.directory, searchPath ?? ".")
|
||||||
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
|
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
|
||||||
const entries = yield* ripgrep
|
const entries = yield* ripgrep
|
||||||
.glob({
|
.glob({
|
||||||
cwd: root,
|
cwd: target.absolute,
|
||||||
pattern: input.pattern,
|
pattern: input.pattern,
|
||||||
limit: limit + 1,
|
limit: limit + 1,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
|
|||||||
import { ToolFailure } from "@opencode-ai/ai"
|
import { ToolFailure } from "@opencode-ai/ai"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Environment } from "../../environment"
|
|
||||||
import { FileSystem } from "../../filesystem"
|
import { FileSystem } from "../../filesystem"
|
||||||
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { Location } from "../../location"
|
import { Location } from "../../location"
|
||||||
import { LocationMutation } from "../../location-mutation"
|
import { LocationMutation } from "../../location-mutation"
|
||||||
import { Permission } from "../../permission"
|
import { Permission } from "../../permission"
|
||||||
@@ -15,11 +15,11 @@ import { RelativePath } from "../../schema"
|
|||||||
export const name = "grep"
|
export const name = "grep"
|
||||||
|
|
||||||
export const Input = Schema.Struct({
|
export const Input = Schema.Struct({
|
||||||
pattern: FileSystem.GrepInput.fields.pattern
|
pattern: FileSystem.GrepInput.fields.pattern.check(
|
||||||
.check(Schema.isMinLength(1, { message: "Pattern must not be empty" }))
|
Schema.isMinLength(1, { message: "Pattern must not be empty" }),
|
||||||
.annotate({
|
).annotate({
|
||||||
description: "Regular expression to search for in file contents (ripgrep syntax)",
|
description: "Regular expression to search for in file contents (ripgrep syntax)",
|
||||||
}),
|
}),
|
||||||
path: Schema.optionalKey(RelativePath).annotate({
|
path: Schema.optionalKey(RelativePath).annotate({
|
||||||
description: "File or directory to search. Defaults to the current working directory.",
|
description: "File or directory to search. Defaults to the current working directory.",
|
||||||
}),
|
}),
|
||||||
@@ -58,7 +58,7 @@ export const toModelContent = (matches: EncodedOutput, truncated = false) => {
|
|||||||
export const Plugin = {
|
export const Plugin = {
|
||||||
id: "opencode.tool.grep",
|
id: "opencode.tool.grep",
|
||||||
effect: Effect.fn("GrepTool.Plugin")(function* (ctx: PluginContext) {
|
effect: Effect.fn("GrepTool.Plugin")(function* (ctx: PluginContext) {
|
||||||
const environment = yield* Environment.Service
|
const fs = yield* FSUtil.Service
|
||||||
const ripgrep = yield* Ripgrep.Service
|
const ripgrep = yield* Ripgrep.Service
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
const mutation = yield* LocationMutation.Service
|
const mutation = yield* LocationMutation.Service
|
||||||
@@ -66,100 +66,104 @@ export const Plugin = {
|
|||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
.transform((draft) =>
|
.transform((draft) =>
|
||||||
draft.add({
|
draft.add(
|
||||||
name,
|
({
|
||||||
options: { codemode: false },
|
name,
|
||||||
description:
|
options: { codemode: false },
|
||||||
"Search file contents using regular expressions. Use it to locate specific code, symbols, or text patterns, and narrow searches with `path` or `include`. Returns matching file paths, line numbers, and line previews.",
|
description:
|
||||||
input: Input,
|
"Search file contents using regular expressions. Use it to locate specific code, symbols, or text patterns, and narrow searches with `path` or `include`. Returns matching file paths, line numbers, and line previews.",
|
||||||
output: Output,
|
input: Input,
|
||||||
execute: (input, context) =>
|
output: Output,
|
||||||
Effect.gen(function* () {
|
execute: (input, context) =>
|
||||||
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
|
Effect.gen(function* () {
|
||||||
const target = yield* mutation.resolve({ path: input.path ?? "." })
|
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
|
||||||
if (target.externalDirectory)
|
const target = yield* mutation.resolve({ path: input.path ?? "." })
|
||||||
|
if (target.externalDirectory)
|
||||||
|
yield* permission.assert({
|
||||||
|
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
|
||||||
|
sessionID: context.sessionID,
|
||||||
|
agent: context.agent,
|
||||||
|
source,
|
||||||
|
})
|
||||||
yield* permission.assert({
|
yield* permission.assert({
|
||||||
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
|
action: name,
|
||||||
|
resources: [input.pattern],
|
||||||
|
save: ["*"],
|
||||||
|
metadata: {
|
||||||
|
root: ".",
|
||||||
|
path: input.path,
|
||||||
|
include: input.include,
|
||||||
|
limit: input.limit,
|
||||||
|
},
|
||||||
sessionID: context.sessionID,
|
sessionID: context.sessionID,
|
||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
yield* permission.assert({
|
const root = path.resolve(location.directory, input.path ?? ".")
|
||||||
action: name,
|
const info = yield* fs
|
||||||
resources: [input.pattern],
|
.stat(root)
|
||||||
save: ["*"],
|
.pipe(
|
||||||
metadata: {
|
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||||
root: ".",
|
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
|
||||||
path: input.path,
|
),
|
||||||
include: input.include,
|
)
|
||||||
limit: input.limit,
|
const cwd = info?.type === "Directory" ? root : path.dirname(root)
|
||||||
},
|
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
|
||||||
sessionID: context.sessionID,
|
const matches = yield* ripgrep
|
||||||
agent: context.agent,
|
.grep({
|
||||||
source,
|
cwd,
|
||||||
})
|
pattern: input.pattern,
|
||||||
const root = target.absolute
|
file: info?.type === "File" ? path.basename(root) : undefined,
|
||||||
const type = yield* Environment.typeFollowing(environment.files, root).pipe(
|
include: input.include,
|
||||||
Effect.catchTag("Environment.NotFound", () =>
|
limit: limit + 1,
|
||||||
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
|
})
|
||||||
),
|
.pipe(
|
||||||
)
|
Effect.timeoutOrElse({
|
||||||
const cwd = type === "directory" ? root : path.dirname(root)
|
duration: FileSystem.DEFAULT_SEARCH_TIMEOUT_MS,
|
||||||
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
|
orElse: () =>
|
||||||
const matches = yield* ripgrep
|
Effect.fail(
|
||||||
.grep({
|
new ToolFailure({
|
||||||
cwd,
|
message: `Search timed out after ${FileSystem.DEFAULT_SEARCH_TIMEOUT_MS / 1_000} seconds. Consider using a more specific path or pattern.`,
|
||||||
pattern: input.pattern,
|
}),
|
||||||
file: type === "file" ? path.basename(root) : undefined,
|
),
|
||||||
include: input.include,
|
}),
|
||||||
limit: limit + 1,
|
Effect.map((result) =>
|
||||||
})
|
result.map((match) =>
|
||||||
.pipe(
|
FileSystem.Match.make({
|
||||||
Effect.timeoutOrElse({
|
...match,
|
||||||
duration: FileSystem.DEFAULT_SEARCH_TIMEOUT_MS,
|
entry: FileSystem.Entry.make({
|
||||||
orElse: () =>
|
...match.entry,
|
||||||
Effect.fail(
|
path: RelativePath.make(
|
||||||
new ToolFailure({
|
path.relative(location.directory, path.resolve(cwd, match.entry.path)),
|
||||||
message: `Search timed out after ${FileSystem.DEFAULT_SEARCH_TIMEOUT_MS / 1_000} seconds. Consider using a more specific path or pattern.`,
|
),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
}),
|
|
||||||
Effect.map((result) =>
|
|
||||||
result.map((match) =>
|
|
||||||
FileSystem.Match.make({
|
|
||||||
...match,
|
|
||||||
entry: FileSystem.Entry.make({
|
|
||||||
...match.entry,
|
|
||||||
path: RelativePath.make(
|
|
||||||
path.relative(location.directory, path.resolve(cwd, match.entry.path)),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
return { matches: matches.slice(0, limit), truncated: matches.length > limit }
|
||||||
|
}).pipe(
|
||||||
|
Effect.map((result) => ({
|
||||||
|
output: result.matches,
|
||||||
|
content: toModelContent(
|
||||||
|
result.matches.map((match) => ({
|
||||||
|
...match,
|
||||||
|
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
|
||||||
|
})),
|
||||||
|
result.truncated,
|
||||||
),
|
),
|
||||||
)
|
metadata: { matches: result.matches.length, truncated: result.truncated },
|
||||||
return { matches: matches.slice(0, limit), truncated: matches.length > limit }
|
})),
|
||||||
}).pipe(
|
Effect.mapError((error) =>
|
||||||
Effect.map((result) => ({
|
error instanceof ToolFailure
|
||||||
output: result.matches,
|
? error
|
||||||
content: toModelContent(
|
: error instanceof Ripgrep.InvalidPatternError
|
||||||
result.matches.map((match) => ({
|
? new ToolFailure({ message: `Invalid regex pattern: ${error.message}` })
|
||||||
...match,
|
|
||||||
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
|
|
||||||
})),
|
|
||||||
result.truncated,
|
|
||||||
),
|
|
||||||
metadata: { matches: result.matches.length, truncated: result.truncated },
|
|
||||||
})),
|
|
||||||
Effect.mapError((error) =>
|
|
||||||
error instanceof ToolFailure
|
|
||||||
? error
|
|
||||||
: error instanceof Ripgrep.InvalidPatternError
|
|
||||||
? new ToolFailure({ message: `Invalid regex pattern: ${error.message}` })
|
|
||||||
: new ToolFailure({ message: `Unable to grep for ${input.pattern}`, error }),
|
: new ToolFailure({ message: `Unable to grep for ${input.pattern}`, error }),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
}),
|
||||||
}),
|
),
|
||||||
)
|
)
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -4,13 +4,12 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
|
|||||||
import { ToolFailure } from "@opencode-ai/ai"
|
import { ToolFailure } from "@opencode-ai/ai"
|
||||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||||
import { createTwoFilesPatch, diffLines } from "diff"
|
import { createTwoFilesPatch, diffLines } from "diff"
|
||||||
import { Effect, Result, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
|
import { PlatformError } from "effect/PlatformError"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Bom } from "@opencode-ai/util/bom"
|
import { Bom } from "@opencode-ai/util/bom"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { Environment } from "../../environment"
|
|
||||||
import { Formatter } from "../../formatter"
|
import { Formatter } from "../../formatter"
|
||||||
import { FileMutation } from "../../file-mutation"
|
|
||||||
import { Location } from "../../location"
|
import { Location } from "../../location"
|
||||||
import { Patch } from "@opencode-ai/util/patch"
|
import { Patch } from "@opencode-ai/util/patch"
|
||||||
import { Permission } from "../../permission"
|
import { Permission } from "../../permission"
|
||||||
@@ -45,13 +44,7 @@ export const toModelOutput = (output: Output) =>
|
|||||||
].join("\n")
|
].join("\n")
|
||||||
|
|
||||||
type Prepared =
|
type Prepared =
|
||||||
| (Extract<Patch.Hunk, { readonly type: "add" }> & {
|
| (Extract<Patch.Hunk, { readonly type: "add" | "delete" }> & {
|
||||||
readonly target: Target
|
|
||||||
readonly content: string
|
|
||||||
readonly before: string
|
|
||||||
readonly after: string
|
|
||||||
})
|
|
||||||
| (Extract<Patch.Hunk, { readonly type: "delete" }> & {
|
|
||||||
readonly target: Target
|
readonly target: Target
|
||||||
readonly before: string
|
readonly before: string
|
||||||
readonly after: string
|
readonly after: string
|
||||||
@@ -76,8 +69,7 @@ interface Target {
|
|||||||
export const Plugin = {
|
export const Plugin = {
|
||||||
id: "opencode.tool.patch",
|
id: "opencode.tool.patch",
|
||||||
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
|
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
|
||||||
const environment = yield* Environment.Service
|
const fs = yield* FSUtil.Service
|
||||||
const mutation = yield* FileMutation.Service
|
|
||||||
const formatter = yield* Formatter.Service
|
const formatter = yield* Formatter.Service
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
const permission = yield* Permission.Service
|
const permission = yield* Permission.Service
|
||||||
@@ -92,13 +84,6 @@ export const Plugin = {
|
|||||||
output: Output,
|
output: Output,
|
||||||
execute: (input, context) => {
|
execute: (input, context) => {
|
||||||
const applied: Array<typeof Applied.Type> = []
|
const applied: Array<typeof Applied.Type> = []
|
||||||
const parsed = Patch.parse(input.patchText)
|
|
||||||
const lockTargets = Result.isSuccess(parsed)
|
|
||||||
? parsed.success.flatMap((hunk) => [
|
|
||||||
path.resolve(location.directory, hunk.path),
|
|
||||||
...(hunk.type === "update" && hunk.movePath ? [path.resolve(location.directory, hunk.movePath)] : []),
|
|
||||||
])
|
|
||||||
: []
|
|
||||||
const fail = (operation: string, error: unknown) => {
|
const fail = (operation: string, error: unknown) => {
|
||||||
const completed = applied.map((item) => item.resource).join(", ")
|
const completed = applied.map((item) => item.resource).join(", ")
|
||||||
return new ToolFailure({
|
return new ToolFailure({
|
||||||
@@ -112,7 +97,7 @@ export const Plugin = {
|
|||||||
id: context.id,
|
id: context.id,
|
||||||
}
|
}
|
||||||
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
|
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
|
||||||
const hunks = yield* Effect.fromResult(parsed).pipe(
|
const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe(
|
||||||
Effect.mapError((error) => new ToolFailure({ message: `patch verification failed: ${error.message}` })),
|
Effect.mapError((error) => new ToolFailure({ message: `patch verification failed: ${error.message}` })),
|
||||||
)
|
)
|
||||||
if (hunks.length === 0) {
|
if (hunks.length === 0) {
|
||||||
@@ -140,19 +125,18 @@ export const Plugin = {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (hunk.type === "add") {
|
if (hunk.type === "add") {
|
||||||
const content =
|
|
||||||
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
|
|
||||||
prepared.push({
|
prepared.push({
|
||||||
...hunk,
|
...hunk,
|
||||||
target,
|
target,
|
||||||
content,
|
|
||||||
before: "",
|
before: "",
|
||||||
after: Bom.split(content).text,
|
after: Bom.split(
|
||||||
|
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`,
|
||||||
|
).text,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (hunk.type === "delete") {
|
if (hunk.type === "delete") {
|
||||||
const content = yield* FileMutation.readText(environment.files, target.absolute).pipe(
|
const content = yield* Bom.readFile(fs, target.absolute).pipe(
|
||||||
Effect.mapError(
|
Effect.mapError(
|
||||||
(error) =>
|
(error) =>
|
||||||
new ToolFailure({
|
new ToolFailure({
|
||||||
@@ -167,7 +151,20 @@ export const Plugin = {
|
|||||||
const original =
|
const original =
|
||||||
previous ??
|
previous ??
|
||||||
(yield* Effect.gen(function* () {
|
(yield* Effect.gen(function* () {
|
||||||
const content = yield* FileMutation.readText(environment.files, target.absolute).pipe(
|
const stats = yield* fs.stat(target.absolute).pipe(
|
||||||
|
Effect.mapError(
|
||||||
|
(error) =>
|
||||||
|
new ToolFailure({
|
||||||
|
message: `patch verification failed: Failed to read file to update ${target.absolute}: ${errorMessage(error)}`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (stats.type === "Directory") {
|
||||||
|
return yield* new ToolFailure({
|
||||||
|
message: `patch verification failed: Failed to read file to update ${target.absolute}: path is a directory`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const content = yield* Bom.readFile(fs, target.absolute).pipe(
|
||||||
Effect.mapError(
|
Effect.mapError(
|
||||||
(error) =>
|
(error) =>
|
||||||
new ToolFailure({
|
new ToolFailure({
|
||||||
@@ -236,8 +233,13 @@ export const Plugin = {
|
|||||||
(change) =>
|
(change) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (change.type === "add") {
|
if (change.type === "add") {
|
||||||
yield* environment.files
|
yield* fs
|
||||||
.write(change.target.absolute, new TextEncoder().encode(change.content))
|
.writeWithDirs(
|
||||||
|
change.target.absolute,
|
||||||
|
change.contents.endsWith("\n") || change.contents === ""
|
||||||
|
? change.contents
|
||||||
|
: `${change.contents}\n`,
|
||||||
|
)
|
||||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
||||||
applied.push({
|
applied.push({
|
||||||
type: change.type,
|
type: change.type,
|
||||||
@@ -247,7 +249,7 @@ export const Plugin = {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (change.type === "delete") {
|
if (change.type === "delete") {
|
||||||
yield* environment.files
|
yield* fs
|
||||||
.remove(change.target.absolute)
|
.remove(change.target.absolute)
|
||||||
.pipe(Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)))
|
.pipe(Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)))
|
||||||
applied.push({
|
applied.push({
|
||||||
@@ -259,10 +261,10 @@ export const Plugin = {
|
|||||||
}
|
}
|
||||||
if (change.moveTarget) {
|
if (change.moveTarget) {
|
||||||
const moveTarget = change.moveTarget
|
const moveTarget = change.moveTarget
|
||||||
yield* environment.files
|
yield* fs
|
||||||
.write(moveTarget.absolute, new TextEncoder().encode(change.content))
|
.writeWithDirs(moveTarget.absolute, change.content)
|
||||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
|
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
|
||||||
yield* environment.files
|
yield* fs
|
||||||
.remove(change.target.absolute)
|
.remove(change.target.absolute)
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.mapError((error) =>
|
Effect.mapError((error) =>
|
||||||
@@ -276,8 +278,8 @@ export const Plugin = {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
yield* environment.files
|
yield* fs
|
||||||
.write(change.target.absolute, new TextEncoder().encode(change.content))
|
.writeWithDirs(change.target.absolute, change.content)
|
||||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
||||||
applied.push({
|
applied.push({
|
||||||
type: change.type,
|
type: change.type,
|
||||||
@@ -292,13 +294,13 @@ export const Plugin = {
|
|||||||
[...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))],
|
[...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))],
|
||||||
(target) =>
|
(target) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const current = yield* FileMutation.readText(environment.files, target).pipe(
|
const current = yield* Bom.readFile(fs, target).pipe(
|
||||||
Effect.mapError((error) => fail(`Failed to read ${target}`, error)),
|
Effect.mapError((error) => fail(`Failed to read ${target}`, error)),
|
||||||
)
|
)
|
||||||
formatted.set(
|
formatted.set(
|
||||||
target,
|
target,
|
||||||
(yield* formatter.file(target))
|
(yield* formatter.file(target))
|
||||||
? yield* FileMutation.syncTextBom(environment.files, target, current.bom).pipe(
|
? yield* Bom.syncFile(fs, target, current.bom).pipe(
|
||||||
Effect.mapError((error) => fail(`Failed to sync ${target}`, error)),
|
Effect.mapError((error) => fail(`Failed to sync ${target}`, error)),
|
||||||
)
|
)
|
||||||
: current.text,
|
: current.text,
|
||||||
@@ -313,7 +315,6 @@ export const Plugin = {
|
|||||||
})
|
})
|
||||||
return { applied, files }
|
return { applied, files }
|
||||||
}).pipe(
|
}).pipe(
|
||||||
mutation.withLock(lockTargets),
|
|
||||||
Effect.map((output) => ({
|
Effect.map((output) => ({
|
||||||
output,
|
output,
|
||||||
content: toModelOutput(output),
|
content: toModelOutput(output),
|
||||||
@@ -344,10 +345,10 @@ export const Plugin = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function errorMessage(error: unknown) {
|
function errorMessage(error: unknown) {
|
||||||
if (error instanceof Environment.NotFound) return "file does not exist"
|
if (error instanceof PlatformError) {
|
||||||
if (error instanceof Environment.WrongKind)
|
if (error.reason._tag === "NotFound") return "file does not exist"
|
||||||
return error.actual === "directory" ? "path is a directory" : `path is ${error.actual}`
|
return error.reason.description ?? error.reason.message
|
||||||
if (error instanceof Environment.Failed) return errorMessage(error.cause)
|
}
|
||||||
return error instanceof Error ? error.message : String(error)
|
return error instanceof Error ? error.message : String(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { Permission } from "../../permission"
|
|||||||
import { SessionInstructions } from "../../session/instructions"
|
import { SessionInstructions } from "../../session/instructions"
|
||||||
import { AbsolutePath } from "../../schema"
|
import { AbsolutePath } from "../../schema"
|
||||||
import { ReadToolFileSystem } from "../read-filesystem"
|
import { ReadToolFileSystem } from "../read-filesystem"
|
||||||
import { Environment } from "../../environment"
|
|
||||||
|
|
||||||
export const name = "read"
|
export const name = "read"
|
||||||
const FILENAME = "AGENTS.md"
|
const FILENAME = "AGENTS.md"
|
||||||
@@ -73,12 +72,16 @@ export const Plugin = {
|
|||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
const content = yield* reader.read(absolute, resource, { offset: input.offset, limit: input.limit }).pipe(
|
const type = yield* reader
|
||||||
Effect.catchIf(
|
.inspect(absolute)
|
||||||
(error) => error instanceof Environment.NotFound,
|
.pipe(Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.absolute)))
|
||||||
() => missing(input.path, target.absolute),
|
const content =
|
||||||
),
|
type === "directory"
|
||||||
)
|
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
|
||||||
|
: yield* reader.read(absolute, resource, {
|
||||||
|
offset: input.offset,
|
||||||
|
limit: input.limit,
|
||||||
|
})
|
||||||
// After a successful read, discover nearby AGENTS.md walking up to the Location
|
// After a successful read, discover nearby AGENTS.md walking up to the Location
|
||||||
// root exclusive and inject them as durable synthetic instructions. For a
|
// root exclusive and inject them as durable synthetic instructions. For a
|
||||||
// directory listing the walk starts at the directory itself (so its own AGENTS.md
|
// directory listing the walk starts at the directory itself (so its own AGENTS.md
|
||||||
@@ -92,7 +95,7 @@ export const Plugin = {
|
|||||||
// supplied by core initial instructions) is dropped by the dirname filter.
|
// supplied by core initial instructions) is dropped by the dirname filter.
|
||||||
const discovered = yield* fs.up({
|
const discovered = yield* fs.up({
|
||||||
targets: [FILENAME],
|
targets: [FILENAME],
|
||||||
start: content.type === "list-page" ? resolved : dirname(resolved),
|
start: type === "directory" ? resolved : dirname(resolved),
|
||||||
stop: root,
|
stop: root,
|
||||||
})
|
})
|
||||||
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
|
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { ToolFailure } from "@opencode-ai/ai"
|
|||||||
import type { Content } from "@opencode-ai/schema/tool"
|
import type { Content } from "@opencode-ai/schema/tool"
|
||||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||||
import { Deferred, Effect, Schema, Scope } from "effect"
|
import { Deferred, Effect, Schema, Scope } from "effect"
|
||||||
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { Config } from "../../config"
|
import { Config } from "../../config"
|
||||||
import { Environment } from "../../environment"
|
|
||||||
import { LocationMutation } from "../../location-mutation"
|
import { LocationMutation } from "../../location-mutation"
|
||||||
import { Permission } from "../../permission"
|
import { Permission } from "../../permission"
|
||||||
import { PluginRuntime } from "../../plugin/runtime"
|
import { PluginRuntime } from "../../plugin/runtime"
|
||||||
@@ -83,7 +83,7 @@ export const Plugin = {
|
|||||||
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) {
|
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) {
|
||||||
const runtime = yield* PluginRuntime.Service
|
const runtime = yield* PluginRuntime.Service
|
||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
const environment = yield* Environment.Service
|
const fsUtil = yield* FSUtil.Service
|
||||||
const mutation = yield* LocationMutation.Service
|
const mutation = yield* LocationMutation.Service
|
||||||
const shell = yield* Shell.Service
|
const shell = yield* Shell.Service
|
||||||
const permission = yield* Permission.Service
|
const permission = yield* Permission.Service
|
||||||
@@ -179,12 +179,14 @@ export const Plugin = {
|
|||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
const workdir = yield* Environment.typeFollowing(environment.files, target.absolute).pipe(
|
const workdir = yield* fsUtil
|
||||||
Effect.catchTag("Environment.NotFound", () =>
|
.stat(target.absolute)
|
||||||
Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)),
|
.pipe(
|
||||||
),
|
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||||
)
|
Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)),
|
||||||
if (workdir !== "directory")
|
),
|
||||||
|
)
|
||||||
|
if (workdir.type !== "Directory")
|
||||||
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.absolute}`))
|
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.absolute}`))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
|
|||||||
import { ToolFailure } from "@opencode-ai/ai"
|
import { ToolFailure } from "@opencode-ai/ai"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { Bom } from "@opencode-ai/util/bom"
|
import { Bom } from "@opencode-ai/util/bom"
|
||||||
import { Environment } from "../../environment"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { FileMutation } from "../../file-mutation"
|
import { FileMutation } from "../../file-mutation"
|
||||||
import { Formatter } from "../../formatter"
|
import { Formatter } from "../../formatter"
|
||||||
import { LocationMutation } from "../../location-mutation"
|
import { LocationMutation } from "../../location-mutation"
|
||||||
@@ -47,9 +47,9 @@ export const Plugin = {
|
|||||||
id: "opencode.tool.write",
|
id: "opencode.tool.write",
|
||||||
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
|
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
|
||||||
const mutation = yield* LocationMutation.Service
|
const mutation = yield* LocationMutation.Service
|
||||||
const fileMutation = yield* FileMutation.Service
|
const files = yield* FileMutation.Service
|
||||||
const environment = yield* Environment.Service
|
|
||||||
const formatter = yield* Formatter.Service
|
const formatter = yield* Formatter.Service
|
||||||
|
const fs = yield* FSUtil.Service
|
||||||
const permission = yield* Permission.Service
|
const permission = yield* Permission.Service
|
||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
@@ -77,8 +77,8 @@ export const Plugin = {
|
|||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
const current = yield* FileMutation.readText(environment.files, target.absolute).pipe(
|
const current = yield* Bom.readFile(fs, target.absolute).pipe(
|
||||||
Effect.catchTag("Environment.NotFound", () => Effect.succeed(undefined)),
|
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
|
||||||
)
|
)
|
||||||
const next = Bom.split(input.content)
|
const next = Bom.split(input.content)
|
||||||
const preview = fileDiff(target.resource, current?.text ?? "", next.text, current ? "modified" : "added")
|
const preview = fileDiff(target.resource, current?.text ?? "", next.text, current ? "modified" : "added")
|
||||||
@@ -91,11 +91,9 @@ export const Plugin = {
|
|||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
const result = yield* fileMutation.writeTextPreservingBom({ target, content: input.content })
|
const result = yield* files.writeTextPreservingBom({ target, content: input.content })
|
||||||
const bom = (yield* FileMutation.readText(environment.files, target.absolute)).bom
|
const bom = (yield* Bom.readFile(fs, target.absolute)).bom
|
||||||
if (yield* formatter.file(target.absolute)) {
|
if (yield* formatter.file(target.absolute)) yield* Bom.syncFile(fs, target.absolute, bom)
|
||||||
yield* FileMutation.syncTextBom(environment.files, target.absolute, bom)
|
|
||||||
}
|
|
||||||
return result
|
return result
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.map((output) => ({ output, content: toModelOutput(output) })),
|
Effect.map((output) => ({ output, content: toModelOutput(output) })),
|
||||||
|
|||||||
@@ -2,22 +2,17 @@ export * as ReadToolFileSystem from "./read-filesystem"
|
|||||||
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
|
||||||
import { lookup } from "mime-types"
|
|
||||||
import { Environment } from "../environment"
|
|
||||||
import type { Files } from "../environment"
|
|
||||||
import { FileSystem } from "../filesystem"
|
import { FileSystem } from "../filesystem"
|
||||||
import { Mime } from "../mime"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath } from "../schema"
|
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath } from "../schema"
|
||||||
|
|
||||||
export const MAX_READ_LINES = 2_000
|
export const MAX_READ_LINES = 2_000
|
||||||
export const MAX_READ_BYTES = 50 * 1024
|
export const MAX_READ_BYTES = 50 * 1024
|
||||||
export const MAX_MEDIA_INGEST_BYTES = 20 * 1024 * 1024
|
export const MAX_MEDIA_INGEST_BYTES = 20 * 1024 * 1024
|
||||||
const FIRST_CHUNK = 256 * 1024
|
|
||||||
const MAX_LINE_LENGTH = 2_000
|
const MAX_LINE_LENGTH = 2_000
|
||||||
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
|
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
|
||||||
const MEDIA_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"])
|
|
||||||
|
|
||||||
export class BinaryFileError extends Schema.TaggedErrorClass<BinaryFileError>()("ReadTool.BinaryFileError", {
|
export class BinaryFileError extends Schema.TaggedErrorClass<BinaryFileError>()("ReadTool.BinaryFileError", {
|
||||||
resource: Schema.String,
|
resource: Schema.String,
|
||||||
@@ -57,13 +52,8 @@ export class PathKindError extends Schema.TaggedErrorClass<PathKindError>()("Rea
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ReadError =
|
export type InspectError = FSUtil.Error | PathKindError
|
||||||
| Environment.NotFound
|
export type ReadError = FSUtil.Error | BinaryFileError | MediaIngestLimitError | OffsetOutOfRangeError | PathKindError
|
||||||
| Environment.Failed
|
|
||||||
| BinaryFileError
|
|
||||||
| MediaIngestLimitError
|
|
||||||
| OffsetOutOfRangeError
|
|
||||||
| PathKindError
|
|
||||||
|
|
||||||
export const PageInput = Schema.Struct({
|
export const PageInput = Schema.Struct({
|
||||||
offset: Schema.optionalKey(NonNegativeInt),
|
offset: Schema.optionalKey(NonNegativeInt),
|
||||||
@@ -100,113 +90,202 @@ export class ListPage extends Schema.Class<ListPage>("ReadTool.ListPage")({
|
|||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
|
readonly inspect: (path: AbsolutePath) => Effect.Effect<"file" | "directory", InspectError>
|
||||||
readonly read: (
|
readonly read: (
|
||||||
path: AbsolutePath,
|
path: AbsolutePath,
|
||||||
resource: string,
|
resource: string,
|
||||||
page?: PageInput,
|
page?: PageInput,
|
||||||
) => Effect.Effect<FileContent | TextPage | ListPage, ReadError>
|
) => Effect.Effect<FileContent | TextPage, ReadError>
|
||||||
|
readonly list: (path: AbsolutePath, page?: PageInput) => Effect.Effect<ListPage, FSUtil.Error>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {}
|
||||||
|
|
||||||
const mimeType = (value: string) => lookup(value) || "application/octet-stream"
|
const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value)
|
||||||
|
const mediaMime = (bytes: Uint8Array) => {
|
||||||
|
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
|
||||||
|
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg"
|
||||||
|
if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif"
|
||||||
|
if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50]))
|
||||||
|
return "image/webp"
|
||||||
|
if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf"
|
||||||
|
}
|
||||||
|
const binary = (bytes: Uint8Array) => {
|
||||||
|
if (bytes.length === 0) return false
|
||||||
|
let nonPrintable = 0
|
||||||
|
for (const byte of bytes) {
|
||||||
|
if (byte === 0) return true
|
||||||
|
if (byte < 9 || (byte > 13 && byte < 32)) nonPrintable++
|
||||||
|
}
|
||||||
|
return nonPrintable / bytes.length > 0.3
|
||||||
|
}
|
||||||
|
const decodeUtf8 = (decoder: TextDecoder, bytes?: Uint8Array) => decoder.decode(bytes, { stream: bytes !== undefined })
|
||||||
|
const decodeChunk = (resource: string, decoder: TextDecoder, bytes: Uint8Array) =>
|
||||||
|
bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : Effect.succeed(decodeUtf8(decoder, bytes))
|
||||||
|
|
||||||
|
export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) {
|
||||||
|
const info = yield* fs.stat(input)
|
||||||
|
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
|
||||||
|
if (!type) return yield* Effect.fail(new PathKindError({ resource: input, expected: "a file or directory" }))
|
||||||
|
return type
|
||||||
|
})
|
||||||
|
|
||||||
export const read = Effect.fn("ReadTool.read")(function* (
|
export const read = Effect.fn("ReadTool.read")(function* (
|
||||||
files: Files,
|
fs: FSUtil.Interface,
|
||||||
input: AbsolutePath,
|
input: string,
|
||||||
resource: string,
|
resource: string,
|
||||||
page: PageInput = {},
|
page: PageInput = {},
|
||||||
) {
|
) {
|
||||||
const first = yield* files.read(input, { offset: 0, length: FIRST_CHUNK }).pipe(
|
const real = yield* fs.realPath(input)
|
||||||
Effect.catchTag("Environment.WrongKind", (error) => {
|
return yield* Effect.scoped(
|
||||||
if (error.actual !== "directory")
|
Effect.gen(function* () {
|
||||||
return Effect.fail(new PathKindError({ resource, expected: "a file or directory" }))
|
const file = yield* fs.open(real, { flag: "r" })
|
||||||
return files.list(input).pipe(
|
const info = yield* file.stat
|
||||||
Effect.map((entries) => list(entries, page)),
|
if (info.type !== "File") return yield* Effect.fail(new PathKindError({ resource, expected: "a file" }))
|
||||||
Effect.catchTag("Environment.WrongKind", () =>
|
const first = Option.getOrElse(
|
||||||
Effect.fail(new PathKindError({ resource, expected: "a file or directory" })),
|
yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || 4 * 1024)),
|
||||||
),
|
() => new Uint8Array(),
|
||||||
)
|
)
|
||||||
|
const mime = mediaMime(first)
|
||||||
|
if (mime) {
|
||||||
|
if (info.size > MAX_MEDIA_INGEST_BYTES)
|
||||||
|
return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES }))
|
||||||
|
const chunks = [first]
|
||||||
|
let total = first.length
|
||||||
|
while (total <= MAX_MEDIA_INGEST_BYTES) {
|
||||||
|
const chunk = yield* file.readAlloc(Math.min(64 * 1024, MAX_MEDIA_INGEST_BYTES + 1 - total))
|
||||||
|
if (Option.isNone(chunk)) break
|
||||||
|
chunks.push(chunk.value)
|
||||||
|
total += chunk.value.length
|
||||||
|
}
|
||||||
|
if (total > MAX_MEDIA_INGEST_BYTES)
|
||||||
|
return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES }))
|
||||||
|
return {
|
||||||
|
type: "file" as const,
|
||||||
|
uri: pathToFileURL(real).href,
|
||||||
|
name: path.basename(real),
|
||||||
|
content: Buffer.concat(
|
||||||
|
chunks.map((chunk) => Buffer.from(chunk)),
|
||||||
|
total,
|
||||||
|
).toString("base64"),
|
||||||
|
encoding: "base64" as const,
|
||||||
|
mime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
|
||||||
|
if (!paged) {
|
||||||
|
if (binary(first)) return yield* Effect.fail(new BinaryFileError({ resource }))
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
const text = [decodeUtf8(decoder, first)]
|
||||||
|
while (true) {
|
||||||
|
const chunk = yield* file.readAlloc(64 * 1024)
|
||||||
|
if (Option.isNone(chunk)) break
|
||||||
|
text.push(yield* decodeChunk(resource, decoder, chunk.value))
|
||||||
|
}
|
||||||
|
text.push(decodeUtf8(decoder))
|
||||||
|
return {
|
||||||
|
type: "file" as const,
|
||||||
|
uri: pathToFileURL(real).href,
|
||||||
|
name: path.basename(real),
|
||||||
|
content: text.join(""),
|
||||||
|
encoding: "utf8" as const,
|
||||||
|
mime: FSUtil.mimeType(real),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const offset = page.offset || 1
|
||||||
|
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
|
||||||
|
const lines: string[] = []
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let pending = ""
|
||||||
|
let discard = false
|
||||||
|
let line = 1
|
||||||
|
let bytes = 0
|
||||||
|
let next: number | undefined
|
||||||
|
const append = (input: string) => {
|
||||||
|
if (line < offset) {
|
||||||
|
line++
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (lines.length >= limit || bytes >= MAX_READ_BYTES) {
|
||||||
|
next = line
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input
|
||||||
|
const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0)
|
||||||
|
if (bytes + size > MAX_READ_BYTES) {
|
||||||
|
next = line
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
lines.push(text)
|
||||||
|
bytes += size
|
||||||
|
line++
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const consume = (input: string) => {
|
||||||
|
let text = input
|
||||||
|
while (true) {
|
||||||
|
const index = text.indexOf("\n")
|
||||||
|
if (index === -1) {
|
||||||
|
if (!discard) {
|
||||||
|
pending += text
|
||||||
|
if (pending.length > MAX_LINE_LENGTH) {
|
||||||
|
pending = pending.slice(0, MAX_LINE_LENGTH + 1)
|
||||||
|
discard = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const current = pending + (discard ? "" : text.slice(0, index))
|
||||||
|
pending = ""
|
||||||
|
discard = false
|
||||||
|
text = text.slice(index + 1)
|
||||||
|
if (!append(current.endsWith("\r") ? current.slice(0, -1) : current)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const consumeChunk = Effect.fnUntraced(function* (chunk: Uint8Array) {
|
||||||
|
let start = 0
|
||||||
|
while (start < chunk.length) {
|
||||||
|
if (lines.length >= limit || bytes >= MAX_READ_BYTES) {
|
||||||
|
next = line
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const newline = chunk.indexOf(10, start)
|
||||||
|
const end = newline === -1 ? chunk.length : newline + 1
|
||||||
|
const segment = chunk.subarray(start, end)
|
||||||
|
if (binary(segment)) return yield* Effect.fail(new BinaryFileError({ resource }))
|
||||||
|
if (!consume(decodeUtf8(decoder, segment))) return false
|
||||||
|
start = end
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
let done = !(yield* consumeChunk(first))
|
||||||
|
while (!done) {
|
||||||
|
const chunk = yield* file.readAlloc(64 * 1024)
|
||||||
|
if (Option.isNone(chunk)) break
|
||||||
|
done = !(yield* consumeChunk(chunk.value))
|
||||||
|
}
|
||||||
|
if (!done) {
|
||||||
|
const tail = decodeUtf8(decoder)
|
||||||
|
if (!discard) pending += tail
|
||||||
|
if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)
|
||||||
|
}
|
||||||
|
if (lines.length === 0 && offset !== 1) return yield* Effect.fail(new OffsetOutOfRangeError({ offset }))
|
||||||
|
return new TextPage({
|
||||||
|
type: "text-page",
|
||||||
|
content: lines.join("\n"),
|
||||||
|
mime: FSUtil.mimeType(real),
|
||||||
|
offset,
|
||||||
|
truncated: next !== undefined,
|
||||||
|
...(next === undefined ? {} : { next }),
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (first instanceof ListPage) return first
|
|
||||||
|
|
||||||
const media = Mime.detect(first.bytes)
|
|
||||||
if (MEDIA_MIMES.has(media)) {
|
|
||||||
if (first.info.size > MAX_MEDIA_INGEST_BYTES)
|
|
||||||
return yield* new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES })
|
|
||||||
const whole = yield* readFile(files, input, resource)
|
|
||||||
return {
|
|
||||||
type: "file" as const,
|
|
||||||
uri: pathToFileURL(input).href,
|
|
||||||
name: path.basename(input),
|
|
||||||
content: Buffer.from(whole.bytes).toString("base64"),
|
|
||||||
encoding: "base64" as const,
|
|
||||||
mime: media,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const paged = first.info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
|
|
||||||
if (!paged) {
|
|
||||||
if (first.bytes.includes(0)) return yield* new BinaryFileError({ resource })
|
|
||||||
return {
|
|
||||||
type: "file" as const,
|
|
||||||
uri: pathToFileURL(input).href,
|
|
||||||
name: path.basename(input),
|
|
||||||
content: new TextDecoder().decode(first.bytes),
|
|
||||||
encoding: "utf8" as const,
|
|
||||||
mime: mimeType(input),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const chunks = [first.bytes]
|
|
||||||
while (true) {
|
|
||||||
const bytes = Buffer.concat(chunks)
|
|
||||||
const eof = bytes.length >= first.info.size
|
|
||||||
const result = textPage(bytes, eof, page)
|
|
||||||
if (result !== undefined) return yield* makeTextPage(bytes, input, resource, result)
|
|
||||||
const next = yield* readFile(files, input, resource, { offset: bytes.length, length: FIRST_CHUNK })
|
|
||||||
if (next.bytes.length === 0) {
|
|
||||||
const result = textPage(bytes, true, page)
|
|
||||||
if (result === undefined) return yield* Effect.die("Read page did not settle at EOF")
|
|
||||||
return yield* makeTextPage(bytes, input, resource, result)
|
|
||||||
}
|
|
||||||
chunks.push(next.bytes)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const readFile = (
|
export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface, input: string, page: PageInput = {}) {
|
||||||
files: Files,
|
const real = yield* fs.realPath(input)
|
||||||
input: AbsolutePath,
|
const items = yield* fs.readDirectoryEntries(real)
|
||||||
resource: string,
|
|
||||||
range?: { readonly offset: number; readonly length: number },
|
|
||||||
) =>
|
|
||||||
files
|
|
||||||
.read(input, range)
|
|
||||||
.pipe(
|
|
||||||
Effect.catchTag("Environment.WrongKind", () => Effect.fail(new PathKindError({ resource, expected: "a file" }))),
|
|
||||||
)
|
|
||||||
|
|
||||||
const makeTextPage = Effect.fnUntraced(function* (
|
|
||||||
bytes: Uint8Array,
|
|
||||||
input: AbsolutePath,
|
|
||||||
resource: string,
|
|
||||||
result: NonNullable<ReturnType<typeof textPage>>,
|
|
||||||
) {
|
|
||||||
if (bytes.subarray(0, result.consumed).includes(0)) return yield* new BinaryFileError({ resource })
|
|
||||||
if (result.entries.length === 0 && result.offset !== 1)
|
|
||||||
return yield* new OffsetOutOfRangeError({ offset: result.offset })
|
|
||||||
return new TextPage({
|
|
||||||
type: "text-page",
|
|
||||||
content: result.entries.join("\n"),
|
|
||||||
mime: mimeType(input),
|
|
||||||
offset: result.offset,
|
|
||||||
truncated: result.next !== undefined,
|
|
||||||
...(result.next === undefined ? {} : { next: result.next }),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const list = (items: ReadonlyArray<Environment.DirEntry>, page: PageInput) => {
|
|
||||||
const offset = page.offset || 1
|
const offset = page.offset || 1
|
||||||
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
|
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
|
||||||
const visible = items
|
const visible = items
|
||||||
@@ -237,58 +316,18 @@ const list = (items: ReadonlyArray<Environment.DirEntry>, page: PageInput) => {
|
|||||||
truncated,
|
truncated,
|
||||||
...(truncated ? { next: offset + selected.length } : {}),
|
...(truncated ? { next: offset + selected.length } : {}),
|
||||||
})
|
})
|
||||||
}
|
})
|
||||||
|
|
||||||
const textPage = (bytes: Uint8Array, eof: boolean, page: PageInput) => {
|
|
||||||
const offset = page.offset || 1
|
|
||||||
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
|
|
||||||
const decoded = new TextDecoder().decode(bytes)
|
|
||||||
const split = decoded.split("\n")
|
|
||||||
const complete = eof ? (split.at(-1) === "" ? split.slice(0, -1) : split) : split.slice(0, -1)
|
|
||||||
const available = complete.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line))
|
|
||||||
|
|
||||||
const entries: string[] = []
|
|
||||||
let size = 0
|
|
||||||
let next: number | undefined
|
|
||||||
for (const [index, value] of available.slice(offset - 1).entries()) {
|
|
||||||
const line = offset + index
|
|
||||||
if (entries.length >= limit || size >= MAX_READ_BYTES) {
|
|
||||||
next = line
|
|
||||||
break
|
|
||||||
}
|
|
||||||
const text = value.length > MAX_LINE_LENGTH ? value.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : value
|
|
||||||
const lineSize = Buffer.byteLength(text, "utf-8") + (entries.length > 0 ? 1 : 0)
|
|
||||||
if (size + lineSize > MAX_READ_BYTES) {
|
|
||||||
next = line
|
|
||||||
break
|
|
||||||
}
|
|
||||||
entries.push(text)
|
|
||||||
size += lineSize
|
|
||||||
}
|
|
||||||
if (next === undefined && entries.length >= limit && (!eof || offset - 1 + entries.length < available.length))
|
|
||||||
next = offset + entries.length
|
|
||||||
if (!eof && next === undefined) return
|
|
||||||
|
|
||||||
const consumedLines = next === undefined ? available.length : next - 1
|
|
||||||
const consumed = consumedLines === 0 ? 0 : (nthNewline(bytes, consumedLines) ?? bytes.length)
|
|
||||||
return { entries, offset, next, consumed }
|
|
||||||
}
|
|
||||||
|
|
||||||
const nthNewline = (bytes: Uint8Array, count: number) => {
|
|
||||||
let found = 0
|
|
||||||
for (const [index, byte] of bytes.entries()) {
|
|
||||||
if (byte !== 10) continue
|
|
||||||
found++
|
|
||||||
if (found === count) return index + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const layer = Layer.effect(
|
const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const environment = yield* Environment.Service
|
const fs = yield* FSUtil.Service
|
||||||
return Service.of({ read: (path, resource, page) => read(environment.files, path, resource, page) })
|
return Service.of({
|
||||||
|
inspect: (path) => inspect(fs, path),
|
||||||
|
read: (path, resource, page) => read(fs, path, resource, page),
|
||||||
|
list: (path, page) => list(fs, path, page),
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const node = makeLocationNode({ service: Service, layer, deps: [Environment.node] })
|
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
|
||||||
|
|||||||
@@ -50,33 +50,6 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
|
|||||||
const decode = Schema.decodeUnknownSync(Info)
|
const decode = Schema.decodeUnknownSync(Info)
|
||||||
|
|
||||||
describe("ConfigProviderPlugin.Plugin", () => {
|
describe("ConfigProviderPlugin.Plugin", () => {
|
||||||
it.effect("adds key auth for custom providers without env credentials", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const integrations = yield* Integration.Service
|
|
||||||
const entries = [
|
|
||||||
new Document({
|
|
||||||
type: "document",
|
|
||||||
info: decode({
|
|
||||||
providers: {
|
|
||||||
litellm: {
|
|
||||||
package: "aisdk:@ai-sdk/openai-compatible",
|
|
||||||
models: { chat: {} },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
|
|
||||||
yield* addPlugin(entries)
|
|
||||||
|
|
||||||
expect(yield* integrations.get(Integration.ID.make("litellm"))).toMatchObject({
|
|
||||||
id: "litellm",
|
|
||||||
name: "litellm",
|
|
||||||
methods: [{ type: "key", label: "Manually enter API Key" }],
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("defaults custom models to agent capabilities", () =>
|
it.effect("defaults custom models to agent capabilities", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
|
|||||||
@@ -1,39 +1,11 @@
|
|||||||
import fs from "node:fs/promises"
|
import fs from "node:fs/promises"
|
||||||
import { describe, expect } from "bun:test"
|
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { ChildProcessSpawner } from "effect/unstable/process"
|
import { ChildProcessSpawner } from "effect/unstable/process"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
|
||||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
import {
|
import { execDefaults, Failed, makeFiles, makeMemoryDriver } from "../src/environment/index"
|
||||||
execDefaults,
|
|
||||||
Failed,
|
|
||||||
makeFiles,
|
|
||||||
makeLocalDriver,
|
|
||||||
makeMemoryDriver,
|
|
||||||
NotFound,
|
|
||||||
typeFollowing,
|
|
||||||
} from "../src/environment/index"
|
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
import { environmentConformance } from "./lib/environment-conformance"
|
import { environmentConformance } from "./lib/environment-conformance"
|
||||||
import { it } from "./lib/effect"
|
|
||||||
|
|
||||||
describe("typeFollowing", () => {
|
|
||||||
it.effect("follows symlinks without changing stat semantics", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const driver = makeMemoryDriver()
|
|
||||||
const files = makeFiles(driver)
|
|
||||||
yield* files.mkdir("/directory")
|
|
||||||
yield* files.write("/file", new Uint8Array())
|
|
||||||
yield* driver.symlink("/directory", "/directory-link")
|
|
||||||
yield* driver.symlink("/file", "/file-link")
|
|
||||||
yield* driver.symlink("/missing", "/dangling-link")
|
|
||||||
|
|
||||||
expect(yield* typeFollowing(files, "/directory-link")).toBe("directory")
|
|
||||||
expect(yield* typeFollowing(files, "/file-link")).toBe("file")
|
|
||||||
expect(yield* typeFollowing(files, "/dangling-link").pipe(Effect.flip)).toBeInstanceOf(NotFound)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
environmentConformance("memory environment", () =>
|
environmentConformance("memory environment", () =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
@@ -46,27 +18,6 @@ environmentConformance("memory environment", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
environmentConformance("local environment", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
|
||||||
const tmp = yield* Effect.promise(() => tmpdir("opencode-local-environment-"))
|
|
||||||
return {
|
|
||||||
files: makeFiles(makeLocalDriver(spawner)),
|
|
||||||
root: tmp.path,
|
|
||||||
...(process.platform === "win32"
|
|
||||||
? {}
|
|
||||||
: {
|
|
||||||
symlink: (target: string, link: string) =>
|
|
||||||
Effect.tryPromise({
|
|
||||||
try: () => fs.symlink(target, link),
|
|
||||||
catch: (cause) => new Failed({ path: link, cause }),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
dispose: Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
||||||
}
|
|
||||||
}).pipe(Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))),
|
|
||||||
)
|
|
||||||
|
|
||||||
environmentConformance(
|
environmentConformance(
|
||||||
"GNU exec environment",
|
"GNU exec environment",
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Deferred, Effect, Fiber, Layer } from "effect"
|
|||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||||
import { Environment } from "@opencode-ai/core/environment"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
@@ -13,7 +13,7 @@ import { location } from "./fixture/location"
|
|||||||
import { tmpdir } from "./fixture/tmpdir"
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
import { it } from "./lib/effect"
|
import { it } from "./lib/effect"
|
||||||
|
|
||||||
function provide(directory: string, environmentLayer = LayerNode.compile(Environment.node)) {
|
function provide(directory: string, filesystemLayer = LayerNode.compile(FSUtil.node)) {
|
||||||
const activeLocation = Layer.succeed(
|
const activeLocation = Layer.succeed(
|
||||||
Location.Service,
|
Location.Service,
|
||||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
||||||
@@ -21,7 +21,7 @@ function provide(directory: string, environmentLayer = LayerNode.compile(Environ
|
|||||||
return Effect.provide(
|
return Effect.provide(
|
||||||
AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [
|
AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [
|
||||||
[Location.node, activeLocation],
|
[Location.node, activeLocation],
|
||||||
[Environment.node, environmentLayer],
|
[FSUtil.node, filesystemLayer],
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -152,57 +152,6 @@ describe("FileMutation", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("shares transaction locks across Location service instances", () =>
|
|
||||||
withTmp((directory) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const firstStarted = yield* Deferred.make<void>()
|
|
||||||
const releaseFirst = yield* Deferred.make<void>()
|
|
||||||
const secondStarted = yield* Deferred.make<void>()
|
|
||||||
const target = path.join(directory, "shared.txt")
|
|
||||||
const first = yield* Effect.gen(function* () {
|
|
||||||
const files = yield* FileMutation.Service
|
|
||||||
yield* files.withLock([target])(
|
|
||||||
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
|
|
||||||
)
|
|
||||||
}).pipe(provide(directory), Effect.forkChild)
|
|
||||||
yield* Deferred.await(firstStarted)
|
|
||||||
const second = yield* Effect.gen(function* () {
|
|
||||||
const files = yield* FileMutation.Service
|
|
||||||
yield* files.withLock([target])(Deferred.succeed(secondStarted, undefined))
|
|
||||||
}).pipe(provide(directory), Effect.forkChild)
|
|
||||||
yield* Effect.yieldNow
|
|
||||||
expect(yield* Deferred.isDone(secondStarted)).toBe(false)
|
|
||||||
|
|
||||||
yield* Deferred.succeed(releaseFirst, undefined)
|
|
||||||
yield* Deferred.await(secondStarted)
|
|
||||||
yield* Fiber.join(first)
|
|
||||||
yield* Fiber.join(second)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("allows transaction locks for distinct resolved paths to proceed independently", () =>
|
|
||||||
withTmp((directory) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const firstStarted = yield* Deferred.make<void>()
|
|
||||||
const releaseFirst = yield* Deferred.make<void>()
|
|
||||||
const secondFinished = yield* Deferred.make<void>()
|
|
||||||
const files = yield* FileMutation.Service
|
|
||||||
const first = yield* files
|
|
||||||
.withLock([path.join(directory, "first.txt")])(
|
|
||||||
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
|
|
||||||
)
|
|
||||||
.pipe(Effect.forkChild)
|
|
||||||
yield* Deferred.await(firstStarted)
|
|
||||||
yield* files.withLock([path.join(directory, "second.txt")])(Deferred.succeed(secondFinished, undefined))
|
|
||||||
expect(yield* Deferred.isDone(secondFinished)).toBe(true)
|
|
||||||
|
|
||||||
yield* Deferred.succeed(releaseFirst, undefined)
|
|
||||||
yield* Fiber.join(first)
|
|
||||||
}).pipe(provide(directory)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("allows distinct absolute targets to proceed independently", () =>
|
it.live("allows distinct absolute targets to proceed independently", () =>
|
||||||
withTmp((directory) =>
|
withTmp((directory) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -242,16 +191,16 @@ describe("FileMutation", () => {
|
|||||||
|
|
||||||
function instrumentWrites(run: <E>(write: Effect.Effect<void, E>, target: string) => Effect.Effect<void, E>) {
|
function instrumentWrites(run: <E>(write: Effect.Effect<void, E>, target: string) => Effect.Effect<void, E>) {
|
||||||
return Layer.effect(
|
return Layer.effect(
|
||||||
Environment.Service,
|
FSUtil.Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const environment = yield* Environment.Service
|
const filesystem = yield* FSUtil.Service
|
||||||
return Environment.Service.of({
|
return FSUtil.Service.of({
|
||||||
...environment,
|
...filesystem,
|
||||||
files: {
|
writeWithDirs: (target, content, mode) => run(filesystem.writeWithDirs(target, content, mode), target),
|
||||||
...environment.files,
|
writeFile: (target, content, options) => run(filesystem.writeFile(target, content, options), target),
|
||||||
write: (target, content) => run(environment.files.write(target, content), target),
|
writeFileString: (target, content, options) =>
|
||||||
},
|
run(filesystem.writeFileString(target, content, options), target),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
).pipe(Layer.provide(LayerNode.compile(Environment.node)))
|
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ import { location } from "../fixture/location"
|
|||||||
import { tmpdir } from "../fixture/tmpdir"
|
import { tmpdir } from "../fixture/tmpdir"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
|
const describeWatcher = Watcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
|
||||||
|
|
||||||
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
|
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
|
||||||
const describeNative = process.env.CI ? describe.skip : describe
|
|
||||||
|
|
||||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.node])))
|
const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.node])))
|
||||||
|
|
||||||
@@ -74,9 +75,10 @@ describe("Watcher lifecycle", () => {
|
|||||||
const interrupted = yield* Deferred.make<void>()
|
const interrupted = yield* Deferred.make<void>()
|
||||||
yield* Effect.gen(function* () {
|
yield* Effect.gen(function* () {
|
||||||
const watcher = yield* Watcher.Service
|
const watcher = yield* Watcher.Service
|
||||||
const consumer = yield* watcher
|
const consumer = yield* watcher.subscribe({ path: "/pending", type: "directory" }).pipe(
|
||||||
.subscribe({ path: "/pending", type: "directory" })
|
Effect.flatMap(Stream.runDrain),
|
||||||
.pipe(Effect.flatMap(Stream.runDrain), Effect.forkScoped({ startImmediately: true }))
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
|
)
|
||||||
yield* Deferred.await(started)
|
yield* Deferred.await(started)
|
||||||
yield* Fiber.interrupt(consumer)
|
yield* Fiber.interrupt(consumer)
|
||||||
expect(yield* Deferred.isDone(interrupted)).toBe(true)
|
expect(yield* Deferred.isDone(interrupted)).toBe(true)
|
||||||
@@ -97,9 +99,10 @@ describe("Watcher lifecycle", () => {
|
|||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const watcher = yield* Watcher.Service
|
const watcher = yield* Watcher.Service
|
||||||
const consume = () =>
|
const consume = () =>
|
||||||
watcher
|
watcher.subscribe({ path: "/shared", type: "directory" }).pipe(
|
||||||
.subscribe({ path: "/shared", type: "directory" })
|
Effect.flatMap(Stream.runDrain),
|
||||||
.pipe(Effect.flatMap(Stream.runDrain), Effect.forkScoped({ startImmediately: true }))
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
|
)
|
||||||
const first = yield* consume()
|
const first = yield* consume()
|
||||||
const second = yield* consume()
|
const second = yield* consume()
|
||||||
yield* Effect.yieldNow
|
yield* Effect.yieldNow
|
||||||
@@ -135,26 +138,22 @@ describe("Watcher lifecycle", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function provide(directory: string, vcs?: Location.Interface["vcs"], watcher?: Layer.Layer<Watcher.Service>) {
|
function provide(directory: string, vcs?: Location.Interface["vcs"]) {
|
||||||
const locationLayer = Layer.succeed(
|
const locationLayer = Layer.succeed(
|
||||||
Location.Service,
|
Location.Service,
|
||||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
|
Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
|
||||||
)
|
)
|
||||||
const built = AppNodeBuilder.build(LocationWatcher.node, [
|
return Effect.provide(
|
||||||
[Config.node, configLayer],
|
AppNodeBuilder.build(LocationWatcher.node, [
|
||||||
[Location.node, locationLayer],
|
[Config.node, configLayer],
|
||||||
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
|
[Location.node, locationLayer],
|
||||||
])
|
]),
|
||||||
return Effect.provide(built)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function withTmp<A, E, R>(
|
function withTmp<A, E, R>(
|
||||||
f: (directory: string, vcs?: Location.Interface["vcs"]) => Effect.Effect<A, E, R>,
|
f: (directory: string, vcs?: Location.Interface["vcs"]) => Effect.Effect<A, E, R>,
|
||||||
options?: {
|
options?: { vcs?: "git" | "hg"; init?: (directory: string) => Promise<void> },
|
||||||
vcs?: "git" | "hg"
|
|
||||||
init?: (directory: string) => Promise<void>
|
|
||||||
watcher?: Layer.Layer<Watcher.Service>
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
return Effect.acquireRelease(
|
return Effect.acquireRelease(
|
||||||
Effect.promise(async () => {
|
Effect.promise(async () => {
|
||||||
@@ -174,57 +173,9 @@ function withTmp<A, E, R>(
|
|||||||
return { tmp, vcs: { type: "git" as const, store: AbsolutePath.make(path.join(tmp.path, ".git")) } }
|
return { tmp, vcs: { type: "git" as const, store: AbsolutePath.make(path.join(tmp.path, ".git")) } }
|
||||||
}),
|
}),
|
||||||
({ tmp }) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
({ tmp }) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
).pipe(Effect.flatMap(({ tmp, vcs }) => f(tmp.path, vcs).pipe(provide(tmp.path, vcs, options?.watcher))))
|
).pipe(Effect.flatMap(({ tmp, vcs }) => f(tmp.path, vcs).pipe(provide(tmp.path, vcs))))
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("LocationWatcher subscriptions", () => {
|
|
||||||
it.live("watches only exact Git branch metadata", () => {
|
|
||||||
const subscriptions: Watcher.WatchInput[] = []
|
|
||||||
const watcher = Layer.succeed(
|
|
||||||
Watcher.Service,
|
|
||||||
Watcher.Service.of({
|
|
||||||
subscribe: (input) => Effect.sync(() => subscriptions.push(input)).pipe(Effect.as(Stream.empty)),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return withTmp(
|
|
||||||
(directory) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* LocationWatcher.Service
|
|
||||||
yield* Effect.sync(() => subscriptions.length).pipe(
|
|
||||||
Effect.filterOrFail((count) => count > 0),
|
|
||||||
Effect.retry(Schedule.spaced("10 millis")),
|
|
||||||
)
|
|
||||||
yield* Effect.sleep("10 millis")
|
|
||||||
expect(subscriptions).toEqual([{ path: path.join(directory, ".git", "HEAD"), type: "file" }])
|
|
||||||
}),
|
|
||||||
{ vcs: "git", watcher },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.live("watches only exact Hg branch metadata", () => {
|
|
||||||
const subscriptions: Watcher.WatchInput[] = []
|
|
||||||
const watcher = Layer.succeed(
|
|
||||||
Watcher.Service,
|
|
||||||
Watcher.Service.of({
|
|
||||||
subscribe: (input) => Effect.sync(() => subscriptions.push(input)).pipe(Effect.as(Stream.empty)),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return withTmp(
|
|
||||||
(directory) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* LocationWatcher.Service
|
|
||||||
yield* Effect.sync(() => subscriptions.length).pipe(
|
|
||||||
Effect.filterOrFail((count) => count > 0),
|
|
||||||
Effect.retry(Schedule.spaced("10 millis")),
|
|
||||||
)
|
|
||||||
yield* Effect.sleep("10 millis")
|
|
||||||
expect(subscriptions).toEqual([{ path: path.join(directory, ".hg", "branch"), type: "file" }])
|
|
||||||
}),
|
|
||||||
{ vcs: "hg", watcher },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
function wait(check: (event: WatcherEvent) => boolean) {
|
function wait(check: (event: WatcherEvent) => boolean) {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
@@ -275,18 +226,31 @@ function eventuallyUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: (
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ready(file: string, eventFile = file) {
|
function noUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>, timeout = 500) {
|
||||||
|
return Effect.acquireUseRelease(
|
||||||
|
wait(check),
|
||||||
|
({ deferred }) =>
|
||||||
|
trigger.pipe(
|
||||||
|
Effect.andThen(Deferred.await(deferred)),
|
||||||
|
Effect.timeoutOption(`${timeout} millis`),
|
||||||
|
Effect.tap((result) => Effect.sync(() => expect(result).toEqual(Option.none()))),
|
||||||
|
),
|
||||||
|
({ fiber }) => Fiber.interrupt(fiber),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ready(directory: string) {
|
||||||
|
const file = path.join(directory, `.watcher-${Math.random().toString(36).slice(2)}`)
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const content = (yield* fs.readFileStringSafe(file)) ?? `ready-${Math.random()}`
|
|
||||||
yield* eventuallyUpdate(
|
yield* eventuallyUpdate(
|
||||||
(event) => event.file === eventFile,
|
(event) => event.file === file,
|
||||||
() => fs.writeFileString(file, content),
|
() => fs.writeFileString(file, `ready-${Math.random()}`),
|
||||||
).pipe(Effect.asVoid)
|
).pipe(Effect.ensuring(fs.remove(file, { force: true }).pipe(Effect.ignore)), Effect.asVoid)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
describeNative("LocationWatcher", () => {
|
describeWatcher("LocationWatcher", () => {
|
||||||
it.live("limits file watches to the exact target", () =>
|
it.live("limits file watches to the exact target", () =>
|
||||||
withTmp((directory) =>
|
withTmp((directory) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -312,25 +276,94 @@ describeNative("LocationWatcher", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("detects creation of a missing directory target", () =>
|
it.live("publishes root create, update, and delete events", () =>
|
||||||
|
withTmp(
|
||||||
|
(directory) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* FSUtil.Service
|
||||||
|
const file = path.join(directory, "watch.txt")
|
||||||
|
yield* ready(directory)
|
||||||
|
for (const item of [
|
||||||
|
{ event: "add" as const, trigger: fs.writeFileString(file, "a") },
|
||||||
|
{ event: "change" as const, trigger: fs.writeFileString(file, "b") },
|
||||||
|
{ event: "unlink" as const, trigger: fs.remove(file) },
|
||||||
|
]) {
|
||||||
|
expect(
|
||||||
|
yield* nextUpdate((event) => event.file === file && event.event === item.event, item.trigger),
|
||||||
|
).toEqual({
|
||||||
|
file,
|
||||||
|
event: item.event,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{ vcs: "git" },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("skips non-git roots", () =>
|
||||||
withTmp((directory) =>
|
withTmp((directory) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const watcher = yield* Watcher.Service
|
const file = path.join(directory, "plain.txt")
|
||||||
const target = path.join(directory, "generated")
|
yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "plain"))
|
||||||
const updates = yield* watcher.subscribe({ path: target, type: "file" })
|
}),
|
||||||
const update = yield* updates.pipe(
|
),
|
||||||
Stream.take(1),
|
)
|
||||||
Stream.runHead,
|
|
||||||
Effect.forkScoped({ startImmediately: true }),
|
|
||||||
)
|
|
||||||
const creates = yield* Effect.suspend(() =>
|
|
||||||
fs.remove(target, { recursive: true, force: true }).pipe(Effect.andThen(fs.ensureDir(target))),
|
|
||||||
).pipe(Effect.repeat(Schedule.spaced("10 millis")), Effect.forkScoped)
|
|
||||||
const event = yield* Fiber.join(update).pipe(Effect.ensuring(Fiber.interrupt(creates)))
|
|
||||||
|
|
||||||
expect(event.valueOrUndefined?.path).toBe(target)
|
it.live("ignores dependency, VCS, and build directories at any depth", () =>
|
||||||
}).pipe(Effect.provide(AppNodeBuilder.build(Watcher.node))),
|
withTmp(
|
||||||
|
(directory) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const afs = yield* FSUtil.Service
|
||||||
|
yield* ready(directory)
|
||||||
|
const roots = ["node_modules", ".git", "dist"].map((name) => path.join(directory, "nested", name))
|
||||||
|
const files = roots.map((root) => path.join(root, "package", "index.js"))
|
||||||
|
yield* noUpdate(
|
||||||
|
(event) => roots.some((root) => event.file === root || event.file.startsWith(`${root}${path.sep}`)),
|
||||||
|
Effect.forEach(files, (file) => afs.writeWithDirs(file, "ignored"), {
|
||||||
|
concurrency: "unbounded",
|
||||||
|
discard: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
{ vcs: "git" },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("cleanup stops publishing events", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const bus = yield* Bus.Service
|
||||||
|
const fs = yield* FSUtil.Service
|
||||||
|
const tmp = yield* Effect.acquireRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
|
)
|
||||||
|
yield* ready(tmp.path).pipe(
|
||||||
|
provide(tmp.path, { type: "git", store: AbsolutePath.make(path.join(tmp.path, ".git")) }),
|
||||||
|
Effect.scoped,
|
||||||
|
)
|
||||||
|
const file = path.join(tmp.path, "after-dispose.txt")
|
||||||
|
yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "gone")).pipe(
|
||||||
|
Effect.provideService(Bus.Service, bus),
|
||||||
|
)
|
||||||
|
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.node])))),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("ignores .git/index changes", () =>
|
||||||
|
withTmp(
|
||||||
|
(directory) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* FSUtil.Service
|
||||||
|
const index = path.join(directory, ".git", "index")
|
||||||
|
yield* ready(directory)
|
||||||
|
yield* noUpdate(
|
||||||
|
(event) => event.file === index,
|
||||||
|
fs
|
||||||
|
.writeFileString(path.join(directory, "tracked.txt"), "a")
|
||||||
|
.pipe(Effect.andThen(Effect.promise(() => $`git add .`.cwd(directory).quiet())), Effect.asVoid),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
{ vcs: "git" },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -341,11 +374,11 @@ describeNative("LocationWatcher", () => {
|
|||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const head = path.join(directory, ".git", "HEAD")
|
const head = path.join(directory, ".git", "HEAD")
|
||||||
const branch = `watch-${Math.random().toString(36).slice(2)}`
|
const branch = `watch-${Math.random().toString(36).slice(2)}`
|
||||||
yield* ready(head)
|
yield* ready(directory)
|
||||||
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
|
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
|
||||||
expect(
|
expect(
|
||||||
yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)),
|
yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)),
|
||||||
).toEqual({ file: head, event: "change" })
|
).toMatchObject({ file: head })
|
||||||
}),
|
}),
|
||||||
{ vcs: "git" },
|
{ vcs: "git" },
|
||||||
),
|
),
|
||||||
@@ -360,8 +393,8 @@ describeNative("LocationWatcher", () => {
|
|||||||
const afs = yield* FSUtil.Service
|
const afs = yield* FSUtil.Service
|
||||||
const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
|
const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
|
||||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(actual, { recursive: true, force: true })))
|
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(actual, { recursive: true, force: true })))
|
||||||
|
yield* ready(directory)
|
||||||
const head = path.join(directory, ".git", "HEAD")
|
const head = path.join(directory, ".git", "HEAD")
|
||||||
yield* ready(head, path.join(actual, "HEAD"))
|
|
||||||
const branch = `watch-${Math.random().toString(36).slice(2)}`
|
const branch = `watch-${Math.random().toString(36).slice(2)}`
|
||||||
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
|
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
|
||||||
expect(
|
expect(
|
||||||
@@ -389,7 +422,7 @@ describeNative("LocationWatcher", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const branch = path.join(directory, ".hg", "branch")
|
const branch = path.join(directory, ".hg", "branch")
|
||||||
yield* ready(branch)
|
yield* ready(directory)
|
||||||
expect(
|
expect(
|
||||||
yield* nextUpdate((event) => event.file === branch, fs.writeFileString(branch, "feature\n")),
|
yield* nextUpdate((event) => event.file === branch, fs.writeFileString(branch, "feature\n")),
|
||||||
).toMatchObject({ file: branch })
|
).toMatchObject({ file: branch })
|
||||||
|
|||||||
@@ -105,29 +105,19 @@ export const environmentConformance = <E>(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
check("preserves symlink metadata while following symlinks for content", (harness) =>
|
check("reports symlinks without resolving them", (harness) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (!harness.symlink) return
|
if (!harness.symlink) return
|
||||||
yield* harness.files.write(`${harness.root}/target`, bytes("target"))
|
yield* harness.files.write(`${harness.root}/target`, bytes("target"))
|
||||||
yield* harness.files.write(`${harness.root}/target-dir/file`, bytes("through link"))
|
yield* harness.files.write(`${harness.root}/target-dir/file`, bytes("through link"))
|
||||||
yield* harness.symlink("../target", `${harness.root}/target-dir/entry-link`)
|
|
||||||
yield* harness.symlink("target", `${harness.root}/link`)
|
yield* harness.symlink("target", `${harness.root}/link`)
|
||||||
yield* harness.symlink("target-dir", `${harness.root}/link-dir`)
|
yield* harness.symlink("target-dir", `${harness.root}/link-dir`)
|
||||||
yield* harness.symlink("missing", `${harness.root}/dangling-link`)
|
|
||||||
expect((yield* harness.files.stat(`${harness.root}/link`)).type).toBe("symlink")
|
expect((yield* harness.files.stat(`${harness.root}/link`)).type).toBe("symlink")
|
||||||
expect(yield* harness.files.list(harness.root)).toContainEqual({ name: "link", type: "symlink" })
|
expect(yield* harness.files.list(harness.root)).toContainEqual({ name: "link", type: "symlink" })
|
||||||
expect(text((yield* harness.files.read(`${harness.root}/link-dir/file`)).bytes)).toBe("through link")
|
expect(text((yield* harness.files.read(`${harness.root}/link-dir/file`)).bytes)).toBe("through link")
|
||||||
expect(
|
const listError = yield* Effect.flip(harness.files.list(`${harness.root}/link-dir`))
|
||||||
(yield* harness.files.list(`${harness.root}/link-dir`)).toSorted((a, b) => a.name.localeCompare(b.name)),
|
expect(listError).toBeInstanceOf(WrongKind)
|
||||||
).toEqual([
|
expect((listError as WrongKind).actual).toBe("symlink")
|
||||||
{ name: "entry-link", type: "symlink" },
|
|
||||||
{ name: "file", type: "file" },
|
|
||||||
])
|
|
||||||
|
|
||||||
const fileError = yield* Effect.flip(harness.files.list(`${harness.root}/link`))
|
|
||||||
expect(fileError).toBeInstanceOf(WrongKind)
|
|
||||||
expect((fileError as WrongKind).actual).toBe("file")
|
|
||||||
expect(yield* Effect.flip(harness.files.list(`${harness.root}/dangling-link`))).toBeInstanceOf(NotFound)
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { Session } from "@opencode-ai/core/session"
|
|||||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||||
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||||
import { UserInterruptedError } from "@opencode-ai/core/session/error"
|
import { UserInterruptedError } from "@opencode-ai/core/session/error"
|
||||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
|
||||||
import { SessionRunner } from "@opencode-ai/core/session/runner"
|
import { SessionRunner } from "@opencode-ai/core/session/runner"
|
||||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||||
@@ -128,34 +127,23 @@ describe("SessionExecution lifecycle", () => {
|
|||||||
it.effect("resumes each suspended Session at most once", () =>
|
it.effect("resumes each suspended Session at most once", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const database = yield* Database.Service
|
const database = yield* Database.Service
|
||||||
const bus = yield* Bus.Service
|
|
||||||
const first = Session.ID.make("ses_resume_first")
|
const first = Session.ID.make("ses_resume_first")
|
||||||
const second = Session.ID.make("ses_resume_second")
|
const second = Session.ID.make("ses_resume_second")
|
||||||
yield* seedSessions(database, [first, second], { time_suspended: Date.now() })
|
yield* seedSessions(database, [first, second], { time_suspended: Date.now() })
|
||||||
|
|
||||||
const drained: string[] = []
|
const drained: string[] = []
|
||||||
const continued: SessionEvent.Synthetic[] = []
|
|
||||||
const scope = yield* Scope.make()
|
const scope = yield* Scope.make()
|
||||||
const context = yield* buildExecution(scope, ({ sessionID }) => Effect.sync(() => void drained.push(sessionID)))
|
const context = yield* buildExecution(scope, ({ sessionID }) => Effect.sync(() => void drained.push(sessionID)))
|
||||||
const execution = Context.get(context, SessionExecution.Service)
|
const execution = Context.get(context, SessionExecution.Service)
|
||||||
const restart = Context.get(context, SessionRestart.Service)
|
const restart = Context.get(context, SessionRestart.Service)
|
||||||
yield* bus.project(SessionEvent.Synthetic, (event) => Effect.sync(() => void continued.push(event)))
|
|
||||||
|
|
||||||
yield* restart.resumeSuspendedSessions
|
yield* restart.resumeSuspendedSessions
|
||||||
yield* Effect.forEach([first, second], execution.awaitIdle, { discard: true })
|
yield* Effect.forEach([first, second], execution.awaitIdle, { discard: true })
|
||||||
expect(drained.toSorted()).toEqual([first, second])
|
expect(drained.toSorted()).toEqual([first, second])
|
||||||
expect(continued.map((event) => event.data).toSorted((a, b) => a.sessionID.localeCompare(b.sessionID))).toEqual(
|
|
||||||
[first, second].map((sessionID) => ({
|
|
||||||
sessionID,
|
|
||||||
text: "The server restarted while you were working. Continue from where you left off without repeating completed work.",
|
|
||||||
description: "Continuing after restart",
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
expect(yield* suspensions(database)).toEqual({ [first]: false, [second]: false })
|
expect(yield* suspensions(database)).toEqual({ [first]: false, [second]: false })
|
||||||
|
|
||||||
yield* restart.resumeSuspendedSessions
|
yield* restart.resumeSuspendedSessions
|
||||||
expect(drained.length).toBe(2)
|
expect(drained.length).toBe(2)
|
||||||
expect(continued.length).toBe(2)
|
|
||||||
yield* Scope.close(scope, Exit.void)
|
yield* Scope.close(scope, Exit.void)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,12 +3,10 @@ import { Agent } from "@opencode-ai/core/agent"
|
|||||||
import type { Permission } from "@opencode-ai/core/permission"
|
import type { Permission } from "@opencode-ai/core/permission"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { Image } from "@opencode-ai/core/image"
|
import { Image } from "@opencode-ai/core/image"
|
||||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
|
||||||
import { Session } from "@opencode-ai/core/session"
|
import { Session } from "@opencode-ai/core/session"
|
||||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||||
import { Tool } from "@opencode-ai/core/tool"
|
import { Tool } from "@opencode-ai/core/tool"
|
||||||
import type { Info } from "@opencode-ai/schema/tool"
|
import type { Info } from "@opencode-ai/schema/tool"
|
||||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
|
||||||
import { executeTool, toolDefinitions } from "./lib/tool"
|
import { executeTool, toolDefinitions } from "./lib/tool"
|
||||||
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
|
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
@@ -28,14 +26,10 @@ const imageStore = Layer.mock(Image.Service, {
|
|||||||
maxBytes: 5,
|
maxBytes: 5,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
return Effect.succeed({
|
return Effect.succeed({ ...content, content: "bm9ybWFsaXplZA==", mime: "image/jpeg" })
|
||||||
...content,
|
|
||||||
content: Buffer.from(`${Buffer.from(content.content, "base64").toString()} normalized`).toString("base64"),
|
|
||||||
mime: "image/jpeg",
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const registryLayer = AppNodeBuilder.build(LayerNode.group([Tool.node, PluginHooks.node]), [[Image.node, imageStore]])
|
const registryLayer = AppNodeBuilder.build(Tool.node, [[Image.node, imageStore]])
|
||||||
const it = testEffect(registryLayer)
|
const it = testEffect(registryLayer)
|
||||||
const identity = {
|
const identity = {
|
||||||
agent: Agent.ID.make("build"),
|
agent: Agent.ID.make("build"),
|
||||||
@@ -350,7 +344,7 @@ describe("Tool", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("normalizes image tool output once and drops unresizable images", () =>
|
it.effect("normalizes image tool output at execution and drops unresizable images", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const service = yield* Tool.Service
|
const service = yield* Tool.Service
|
||||||
yield* transform(service,
|
yield* transform(service,
|
||||||
@@ -382,12 +376,7 @@ describe("Tool", () => {
|
|||||||
|
|
||||||
const execution = yield* executeTool(service, call("snapshot"))
|
const execution = yield* executeTool(service, call("snapshot"))
|
||||||
expect(execution.content).toEqual([
|
expect(execution.content).toEqual([
|
||||||
{
|
{ type: "file", uri: "data:image/jpeg;base64,bm9ybWFsaXplZA==", mime: "image/jpeg", name: "frame.png" },
|
||||||
type: "file",
|
|
||||||
uri: "data:image/jpeg;base64,aW1hZ2Ugbm9ybWFsaXplZA==",
|
|
||||||
mime: "image/jpeg",
|
|
||||||
name: "frame.png",
|
|
||||||
},
|
|
||||||
{ type: "text", text: "snapshot" },
|
{ type: "text", text: "snapshot" },
|
||||||
{ type: "text", text: "[1 image omitted: could not be decoded.]" },
|
{ type: "text", text: "[1 image omitted: could not be decoded.]" },
|
||||||
{ type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" },
|
{ type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" },
|
||||||
@@ -395,34 +384,6 @@ describe("Tool", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("normalizes image content added by an after hook", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const service = yield* Tool.Service
|
|
||||||
const hooks = yield* PluginHooks.Service
|
|
||||||
yield* transform(service, { hooked: constant("original") }, { codemode: false })
|
|
||||||
yield* hooks.register("tool", "execute.after", (event) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
if (event.status !== "completed") return
|
|
||||||
event.result = {
|
|
||||||
...event.result,
|
|
||||||
content: [
|
|
||||||
{ type: "file", uri: "data:image/png;base64,aW1hZ2U=", mime: "image/png", name: "hook.png" },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect((yield* executeTool(service, call("hooked"))).content).toEqual([
|
|
||||||
{
|
|
||||||
type: "file",
|
|
||||||
uri: "data:image/jpeg;base64,aW1hZ2Ugbm9ybWFsaXplZA==",
|
|
||||||
mime: "image/jpeg",
|
|
||||||
name: "hook.png",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("publishes progress metadata unchanged", () =>
|
it.effect("publishes progress metadata unchanged", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const service = yield* Tool.Service
|
const service = yield* Tool.Service
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import { Agent } from "@opencode-ai/core/agent"
|
|||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
import { Bus } from "@opencode-ai/core/bus"
|
import { Bus } from "@opencode-ai/core/bus"
|
||||||
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { Skill } from "@opencode-ai/core/skill"
|
import { Skill } from "@opencode-ai/core/skill"
|
||||||
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
|
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
|
||||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
@@ -24,15 +25,8 @@ const discovery = Layer.succeed(
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const watcherLayer = Watcher.testLayer
|
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
Layer.mergeAll(
|
AppNodeBuilder.build(LayerNode.group([Skill.node, Agent.node, Bus.node]), [[SkillDiscovery.node, discovery]]),
|
||||||
AppNodeBuilder.build(LayerNode.group([Skill.node, Agent.node, Bus.node]), [
|
|
||||||
[SkillDiscovery.node, discovery],
|
|
||||||
[Watcher.node, watcherLayer],
|
|
||||||
]),
|
|
||||||
watcherLayer,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
function write(directory: string, name: string, description: string) {
|
function write(directory: string, name: string, description: string) {
|
||||||
@@ -59,24 +53,6 @@ function waitForSkillUpdate() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function expectSubscription(check: (input: Watcher.WatchInput) => boolean) {
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
const watcher = yield* Watcher.Test
|
|
||||||
expect((yield* watcher.subscriptions()).some(check)).toBe(true)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function emitAndWait(update: Watcher.Update) {
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
const watcher = yield* Watcher.Test
|
|
||||||
yield* Effect.acquireUseRelease(
|
|
||||||
waitForSkillUpdate(),
|
|
||||||
({ deferred }) => watcher.emit(update).pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
|
|
||||||
({ fiber }) => Fiber.interrupt(fiber),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Skill", () => {
|
describe("Skill", () => {
|
||||||
it.live("publishes updates when skill sources change", () =>
|
it.live("publishes updates when skill sources change", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -113,7 +89,6 @@ describe("Skill", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const skill = yield* Skill.Service
|
const skill = yield* Skill.Service
|
||||||
const watcher = yield* Watcher.Test
|
|
||||||
yield* skill.transform((editor) => {
|
yield* skill.transform((editor) => {
|
||||||
editor.source({ type: "directory", path: AbsolutePath.make(first) })
|
editor.source({ type: "directory", path: AbsolutePath.make(first) })
|
||||||
editor.source({ type: "directory", path: AbsolutePath.make(first) })
|
editor.source({ type: "directory", path: AbsolutePath.make(first) })
|
||||||
@@ -144,21 +119,6 @@ describe("Skill", () => {
|
|||||||
content: "# review",
|
content: "# review",
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
expect(yield* watcher.subscriptions()).toEqual([
|
|
||||||
{ path: first, type: "directory" },
|
|
||||||
{ path: second, type: "directory" },
|
|
||||||
])
|
|
||||||
|
|
||||||
yield* Effect.promise(() => write(second, "review", "Updated Second"))
|
|
||||||
yield* emitAndWait({ type: "update", path: path.join(second, "review", "SKILL.md") })
|
|
||||||
|
|
||||||
expect((yield* skill.list()).find((item) => item.id === "review")?.description).toBe("Updated Second")
|
|
||||||
expect(yield* watcher.subscriptions()).toEqual([
|
|
||||||
{ path: first, type: "directory" },
|
|
||||||
{ path: second, type: "directory" },
|
|
||||||
{ path: first, type: "directory" },
|
|
||||||
{ path: second, type: "directory" },
|
|
||||||
])
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -238,7 +198,7 @@ metadata:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("clears cached skills when sources reload", () =>
|
it.live("invalidates cached skills and publishes updates for watcher changes", () =>
|
||||||
Effect.acquireRelease(
|
Effect.acquireRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
@@ -250,187 +210,26 @@ metadata:
|
|||||||
await write(tmp.path, "deploy", "Initial deploy")
|
await write(tmp.path, "deploy", "Initial deploy")
|
||||||
})
|
})
|
||||||
|
|
||||||
const skill = yield* Skill.Service
|
|
||||||
const watcher = yield* Watcher.Test
|
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
|
|
||||||
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Initial deploy")
|
|
||||||
expect(yield* watcher.subscriptions()).toEqual([{ path: tmp.path, type: "directory" }])
|
|
||||||
|
|
||||||
let refreshed: Skill.Info[] = []
|
|
||||||
const unsubscribe = yield* bus.listen((event) => {
|
|
||||||
if (event.type !== Skill.Event.Updated.type) return Effect.void
|
|
||||||
return skill.list().pipe(
|
|
||||||
Effect.tap((items) => Effect.sync(() => (refreshed = items))),
|
|
||||||
Effect.asVoid,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy"))
|
|
||||||
yield* skill.reload().pipe(Effect.timeout("1 second"))
|
|
||||||
yield* unsubscribe
|
|
||||||
|
|
||||||
expect(refreshed.find((item) => item.id === "deploy")?.description).toBe("Updated deploy")
|
|
||||||
expect(yield* watcher.subscriptions()).toEqual([
|
|
||||||
{ path: tmp.path, type: "directory" },
|
|
||||||
{ path: tmp.path, type: "directory" },
|
|
||||||
])
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("reloads project sources created after their missing parent", () =>
|
|
||||||
Effect.acquireRelease(
|
|
||||||
Effect.promise(() => tmpdir()),
|
|
||||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
||||||
).pipe(
|
|
||||||
Effect.flatMap((tmp) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const source = path.join(tmp.path, "generated", "skills")
|
|
||||||
const file = path.join(source, "deploy", "SKILL.md")
|
|
||||||
const skill = yield* Skill.Service
|
|
||||||
const watcher = yield* Watcher.Test
|
|
||||||
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
|
|
||||||
expect(yield* skill.list()).toEqual([])
|
|
||||||
expect(yield* watcher.subscriptions()).toEqual([{ path: path.join(tmp.path, "generated"), type: "file" }])
|
|
||||||
|
|
||||||
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "generated")))
|
|
||||||
yield* emitAndWait({ type: "create", path: path.join(tmp.path, "generated") })
|
|
||||||
expect(yield* skill.list()).toEqual([])
|
|
||||||
expect(yield* watcher.subscriptions()).toEqual([
|
|
||||||
{ path: path.join(tmp.path, "generated"), type: "file" },
|
|
||||||
{ path: source, type: "file" },
|
|
||||||
])
|
|
||||||
|
|
||||||
yield* Effect.promise(async () => {
|
|
||||||
await fs.mkdir(path.dirname(file), { recursive: true })
|
|
||||||
await write(source, "deploy", "Deploy production")
|
|
||||||
})
|
|
||||||
yield* emitAndWait({ type: "create", path: source })
|
|
||||||
|
|
||||||
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
|
|
||||||
expect(yield* watcher.subscriptions()).toEqual([
|
|
||||||
{ path: path.join(tmp.path, "generated"), type: "file" },
|
|
||||||
{ path: source, type: "file" },
|
|
||||||
{ path: source, type: "directory" },
|
|
||||||
])
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("watches directory sources for added and changed skills", () =>
|
|
||||||
Effect.acquireRelease(
|
|
||||||
Effect.promise(() => tmpdir()),
|
|
||||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
||||||
).pipe(
|
|
||||||
Effect.flatMap((tmp) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* Effect.promise(async () => {
|
|
||||||
await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
|
|
||||||
await write(tmp.path, "deploy", "Initial deploy")
|
|
||||||
})
|
|
||||||
|
|
||||||
const skill = yield* Skill.Service
|
const skill = yield* Skill.Service
|
||||||
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
|
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
|
||||||
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
|
|
||||||
yield* expectSubscription((input) => input.type === "directory" && input.path === tmp.path)
|
|
||||||
|
|
||||||
const deploy = path.join(tmp.path, "deploy", "SKILL.md")
|
expect((yield* skill.list()).find((item) => item.name === "deploy")?.description).toBe("Initial deploy")
|
||||||
|
|
||||||
|
const file = path.join(tmp.path, "deploy", "SKILL.md")
|
||||||
yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy"))
|
yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy"))
|
||||||
yield* emitAndWait({ type: "update", path: deploy })
|
expect((yield* skill.list()).find((item) => item.name === "deploy")?.description).toBe("Initial deploy")
|
||||||
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Updated deploy")
|
|
||||||
|
|
||||||
yield* Effect.promise(async () => {
|
yield* Effect.acquireUseRelease(
|
||||||
await fs.mkdir(path.join(tmp.path, "review"), { recursive: true })
|
waitForSkillUpdate(),
|
||||||
await write(tmp.path, "review", "Review changes")
|
({ deferred }) =>
|
||||||
})
|
bus
|
||||||
const review = path.join(tmp.path, "review", "SKILL.md")
|
.publish(FileSystem.Event.Changed, { file, event: "change" })
|
||||||
yield* emitAndWait({ type: "create", path: review })
|
.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
|
||||||
expect((yield* skill.list()).map((item) => item.id)).toEqual([
|
({ fiber }) => Fiber.interrupt(fiber),
|
||||||
Skill.ID.make("deploy"),
|
)
|
||||||
Skill.ID.make("review"),
|
|
||||||
])
|
|
||||||
|
|
||||||
yield* Effect.promise(() => fs.rm(path.join(tmp.path, "review"), { recursive: true }))
|
expect((yield* skill.list()).find((item) => item.name === "deploy")?.description).toBe("Updated deploy")
|
||||||
yield* emitAndWait({ type: "delete", path: review })
|
|
||||||
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("watches canonical directories behind symlinked skills", () =>
|
|
||||||
Effect.acquireRelease(
|
|
||||||
Effect.promise(() => tmpdir()),
|
|
||||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
||||||
).pipe(
|
|
||||||
Effect.flatMap((tmp) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const source = path.join(tmp.path, "source")
|
|
||||||
const target = path.join(tmp.path, "target", "bro")
|
|
||||||
const file = path.join(target, "SKILL.md")
|
|
||||||
yield* Effect.promise(async () => {
|
|
||||||
await fs.mkdir(source, { recursive: true })
|
|
||||||
await fs.mkdir(target, { recursive: true })
|
|
||||||
await fs.writeFile(file, "---\nname: bro\ndescription: Initial\n---\n# bro")
|
|
||||||
await fs.symlink(target, path.join(source, "bro"))
|
|
||||||
})
|
|
||||||
|
|
||||||
const skill = yield* Skill.Service
|
|
||||||
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
|
|
||||||
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Initial")
|
|
||||||
yield* expectSubscription((input) => input.type === "directory" && input.path === target)
|
|
||||||
|
|
||||||
yield* Effect.promise(() => fs.writeFile(file, "---\nname: bro\ndescription: Updated\n---\n# bro"))
|
|
||||||
yield* emitAndWait({ type: "update", path: file })
|
|
||||||
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Updated")
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("invalidates symlinked sources when their target changes", () =>
|
|
||||||
Effect.acquireRelease(
|
|
||||||
Effect.promise(() => tmpdir()),
|
|
||||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
||||||
).pipe(
|
|
||||||
Effect.flatMap((tmp) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const source = path.join(tmp.path, "source")
|
|
||||||
const first = path.join(tmp.path, "first")
|
|
||||||
const second = path.join(tmp.path, "second")
|
|
||||||
yield* Effect.promise(async () => {
|
|
||||||
await fs.mkdir(path.join(first, "bro"), { recursive: true })
|
|
||||||
await fs.mkdir(path.join(second, "bro"), { recursive: true })
|
|
||||||
await write(first, "bro", "First")
|
|
||||||
await write(second, "bro", "Second")
|
|
||||||
await fs.symlink(first, source)
|
|
||||||
})
|
|
||||||
|
|
||||||
const skill = yield* Skill.Service
|
|
||||||
const watcher = yield* Watcher.Test
|
|
||||||
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
|
|
||||||
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("First")
|
|
||||||
expect(yield* watcher.subscriptions()).toEqual([
|
|
||||||
{ path: first, type: "directory" },
|
|
||||||
{ path: source, type: "file" },
|
|
||||||
])
|
|
||||||
|
|
||||||
yield* Effect.promise(async () => {
|
|
||||||
await fs.unlink(source)
|
|
||||||
await fs.symlink(second, source)
|
|
||||||
})
|
|
||||||
yield* emitAndWait({ type: "update", path: source })
|
|
||||||
|
|
||||||
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Second")
|
|
||||||
expect(yield* watcher.subscriptions()).toEqual([
|
|
||||||
{ path: first, type: "directory" },
|
|
||||||
{ path: source, type: "file" },
|
|
||||||
{ path: second, type: "directory" },
|
|
||||||
{ path: source, type: "file" },
|
|
||||||
])
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { describe, expect } from "bun:test"
|
|||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
import { Environment } from "@opencode-ai/core/environment"
|
|
||||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||||
import { Formatter } from "@opencode-ai/core/formatter"
|
import { Formatter } from "@opencode-ai/core/formatter"
|
||||||
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||||
import { Permission } from "@opencode-ai/core/permission"
|
import { Permission } from "@opencode-ai/core/permission"
|
||||||
@@ -23,15 +23,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
|
|||||||
const editToolNode = makeLocationNode({
|
const editToolNode = makeLocationNode({
|
||||||
name: "test/edit-tool-plugin",
|
name: "test/edit-tool-plugin",
|
||||||
layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
|
layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
|
||||||
deps: [
|
deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node],
|
||||||
Tool.node,
|
|
||||||
LocationMutation.node,
|
|
||||||
FileMutation.node,
|
|
||||||
Environment.node,
|
|
||||||
Formatter.node,
|
|
||||||
Location.node,
|
|
||||||
Permission.node,
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const sessionID = Session.ID.make("ses_edit_tool_test")
|
const sessionID = Session.ID.make("ses_edit_tool_test")
|
||||||
@@ -80,28 +72,29 @@ const reset = () => {
|
|||||||
formatFile = () => Effect.succeed(false)
|
formatFile = () => Effect.succeed(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const environment = Layer.effect(
|
const filesystem = Layer.effect(
|
||||||
Environment.Service,
|
FSUtil.Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const current = yield* Environment.Service
|
const fs = yield* FSUtil.Service
|
||||||
return Environment.Service.of({
|
return FSUtil.Service.of({
|
||||||
...current,
|
...fs,
|
||||||
files: {
|
readFile: (target) =>
|
||||||
...current.files,
|
fs
|
||||||
read: (target, range) =>
|
.readFile(target)
|
||||||
current.files
|
.pipe(
|
||||||
.read(target, range)
|
Effect.tap((content) =>
|
||||||
.pipe(
|
Effect.sync(() => reads++).pipe(Effect.andThen(Effect.suspend(() => afterRead(target, content)))),
|
||||||
Effect.tap((result) =>
|
|
||||||
Effect.sync(() => reads++).pipe(Effect.andThen(Effect.suspend(() => afterRead(target, result.bytes)))),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
write: (target, content) =>
|
),
|
||||||
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(current.files.write(target, content))),
|
writeWithDirs: (target, content, mode) =>
|
||||||
},
|
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeWithDirs(target, content, mode))),
|
||||||
|
writeFile: (target, content, options) =>
|
||||||
|
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeFile(target, content, options))),
|
||||||
|
writeFileString: (target, content, options) =>
|
||||||
|
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeFileString(target, content, options))),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
).pipe(Layer.provide(LayerNode.compile(Environment.node)))
|
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||||
|
|
||||||
const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) => {
|
const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) => {
|
||||||
const activeLocation = Layer.succeed(
|
const activeLocation = Layer.succeed(
|
||||||
@@ -113,9 +106,15 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
|
|||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
AppNodeBuilder.build(
|
AppNodeBuilder.build(
|
||||||
LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, editToolNode]),
|
LayerNode.group([
|
||||||
|
Tool.node,
|
||||||
|
Tool.node,
|
||||||
|
LocationMutation.node,
|
||||||
|
FileMutation.node,
|
||||||
|
editToolNode,
|
||||||
|
]),
|
||||||
[
|
[
|
||||||
[Environment.node, environment],
|
[FSUtil.node, filesystem],
|
||||||
[Location.node, activeLocation],
|
[Location.node, activeLocation],
|
||||||
[Formatter.node, formatter],
|
[Formatter.node, formatter],
|
||||||
[Permission.node, permission],
|
[Permission.node, permission],
|
||||||
@@ -472,7 +471,10 @@ describe("EditTool", () => {
|
|||||||
withTool(tmp.path, (registry) =>
|
withTool(tmp.path, (registry) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
expect(
|
expect(
|
||||||
yield* executeTool(registry, call({ path: "missing.ts", oldString: "before", newString: "after" })),
|
yield* executeTool(
|
||||||
|
registry,
|
||||||
|
call({ path: "missing.ts", oldString: "before", newString: "after" }),
|
||||||
|
),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
status: "error",
|
status: "error",
|
||||||
error: { type: "tool.execution", message: "File not found: missing.ts" },
|
error: { type: "tool.execution", message: "File not found: missing.ts" },
|
||||||
@@ -643,43 +645,6 @@ describe("EditTool", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("serializes concurrent edit transactions", () =>
|
|
||||||
Effect.acquireUseRelease(
|
|
||||||
Effect.promise(() => tmpdir()),
|
|
||||||
(tmp) => {
|
|
||||||
reset()
|
|
||||||
const target = path.join(tmp.path, "concurrent.txt")
|
|
||||||
afterRead = () => (reads === 1 ? Effect.sleep("50 millis") : Effect.void)
|
|
||||||
return Effect.promise(() => fs.writeFile(target, "one\ntwo\n")).pipe(
|
|
||||||
Effect.andThen(
|
|
||||||
withTool(tmp.path, (registry) =>
|
|
||||||
Effect.all(
|
|
||||||
[
|
|
||||||
executeTool(
|
|
||||||
registry,
|
|
||||||
call({ path: "concurrent.txt", oldString: "one", newString: "ONE" }, "call-edit-one"),
|
|
||||||
),
|
|
||||||
executeTool(
|
|
||||||
registry,
|
|
||||||
call({ path: "concurrent.txt", oldString: "two", newString: "TWO" }, "call-edit-two"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
{ concurrency: "unbounded" },
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Effect.andThen((results) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
expect(results.map((result) => result.status)).toEqual(["completed", "completed"])
|
|
||||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("ONE\nTWO\n")
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("applies the edit when content changes after matching", () =>
|
it.live("applies the edit when content changes after matching", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ import fs from "fs/promises"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Exit, Layer, Schema } from "effect"
|
import { Effect, Exit, Layer, Schema } from "effect"
|
||||||
|
import { systemError } from "effect/PlatformError"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
import { Environment } from "@opencode-ai/core/environment"
|
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { Formatter } from "@opencode-ai/core/formatter"
|
import { Formatter } from "@opencode-ai/core/formatter"
|
||||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { Permission } from "@opencode-ai/core/permission"
|
import { Permission } from "@opencode-ai/core/permission"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
@@ -23,7 +22,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
|
|||||||
const patchToolNode = makeLocationNode({
|
const patchToolNode = makeLocationNode({
|
||||||
name: "test/patch-tool-plugin",
|
name: "test/patch-tool-plugin",
|
||||||
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
|
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
|
||||||
deps: [Tool.node, FileMutation.node, Environment.node, Formatter.node, Location.node, Permission.node],
|
deps: [Tool.node, Formatter.node, FSUtil.node, Location.node, Permission.node],
|
||||||
})
|
})
|
||||||
|
|
||||||
const sessionID = Session.ID.make("ses_patch_tool_test")
|
const sessionID = Session.ID.make("ses_patch_tool_test")
|
||||||
@@ -82,33 +81,48 @@ const reset = () => {
|
|||||||
formatFile = () => Effect.succeed(false)
|
formatFile = () => Effect.succeed(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const environment = Layer.effect(
|
const filesystem = Layer.effect(
|
||||||
Environment.Service,
|
FSUtil.Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const current = yield* Environment.Service
|
const fs = yield* FSUtil.Service
|
||||||
return Environment.Service.of({
|
return FSUtil.Service.of({
|
||||||
...current,
|
...fs,
|
||||||
files: {
|
readFile: (target) =>
|
||||||
...current.files,
|
Effect.sync(() => {
|
||||||
read: (target, range) =>
|
if (!editApproved) readsBeforeEditApproval++
|
||||||
Effect.sync(() => {
|
}).pipe(Effect.andThen(fs.readFile(target))),
|
||||||
if (!editApproved) readsBeforeEditApproval++
|
remove: (target, options) => {
|
||||||
}).pipe(Effect.andThen(current.files.read(target, range))),
|
if (failRemoveTarget && path.basename(target) === failRemoveTarget) return Effect.die("forced remove failure")
|
||||||
remove: (target) => {
|
if (failRemoveErrorTarget && path.basename(target) === failRemoveErrorTarget) {
|
||||||
if (failRemoveTarget && path.basename(target) === failRemoveTarget) return Effect.die("forced remove failure")
|
return Effect.fail(
|
||||||
if (failRemoveErrorTarget && path.basename(target) === failRemoveErrorTarget)
|
systemError({
|
||||||
return Effect.fail(new Environment.Failed({ path: target, cause: new Error("forced remove failure") }))
|
_tag: "Unknown",
|
||||||
return current.files.remove(target)
|
module: "FileSystem",
|
||||||
},
|
method: "remove",
|
||||||
write: (target, content) => {
|
description: "forced remove failure",
|
||||||
if (failWriteTarget && path.basename(target) === failWriteTarget)
|
pathOrDescriptor: target,
|
||||||
return Effect.fail(new Environment.Failed({ path: target, cause: new Error("forced write failure") }))
|
}),
|
||||||
return current.files.write(target, content)
|
)
|
||||||
},
|
}
|
||||||
|
return fs.remove(target, options)
|
||||||
|
},
|
||||||
|
writeWithDirs: (target, content, mode) => {
|
||||||
|
if (failWriteTarget && path.basename(target) === failWriteTarget) {
|
||||||
|
return Effect.fail(
|
||||||
|
systemError({
|
||||||
|
_tag: "Unknown",
|
||||||
|
module: "FileSystem",
|
||||||
|
method: "writeWithDirs",
|
||||||
|
description: "forced write failure",
|
||||||
|
pathOrDescriptor: target,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return fs.writeWithDirs(target, content, mode)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
).pipe(Layer.provide(LayerNode.compile(Environment.node)))
|
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||||
|
|
||||||
const withTool = <A, E, R>(
|
const withTool = <A, E, R>(
|
||||||
directory: string,
|
directory: string,
|
||||||
@@ -125,8 +139,8 @@ const withTool = <A, E, R>(
|
|||||||
return yield* body(yield* Tool.Service)
|
return yield* body(yield* Tool.Service)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
AppNodeBuilder.build(LayerNode.group([Tool.node, FileMutation.node, patchToolNode]), [
|
AppNodeBuilder.build(LayerNode.group([Tool.node, patchToolNode]), [
|
||||||
[Environment.node, environment],
|
[FSUtil.node, filesystem],
|
||||||
[Location.node, activeLocation],
|
[Location.node, activeLocation],
|
||||||
[Formatter.node, formatter],
|
[Formatter.node, formatter],
|
||||||
[Permission.node, permission],
|
[Permission.node, permission],
|
||||||
@@ -248,43 +262,6 @@ describe("PatchTool", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("serializes concurrent patch transactions", () =>
|
|
||||||
withTempTool((directory, registry) => {
|
|
||||||
const target = path.join(directory, "concurrent.txt")
|
|
||||||
afterEditApproval = () =>
|
|
||||||
assertions.filter((input) => input.action === "edit").length === 1 ? Effect.sleep("50 millis") : Effect.void
|
|
||||||
return Effect.promise(() => fs.writeFile(target, "one\ntwo\n")).pipe(
|
|
||||||
Effect.andThen(
|
|
||||||
Effect.all(
|
|
||||||
[
|
|
||||||
executeTool(
|
|
||||||
registry,
|
|
||||||
call(
|
|
||||||
"*** Begin Patch\n*** Update File: concurrent.txt\n@@\n-one\n+ONE\n*** End Patch",
|
|
||||||
"call-patch-one",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
executeTool(
|
|
||||||
registry,
|
|
||||||
call(
|
|
||||||
"*** Begin Patch\n*** Update File: concurrent.txt\n@@\n-two\n+TWO\n*** End Patch",
|
|
||||||
"call-patch-two",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
{ concurrency: "unbounded" },
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Effect.andThen((results) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
expect(results.map((result) => result.status)).toEqual(["completed", "completed"])
|
|
||||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("ONE\nTWO\n")
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("returns file diffs for final formatted content", () =>
|
it.live("returns file diffs for final formatted content", () =>
|
||||||
withTempTool((directory, registry) => {
|
withTempTool((directory, registry) => {
|
||||||
const target = path.join(directory, "formatted.txt")
|
const target = path.join(directory, "formatted.txt")
|
||||||
|
|||||||
@@ -1,65 +1,66 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Environment } from "@opencode-ai/core/environment"
|
import { Effect, FileSystem } from "effect"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
|
||||||
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
|
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
|
|
||||||
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
||||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
import { Effect, FileSystem } from "effect"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { ChildProcessSpawner } from "effect/unstable/process"
|
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
const it = testEffect(LayerNode.compile(LayerNode.group([CrossSpawnSpawner.node, LayerNodePlatform.filesystem])))
|
const it = testEffect(LayerNode.compile(LayerNode.group([FSUtil.node, LayerNodePlatform.filesystem])))
|
||||||
const fixture = Effect.gen(function* () {
|
const fixture = Effect.gen(function* () {
|
||||||
|
const fs = yield* FSUtil.Service
|
||||||
const files = yield* FileSystem.FileSystem
|
const files = yield* FileSystem.FileSystem
|
||||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
|
||||||
const directory = yield* files.makeTempDirectoryScoped()
|
const directory = yield* files.makeTempDirectoryScoped()
|
||||||
return { environment: Environment.makeFiles(Environment.makeLocalDriver(spawner)), files, directory }
|
return { fs, files, directory }
|
||||||
})
|
})
|
||||||
const absolute = (value: string) => AbsolutePath.make(value)
|
|
||||||
|
|
||||||
describe("ReadToolFileSystem", () => {
|
describe("ReadToolFileSystem", () => {
|
||||||
it.effect("preserves the environment not-found error", () =>
|
it.effect("fails with a typed filesystem error when a resolved file disappears", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { environment, directory } = yield* fixture
|
const { fs, directory } = yield* fixture
|
||||||
const file = path.join(directory, "missing.txt")
|
const file = path.join(directory, "missing.txt")
|
||||||
|
|
||||||
const error = yield* ReadToolFileSystem.read(environment, absolute(file), "missing.txt").pipe(Effect.flip)
|
const error = yield* ReadToolFileSystem.read(fs, file, "missing.txt").pipe(Effect.flip)
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(Environment.NotFound)
|
expect(error).toMatchObject({ _tag: "PlatformError" })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("returns a listing when read reports a directory", () =>
|
it.effect("fails when a file becomes the wrong path kind", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { environment, files, directory } = yield* fixture
|
const { fs, directory } = yield* fixture
|
||||||
yield* files.makeDirectory(path.join(directory, "folder"))
|
|
||||||
yield* files.writeFileString(path.join(directory, "file.txt"), "hello")
|
|
||||||
|
|
||||||
const result = yield* ReadToolFileSystem.read(environment, absolute(directory), "folder")
|
const error = yield* ReadToolFileSystem.read(fs, directory, "folder").pipe(Effect.flip)
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(error).toBeInstanceOf(ReadToolFileSystem.PathKindError)
|
||||||
type: "list-page",
|
}),
|
||||||
entries: [
|
)
|
||||||
{ path: `folder${path.sep}`, type: "directory" },
|
|
||||||
{ path: "file.txt", type: "file" },
|
it.effect("fails with a typed filesystem error when directory listing fails", () =>
|
||||||
],
|
Effect.gen(function* () {
|
||||||
})
|
const { fs, files, directory } = yield* fixture
|
||||||
|
const file = path.join(directory, "file.txt")
|
||||||
|
yield* files.writeFileString(file, "hello")
|
||||||
|
|
||||||
|
const error = yield* ReadToolFileSystem.list(fs, file).pipe(Effect.flip)
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(FSUtil.FileSystemError)
|
||||||
|
if (error instanceof FSUtil.FileSystemError) expect(error.method).toBe("readDirectoryEntries")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("reads malformed UTF-8 lossily and still rejects null-byte binary content", () =>
|
it.effect("reads malformed UTF-8 lossily and still rejects null-byte binary content", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { environment, files, directory } = yield* fixture
|
const { fs, files, directory } = yield* fixture
|
||||||
const binary = path.join(directory, "archive.dat")
|
const binary = path.join(directory, "archive.dat")
|
||||||
const malformed = path.join(directory, "malformed.txt")
|
const malformed = path.join(directory, "malformed.txt")
|
||||||
yield* files.writeFile(binary, Uint8Array.of(0, 1, 2, 3))
|
yield* files.writeFile(binary, Uint8Array.of(0, 1, 2, 3))
|
||||||
yield* files.writeFile(malformed, Uint8Array.of(0x68, 0x69, 0x80))
|
yield* files.writeFile(malformed, Uint8Array.of(0x68, 0x69, 0x80))
|
||||||
|
|
||||||
const binaryError = yield* ReadToolFileSystem.read(environment, absolute(binary), "archive.dat").pipe(Effect.flip)
|
const binaryError = yield* ReadToolFileSystem.read(fs, binary, "archive.dat").pipe(Effect.flip)
|
||||||
const malformedResult = yield* ReadToolFileSystem.read(environment, absolute(malformed), "malformed.txt")
|
const malformedResult = yield* ReadToolFileSystem.read(fs, malformed, "malformed.txt")
|
||||||
|
|
||||||
expect(binaryError).toBeInstanceOf(ReadToolFileSystem.BinaryFileError)
|
expect(binaryError).toBeInstanceOf(ReadToolFileSystem.BinaryFileError)
|
||||||
expect(binaryError.message).toBe("Cannot read binary file: archive.dat")
|
expect(binaryError.message).toBe("Cannot read binary file: archive.dat")
|
||||||
@@ -69,11 +70,11 @@ describe("ReadToolFileSystem", () => {
|
|||||||
|
|
||||||
it.effect("reads text despite a binary-associated extension", () =>
|
it.effect("reads text despite a binary-associated extension", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { environment, files, directory } = yield* fixture
|
const { fs, files, directory } = yield* fixture
|
||||||
const file = path.join(directory, "notes.docx")
|
const file = path.join(directory, "notes.docx")
|
||||||
yield* files.writeFileString(file, "plain text")
|
yield* files.writeFileString(file, "plain text")
|
||||||
|
|
||||||
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "notes.docx")
|
const result = yield* ReadToolFileSystem.read(fs, file, "notes.docx")
|
||||||
|
|
||||||
expect(result).toMatchObject({ type: "file", content: "plain text", encoding: "utf8" })
|
expect(result).toMatchObject({ type: "file", content: "plain text", encoding: "utf8" })
|
||||||
}),
|
}),
|
||||||
@@ -82,17 +83,15 @@ describe("ReadToolFileSystem", () => {
|
|||||||
it.effect("lists unresolved symlinks, including broken and escaping links", () =>
|
it.effect("lists unresolved symlinks, including broken and escaping links", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (process.platform === "win32") return
|
if (process.platform === "win32") return
|
||||||
const { environment, files, directory } = yield* fixture
|
const { fs: service, files, directory } = yield* fixture
|
||||||
const outside = yield* files.makeTempDirectoryScoped()
|
const outside = yield* files.makeTempDirectoryScoped()
|
||||||
yield* files.makeDirectory(path.join(directory, "folder"))
|
yield* files.makeDirectory(path.join(directory, "folder"))
|
||||||
yield* files.writeFileString(path.join(directory, "file.txt"), "hello")
|
yield* files.writeFileString(path.join(directory, "file.txt"), "hello")
|
||||||
yield* Effect.promise(() => fs.symlink(path.join(outside, "target.txt"), path.join(directory, "escape")))
|
yield* Effect.promise(() => fs.symlink(path.join(outside, "target.txt"), path.join(directory, "escape")))
|
||||||
yield* Effect.promise(() => fs.symlink(path.join(directory, "missing.txt"), path.join(directory, "broken")))
|
yield* Effect.promise(() => fs.symlink(path.join(directory, "missing.txt"), path.join(directory, "broken")))
|
||||||
|
|
||||||
const result = yield* ReadToolFileSystem.read(environment, absolute(directory), "folder")
|
const result = yield* ReadToolFileSystem.list(service, directory)
|
||||||
|
|
||||||
expect(result.type).toBe("list-page")
|
|
||||||
if (result.type !== "list-page") return
|
|
||||||
expect(result.entries.map((entry) => ({ ...entry, path: String(entry.path) }))).toEqual([
|
expect(result.entries.map((entry) => ({ ...entry, path: String(entry.path) }))).toEqual([
|
||||||
{ path: `folder${path.sep}`, type: "directory" },
|
{ path: `folder${path.sep}`, type: "directory" },
|
||||||
{ path: "broken", type: "symlink" },
|
{ path: "broken", type: "symlink" },
|
||||||
@@ -102,154 +101,45 @@ describe("ReadToolFileSystem", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("reads a symlinked directory as a listing", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
if (process.platform === "win32") return
|
|
||||||
const { environment, files, directory } = yield* fixture
|
|
||||||
const target = path.join(directory, "target")
|
|
||||||
const link = path.join(directory, "link")
|
|
||||||
yield* files.makeDirectory(target)
|
|
||||||
yield* files.writeFileString(path.join(target, "file.txt"), "hello")
|
|
||||||
yield* Effect.promise(() => fs.symlink(target, link))
|
|
||||||
|
|
||||||
const result = yield* ReadToolFileSystem.read(environment, absolute(link), "link")
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
type: "list-page",
|
|
||||||
entries: [{ path: "file.txt", type: "file" }],
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("reports out-of-range pagination as a typed error", () =>
|
it.effect("reports out-of-range pagination as a typed error", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { environment, files, directory } = yield* fixture
|
const { fs, files, directory } = yield* fixture
|
||||||
const file = path.join(directory, "short.txt")
|
const file = path.join(directory, "short.txt")
|
||||||
yield* files.writeFileString(file, "one\n")
|
yield* files.writeFileString(file, "one\n")
|
||||||
|
|
||||||
const error = yield* ReadToolFileSystem.read(environment, absolute(file), "short.txt", { offset: 2 }).pipe(
|
const error = yield* ReadToolFileSystem.read(fs, file, "short.txt", { offset: 2 }).pipe(Effect.flip)
|
||||||
Effect.flip,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(ReadToolFileSystem.OffsetOutOfRangeError)
|
expect(error).toBeInstanceOf(ReadToolFileSystem.OffsetOutOfRangeError)
|
||||||
expect(error.message).toBe("Offset 2 is out of range")
|
expect(error.message).toBe("Offset 2 is out of range")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("pages text with one-based offsets", () =>
|
it.effect("stops reading after the requested page is complete", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { environment, files, directory } = yield* fixture
|
const { fs, files, directory } = yield* fixture
|
||||||
const file = path.join(directory, "lines.txt")
|
const prefix = new TextEncoder().encode("one\n")
|
||||||
yield* files.writeFileString(file, "one\r\ntwo\nthree")
|
for (const [name, trailing] of [
|
||||||
|
["malformed.txt", 0x80],
|
||||||
|
["nul.txt", 0],
|
||||||
|
] as const) {
|
||||||
|
const file = path.join(directory, name)
|
||||||
|
yield* files.writeFile(file, Uint8Array.from([...prefix, trailing]))
|
||||||
|
|
||||||
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "lines.txt", {
|
const result = yield* ReadToolFileSystem.read(fs, file, name, { limit: 1 })
|
||||||
offset: 2,
|
|
||||||
limit: 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ type: "text-page", content: "two", offset: 2, truncated: true, next: 3 })
|
expect(result).toMatchObject({ type: "text-page", content: "one", truncated: true, next: 2 })
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("truncates long lines", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const { environment, files, directory } = yield* fixture
|
|
||||||
const file = path.join(directory, "long.txt")
|
|
||||||
yield* files.writeFileString(file, "a".repeat(2_001))
|
|
||||||
|
|
||||||
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "long.txt", { limit: 1 })
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
type: "text-page",
|
|
||||||
content: `${"a".repeat(2_000)}... (line truncated to 2000 chars)`,
|
|
||||||
truncated: false,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("enforces line and byte budgets with continuation offsets", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const { environment, files, directory } = yield* fixture
|
|
||||||
const linesFile = path.join(directory, "many-lines.txt")
|
|
||||||
const bytesFile = path.join(directory, "many-bytes.txt")
|
|
||||||
yield* files.writeFileString(linesFile, Array.from({ length: 2_001 }, (_, index) => String(index)).join("\n"))
|
|
||||||
yield* files.writeFileString(bytesFile, Array.from({ length: 200 }, () => "a".repeat(2_000)).join("\n"))
|
|
||||||
const ranges: Array<{ readonly offset: number; readonly length: number } | undefined> = []
|
|
||||||
const tracked = {
|
|
||||||
...environment,
|
|
||||||
read: (path: string, range?: { readonly offset: number; readonly length: number }) =>
|
|
||||||
Effect.sync(() => ranges.push(range)).pipe(Effect.andThen(environment.read(path, range))),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = yield* ReadToolFileSystem.read(environment, absolute(linesFile), "many-lines.txt", { limit: 2_000 })
|
|
||||||
const bytes = yield* ReadToolFileSystem.read(tracked, absolute(bytesFile), "many-bytes.txt", {})
|
|
||||||
|
|
||||||
expect(lines).toMatchObject({ type: "text-page", truncated: true, next: 2_001 })
|
|
||||||
expect(lines.type === "text-page" ? lines.content.split("\n") : []).toHaveLength(2_000)
|
|
||||||
expect(bytes).toMatchObject({ type: "text-page", truncated: true, next: 26 })
|
|
||||||
expect(bytes.type === "text-page" ? Buffer.byteLength(bytes.content) : Infinity).toBeLessThanOrEqual(
|
|
||||||
ReadToolFileSystem.MAX_READ_BYTES,
|
|
||||||
)
|
|
||||||
expect(ranges).toEqual([{ offset: 0, length: 256 * 1024 }])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("sorts and pages directory entries", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const { environment, files, directory } = yield* fixture
|
|
||||||
yield* files.makeDirectory(path.join(directory, "z"))
|
|
||||||
yield* files.makeDirectory(path.join(directory, "a"))
|
|
||||||
yield* files.writeFileString(path.join(directory, "b.txt"), "")
|
|
||||||
|
|
||||||
const result = yield* ReadToolFileSystem.read(environment, absolute(directory), "folder", {
|
|
||||||
offset: 2,
|
|
||||||
limit: 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
type: "list-page",
|
|
||||||
entries: [{ path: `z${path.sep}`, type: "directory" }],
|
|
||||||
truncated: true,
|
|
||||||
next: 3,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("stops checking for null bytes after the requested page", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const { environment, files, directory } = yield* fixture
|
|
||||||
const file = path.join(directory, "nul.txt")
|
|
||||||
yield* files.writeFile(file, Uint8Array.from([...new TextEncoder().encode("one\n"), 0]))
|
|
||||||
|
|
||||||
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "nul.txt", { limit: 1 })
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ type: "text-page", content: "one", truncated: true, next: 2 })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("reads page two after fetching more than the first 256KB range", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const { environment, files, directory } = yield* fixture
|
|
||||||
const file = path.join(directory, "large.txt")
|
|
||||||
yield* files.writeFileString(file, `${"a".repeat(300 * 1024)}\nsecond\n`)
|
|
||||||
|
|
||||||
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "large.txt", {
|
|
||||||
offset: 2,
|
|
||||||
limit: 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ type: "text-page", content: "second", offset: 2, truncated: false })
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("preserves the media ingestion limit message", () =>
|
it.effect("preserves the media ingestion limit message", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { environment, files, directory } = yield* fixture
|
const { fs, files, directory } = yield* fixture
|
||||||
const file = path.join(directory, "oversized.png")
|
const file = path.join(directory, "oversized.png")
|
||||||
yield* files.writeFile(file, Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a))
|
yield* files.writeFile(file, Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a))
|
||||||
yield* files.truncate(file, ReadToolFileSystem.MAX_MEDIA_INGEST_BYTES + 1)
|
yield* files.truncate(file, ReadToolFileSystem.MAX_MEDIA_INGEST_BYTES + 1)
|
||||||
|
|
||||||
const error = yield* ReadToolFileSystem.read(environment, absolute(file), "oversized.png").pipe(Effect.flip)
|
const error = yield* ReadToolFileSystem.read(fs, file, "oversized.png").pipe(Effect.flip)
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(ReadToolFileSystem.MediaIngestLimitError)
|
expect(error).toBeInstanceOf(ReadToolFileSystem.MediaIngestLimitError)
|
||||||
expect(error.message).toBe(
|
expect(error.message).toBe(
|
||||||
@@ -260,11 +150,11 @@ describe("ReadToolFileSystem", () => {
|
|||||||
|
|
||||||
it.effect("reads PDFs as bounded media", () =>
|
it.effect("reads PDFs as bounded media", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { environment, files, directory } = yield* fixture
|
const { fs, files, directory } = yield* fixture
|
||||||
const file = path.join(directory, "document.pdf")
|
const file = path.join(directory, "document.pdf")
|
||||||
yield* files.writeFileString(file, "%PDF-1.7\ncontent")
|
yield* files.writeFileString(file, "%PDF-1.7\ncontent")
|
||||||
|
|
||||||
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "document.pdf")
|
const result = yield* ReadToolFileSystem.read(fs, file, "document.pdf")
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
type: "file",
|
type: "file",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { beforeEach, describe, expect } from "bun:test"
|
import { beforeEach, describe, expect } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Effect, Exit, Layer, Stream } from "effect"
|
import { Effect, Exit, Layer, PlatformError, Stream } from "effect"
|
||||||
import { Config } from "@opencode-ai/core/config"
|
import { Config } from "@opencode-ai/core/config"
|
||||||
import { Document, Info } from "@opencode-ai/schema/config"
|
import { Document, Info } from "@opencode-ai/schema/config"
|
||||||
import { ConfigMedia } from "@opencode-ai/schema/config/media"
|
import { ConfigMedia } from "@opencode-ai/schema/config/media"
|
||||||
@@ -21,7 +21,6 @@ import { ReadTool } from "@opencode-ai/core/tool/plugin/read"
|
|||||||
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
|
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { SessionInstructions } from "@opencode-ai/core/session/instructions"
|
import { SessionInstructions } from "@opencode-ai/core/session/instructions"
|
||||||
import { Environment } from "@opencode-ai/core/environment"
|
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||||
|
|
||||||
@@ -43,13 +42,24 @@ const readToolNode = makeLocationNode({
|
|||||||
const assertions: Permission.AssertInput[] = []
|
const assertions: Permission.AssertInput[] = []
|
||||||
const missingPath = "__missing_read_target__.txt"
|
const missingPath = "__missing_read_target__.txt"
|
||||||
const missingAbsolutePath = path.join(process.cwd(), missingPath)
|
const missingAbsolutePath = path.join(process.cwd(), missingPath)
|
||||||
|
const notFound = (target: string) =>
|
||||||
|
PlatformError.systemError({
|
||||||
|
_tag: "NotFound",
|
||||||
|
module: "FileSystem",
|
||||||
|
method: "stat",
|
||||||
|
pathOrDescriptor: target,
|
||||||
|
})
|
||||||
const readCalls: {
|
const readCalls: {
|
||||||
input: AbsolutePath
|
input: AbsolutePath
|
||||||
page: ReadToolFileSystem.PageInput
|
page: ReadToolFileSystem.PageInput
|
||||||
}[] = []
|
}[] = []
|
||||||
|
const listCalls: ReadToolFileSystem.PageInput[] = []
|
||||||
|
let listResult = new ReadToolFileSystem.ListPage({ type: "list-page", entries: [], truncated: false })
|
||||||
|
let resolvedType: "file" | "directory" = "file"
|
||||||
let resolveFailure: unknown
|
let resolveFailure: unknown
|
||||||
|
let inspectFailure: ReadToolFileSystem.InspectError | undefined
|
||||||
let directoryEntries: string[] = []
|
let directoryEntries: string[] = []
|
||||||
let readResult: ReadToolFileSystem.FileContent | ReadToolFileSystem.TextPage | ReadToolFileSystem.ListPage = {
|
let readResult: ReadToolFileSystem.FileContent | ReadToolFileSystem.TextPage = {
|
||||||
type: "file",
|
type: "file",
|
||||||
uri: "file:///README.md",
|
uri: "file:///README.md",
|
||||||
name: "README.md",
|
name: "README.md",
|
||||||
@@ -61,12 +71,22 @@ let readFailure: ReadToolFileSystem.ReadError | undefined
|
|||||||
const reader = Layer.succeed(
|
const reader = Layer.succeed(
|
||||||
ReadToolFileSystem.Service,
|
ReadToolFileSystem.Service,
|
||||||
ReadToolFileSystem.Service.of({
|
ReadToolFileSystem.Service.of({
|
||||||
|
inspect: () =>
|
||||||
|
resolveFailure !== undefined
|
||||||
|
? Effect.die(resolveFailure)
|
||||||
|
: inspectFailure !== undefined
|
||||||
|
? Effect.fail(inspectFailure)
|
||||||
|
: Effect.succeed(resolvedType),
|
||||||
read: (input, _resource, page = {}) => {
|
read: (input, _resource, page = {}) => {
|
||||||
readCalls.push({ input, page })
|
readCalls.push({ input, page })
|
||||||
if (resolveFailure !== undefined) return Effect.die(resolveFailure)
|
|
||||||
if (readFailure !== undefined) return Effect.fail(readFailure)
|
if (readFailure !== undefined) return Effect.fail(readFailure)
|
||||||
return Effect.succeed(readResult)
|
return Effect.succeed(readResult)
|
||||||
},
|
},
|
||||||
|
list: (_path, input = {}) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
listCalls.push(input)
|
||||||
|
return listResult
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
let allow = true
|
let allow = true
|
||||||
@@ -105,6 +125,17 @@ const testFileSystem = Layer.effect(
|
|||||||
FSUtil.Service.of({
|
FSUtil.Service.of({
|
||||||
...fs,
|
...fs,
|
||||||
readDirectory: () => Effect.succeed(directoryEntries),
|
readDirectory: () => Effect.succeed(directoryEntries),
|
||||||
|
realPath: (path) =>
|
||||||
|
path === missingAbsolutePath
|
||||||
|
? Effect.fail(
|
||||||
|
PlatformError.systemError({
|
||||||
|
_tag: "NotFound",
|
||||||
|
module: "FileSystem",
|
||||||
|
method: "realPath",
|
||||||
|
pathOrDescriptor: path,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: Effect.succeed(path),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -164,8 +195,11 @@ describe("ReadTool", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
assertions.length = 0
|
assertions.length = 0
|
||||||
readCalls.length = 0
|
readCalls.length = 0
|
||||||
|
listCalls.length = 0
|
||||||
allow = true
|
allow = true
|
||||||
|
resolvedType = "file"
|
||||||
resolveFailure = undefined
|
resolveFailure = undefined
|
||||||
|
inspectFailure = undefined
|
||||||
directoryEntries = []
|
directoryEntries = []
|
||||||
readResult = {
|
readResult = {
|
||||||
type: "file",
|
type: "file",
|
||||||
@@ -176,6 +210,7 @@ describe("ReadTool", () => {
|
|||||||
mime: "text/plain",
|
mime: "text/plain",
|
||||||
}
|
}
|
||||||
readFailure = undefined
|
readFailure = undefined
|
||||||
|
listResult = new ReadToolFileSystem.ListPage({ type: "list-page", entries: [], truncated: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
it.effect("registers, authorizes, and reads through the location filesystem", () =>
|
it.effect("registers, authorizes, and reads through the location filesystem", () =>
|
||||||
@@ -637,7 +672,7 @@ describe("ReadTool", () => {
|
|||||||
|
|
||||||
it.effect("returns missing paths as model-visible tool failures", () =>
|
it.effect("returns missing paths as model-visible tool failures", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
readFailure = new Environment.NotFound({ path: missingAbsolutePath })
|
inspectFailure = notFound(missingAbsolutePath)
|
||||||
directoryEntries = [
|
directoryEntries = [
|
||||||
"__missing_read_target__.txt.bak",
|
"__missing_read_target__.txt.bak",
|
||||||
"copy___missing_read_target__.txt",
|
"copy___missing_read_target__.txt",
|
||||||
@@ -661,18 +696,14 @@ describe("ReadTool", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: [missingPath], save: ["*"] }])
|
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: [missingPath], save: ["*"] }])
|
||||||
expect(readCalls).toEqual([
|
expect(readCalls).toEqual([])
|
||||||
{
|
|
||||||
input: AbsolutePath.make(missingAbsolutePath),
|
|
||||||
page: { offset: undefined, limit: undefined },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("lists a bounded directory page through read", () =>
|
it.effect("lists a bounded directory page through read", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
readResult = new ReadToolFileSystem.ListPage({
|
resolvedType = "directory"
|
||||||
|
listResult = new ReadToolFileSystem.ListPage({
|
||||||
type: "list-page",
|
type: "list-page",
|
||||||
entries: [
|
entries: [
|
||||||
FileSystem.Entry.make({ path: RelativePath.make("components/"), type: "directory" }),
|
FileSystem.Entry.make({ path: RelativePath.make("components/"), type: "directory" }),
|
||||||
@@ -695,7 +726,7 @@ describe("ReadTool", () => {
|
|||||||
})
|
})
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
status: "completed",
|
status: "completed",
|
||||||
output: { entries: readResult.entries, truncated: true, next: 4 },
|
output: { entries: listResult.entries, truncated: true, next: 4 },
|
||||||
})
|
})
|
||||||
if (result.status !== "completed") return
|
if (result.status !== "completed") return
|
||||||
expect(result.metadata).toEqual({ truncated: true })
|
expect(result.metadata).toEqual({ truncated: true })
|
||||||
@@ -706,15 +737,14 @@ describe("ReadTool", () => {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }])
|
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }])
|
||||||
expect(readCalls).toEqual([
|
expect(listCalls).toEqual([{ offset: 2, limit: 10 }])
|
||||||
{ input: AbsolutePath.make(path.join(process.cwd(), "src")), page: { offset: 2, limit: 10 } },
|
|
||||||
])
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("does not list a directory when permission is denied", () =>
|
it.effect("does not list a directory when permission is denied", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
allow = false
|
allow = false
|
||||||
|
resolvedType = "directory"
|
||||||
const registry = yield* Tool.Service
|
const registry = yield* Tool.Service
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
@@ -724,7 +754,7 @@ describe("ReadTool", () => {
|
|||||||
call: { type: "tool-call", id: "call-read-directory-denied", name: "read", input: { path: "src" } },
|
call: { type: "tool-call", id: "call-read-directory-denied", name: "read", input: { path: "src" } },
|
||||||
}),
|
}),
|
||||||
).toEqual({ status: "error", error: { type: "permission.rejected", message: "Permission denied: read" } })
|
).toEqual({ status: "error", error: { type: "permission.rejected", message: "Permission denied: read" } })
|
||||||
expect(readCalls).toEqual([])
|
expect(listCalls).toEqual([])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -743,12 +773,7 @@ describe("ReadTool", () => {
|
|||||||
),
|
),
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
|
|
||||||
expect(readCalls).toEqual([
|
expect(readCalls).toEqual([])
|
||||||
{
|
|
||||||
input: AbsolutePath.make(path.join(process.cwd(), "missing.txt")),
|
|
||||||
page: { offset: undefined, limit: undefined },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { Effect, Layer } from "effect"
|
|||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
import { Environment } from "@opencode-ai/core/environment"
|
|
||||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||||
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||||
import { Permission } from "@opencode-ai/core/permission"
|
import { Permission } from "@opencode-ai/core/permission"
|
||||||
@@ -24,12 +24,19 @@ import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
|
|||||||
const globToolNode = makeLocationNode({
|
const globToolNode = makeLocationNode({
|
||||||
name: "test/glob-tool-plugin",
|
name: "test/glob-tool-plugin",
|
||||||
layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)),
|
layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)),
|
||||||
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
|
deps: [
|
||||||
|
Tool.node,
|
||||||
|
FSUtil.node,
|
||||||
|
Ripgrep.node,
|
||||||
|
Location.node,
|
||||||
|
LocationMutation.node,
|
||||||
|
Permission.node,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
const grepToolNode = makeLocationNode({
|
const grepToolNode = makeLocationNode({
|
||||||
name: "test/grep-tool-plugin",
|
name: "test/grep-tool-plugin",
|
||||||
layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)),
|
layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)),
|
||||||
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
|
deps: [Tool.node, FSUtil.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
|
||||||
})
|
})
|
||||||
const sessionID = Session.ID.make("ses_search_tool_test")
|
const sessionID = Session.ID.make("ses_search_tool_test")
|
||||||
|
|
||||||
@@ -179,7 +186,9 @@ describe("search tools", () => {
|
|||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
(tmp) =>
|
(tmp) =>
|
||||||
Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "haystack\n")).pipe(
|
Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "haystack\n")).pipe(
|
||||||
Effect.andThen(withTools(tmp.path, (registry) => executeTool(registry, call("grep", { pattern: "needle" })))),
|
Effect.andThen(
|
||||||
|
withTools(tmp.path, (registry) => executeTool(registry, call("grep", { pattern: "needle" }))),
|
||||||
|
),
|
||||||
Effect.tap((result) =>
|
Effect.tap((result) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
@@ -288,7 +297,9 @@ describe("search tools", () => {
|
|||||||
(tmp) =>
|
(tmp) =>
|
||||||
Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "content\n")).pipe(
|
Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "content\n")).pipe(
|
||||||
Effect.andThen(
|
Effect.andThen(
|
||||||
withTools(tmp.path, (registry) => executeTool(registry, call("glob", { path: "file.txt", pattern: "*" }))),
|
withTools(tmp.path, (registry) =>
|
||||||
|
executeTool(registry, call("glob", { path: "file.txt", pattern: "*" })),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Effect.tap((result) =>
|
Effect.tap((result) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
@@ -320,7 +331,9 @@ describe("search tools", () => {
|
|||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
expect(result.status).toBe("completed")
|
expect(result.status).toBe("completed")
|
||||||
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "glob"])
|
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "glob"])
|
||||||
expect(assertions[0]?.resources).toEqual([path.join(outside.path, "*").replaceAll("\\", "/")])
|
expect(assertions[0]?.resources).toEqual([
|
||||||
|
path.join(outside.path, "*").replaceAll("\\", "/"),
|
||||||
|
])
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -524,8 +524,7 @@ describe("ShellTool", () => {
|
|||||||
const content = settled.content?.[0]
|
const content = settled.content?.[0]
|
||||||
if (!content || content.type !== "text") throw new Error("Expected text content")
|
if (!content || content.type !== "text") throw new Error("Expected text content")
|
||||||
expect(content.text).not.toContain("one")
|
expect(content.text).not.toContain("one")
|
||||||
// Windows shells emit CRLF; the assertion targets line limits, not line endings.
|
expect(content.text).toStartWith("two\nthree")
|
||||||
expect(content.text.replaceAll("\r\n", "\n")).toStartWith("two\nthree")
|
|
||||||
expect(content.text).toContain("output truncated; full output saved to:")
|
expect(content.text).toContain("output truncated; full output saved to:")
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { FileMutation } from "@opencode-ai/core/file-mutation"
|
|||||||
import { Formatter } from "@opencode-ai/core/formatter"
|
import { Formatter } from "@opencode-ai/core/formatter"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
import { Environment } from "@opencode-ai/core/environment"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||||
import { Permission } from "@opencode-ai/core/permission"
|
import { Permission } from "@opencode-ai/core/permission"
|
||||||
@@ -23,7 +23,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
|
|||||||
const writeToolNode = makeLocationNode({
|
const writeToolNode = makeLocationNode({
|
||||||
name: "test/write-tool-plugin",
|
name: "test/write-tool-plugin",
|
||||||
layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)),
|
layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)),
|
||||||
deps: [Tool.node, LocationMutation.node, FileMutation.node, Environment.node, Formatter.node, Permission.node],
|
deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node],
|
||||||
})
|
})
|
||||||
|
|
||||||
const sessionID = Session.ID.make("ses_write_tool_test")
|
const sessionID = Session.ID.make("ses_write_tool_test")
|
||||||
@@ -68,20 +68,17 @@ const reset = () => {
|
|||||||
denyAction = undefined
|
denyAction = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const environment = Layer.effect(
|
const filesystem = Layer.effect(
|
||||||
Environment.Service,
|
FSUtil.Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const current = yield* Environment.Service
|
const fs = yield* FSUtil.Service
|
||||||
return Environment.Service.of({
|
return FSUtil.Service.of({
|
||||||
...current,
|
...fs,
|
||||||
files: {
|
writeWithDirs: (target, content, mode) =>
|
||||||
...current.files,
|
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeWithDirs(target, content, mode))),
|
||||||
write: (target, content) =>
|
|
||||||
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(current.files.write(target, content))),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
).pipe(Layer.provide(LayerNode.compile(Environment.node)))
|
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||||
|
|
||||||
const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) => {
|
const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) => {
|
||||||
const activeLocation = Layer.succeed(
|
const activeLocation = Layer.succeed(
|
||||||
@@ -95,7 +92,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
|
|||||||
AppNodeBuilder.build(
|
AppNodeBuilder.build(
|
||||||
LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]),
|
LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]),
|
||||||
[
|
[
|
||||||
[Environment.node, environment],
|
[FSUtil.node, filesystem],
|
||||||
[Location.node, activeLocation],
|
[Location.node, activeLocation],
|
||||||
[Formatter.node, formatter],
|
[Formatter.node, formatter],
|
||||||
[Permission.node, permission],
|
[Permission.node, permission],
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ export interface Storage {
|
|||||||
* JSON-serializable.
|
* JSON-serializable.
|
||||||
*/
|
*/
|
||||||
memory<Value extends object>(key: string, options: { readonly initial: Value }): MemoryEntry<Value>
|
memory<Value extends object>(key: string, options: { readonly initial: Value }): MemoryEntry<Value>
|
||||||
flush(): Promise<void>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clone<Value extends object>(value: Value) {
|
function clone<Value extends object>(value: Value) {
|
||||||
@@ -47,7 +46,6 @@ function segment(value: string) {
|
|||||||
function createStorage(root: string, channel: string) {
|
function createStorage(root: string, channel: string) {
|
||||||
const entries = new Map<string, { readonly value: Entry<object>; readonly reload: () => void }>()
|
const entries = new Map<string, { readonly value: Entry<object>; readonly reload: () => void }>()
|
||||||
const memories = new Map<string, MemoryEntry<object>>()
|
const memories = new Map<string, MemoryEntry<object>>()
|
||||||
const pending = new Set<Promise<void>>()
|
|
||||||
const directory = path.join(root, segment(channel), "tui")
|
const directory = path.join(root, segment(channel), "tui")
|
||||||
const locks = path.join(root, segment(channel), "locks")
|
const locks = path.join(root, segment(channel), "locks")
|
||||||
mkdirSync(directory, { recursive: true })
|
mkdirSync(directory, { recursive: true })
|
||||||
@@ -68,8 +66,8 @@ function createStorage(root: string, channel: string) {
|
|||||||
const [store, setStore] = createStore(load())
|
const [store, setStore] = createStore(load())
|
||||||
const merge = (next: Value) => reconcile(next, { key: options.key })
|
const merge = (next: Value) => reconcile(next, { key: options.key })
|
||||||
const reload = () => batch(() => setStore(merge(load())))
|
const reload = () => batch(() => setStore(merge(load())))
|
||||||
const update = (mutation: (draft: Value) => void) => {
|
const update = (mutation: (draft: Value) => void) =>
|
||||||
const operation = Flock.withLock(
|
Flock.withLock(
|
||||||
file,
|
file,
|
||||||
async () => {
|
async () => {
|
||||||
const draft = load()
|
const draft = load()
|
||||||
@@ -80,13 +78,6 @@ function createStorage(root: string, channel: string) {
|
|||||||
},
|
},
|
||||||
{ dir: locks },
|
{ dir: locks },
|
||||||
)
|
)
|
||||||
pending.add(operation)
|
|
||||||
operation.then(
|
|
||||||
() => pending.delete(operation),
|
|
||||||
() => pending.delete(operation),
|
|
||||||
)
|
|
||||||
return operation
|
|
||||||
}
|
|
||||||
const entry = [store, update] as const
|
const entry = [store, update] as const
|
||||||
entries.set(file, { value: entry as Entry<object>, reload })
|
entries.set(file, { value: entry as Entry<object>, reload })
|
||||||
return entry
|
return entry
|
||||||
@@ -99,15 +90,6 @@ function createStorage(root: string, channel: string) {
|
|||||||
memories.set(key, entry as MemoryEntry<object>)
|
memories.set(key, entry as MemoryEntry<object>)
|
||||||
return entry
|
return entry
|
||||||
},
|
},
|
||||||
async flush() {
|
|
||||||
const failures: unknown[] = []
|
|
||||||
while (pending.size > 0) {
|
|
||||||
const results = await Promise.allSettled(pending)
|
|
||||||
failures.push(...results.filter((result) => result.status === "rejected").map((result) => result.reason))
|
|
||||||
}
|
|
||||||
if (failures.length === 1) throw failures[0]
|
|
||||||
if (failures.length > 1) throw new AggregateError(failures, "Storage writes failed")
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const watcher = watch(directory, () => entries.forEach((entry) => entry.reload()))
|
const watcher = watch(directory, () => entries.forEach((entry) => entry.reload()))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
import type { TextareaRenderable } from "@opentui/core"
|
import { decodePasteBytes, stripAnsiSequences, type TextareaRenderable } from "@opentui/core"
|
||||||
import { useKeyboard } from "@opentui/solid"
|
import { useKeyboard, usePaste } from "@opentui/solid"
|
||||||
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||||
import {
|
import {
|
||||||
createFormBodyState,
|
createFormBodyState,
|
||||||
@@ -149,6 +149,14 @@ export function RunFormBody(props: {
|
|||||||
if (formSingle(props.request)) submit(next)
|
if (formSingle(props.request)) submit(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
usePaste((event) => {
|
||||||
|
const field = current()
|
||||||
|
if (!field || textual() || !custom() || confirm()) return
|
||||||
|
event.preventDefault()
|
||||||
|
const next = formPick(formSetSelected(state(), rows().length), props.request)
|
||||||
|
setState(formSetDraft(next, field, formInput(next, field) + stripAnsiSequences(decodePasteBytes(event.bytes))))
|
||||||
|
})
|
||||||
|
|
||||||
const moveField = (direction: -1 | 1) => {
|
const moveField = (direction: -1 | 1) => {
|
||||||
const next = (state().field + direction + props.request.fields.length + 1) % (props.request.fields.length + 1)
|
const next = (state().field + direction + props.request.fields.length + 1) % (props.request.fields.length + 1)
|
||||||
if (direction < 0 || confirm()) {
|
if (direction < 0 || confirm()) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
||||||
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
import { usePaste, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||||
import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"
|
import { decodePasteBytes, stripAnsiSequences, type ScrollBoxRenderable, type TextareaRenderable } from "@opentui/core"
|
||||||
import open from "open"
|
import open from "open"
|
||||||
import { useTheme, useThemes } from "../../context/theme"
|
import { useTheme, useThemes } from "../../context/theme"
|
||||||
import type { FormField, FormValue } from "@opencode-ai/client"
|
import type { FormField, FormValue } from "@opencode-ai/client"
|
||||||
@@ -265,6 +265,19 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||||||
pick(row.value)
|
pick(row.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
usePaste((event) => {
|
||||||
|
if (keymap.mode.current() !== FORM_MODE) return
|
||||||
|
const current = answerField()
|
||||||
|
if (!current || textual() || !custom() || confirm()) return
|
||||||
|
event.preventDefault()
|
||||||
|
setStore("selected", rows().length)
|
||||||
|
setStore("custom", {
|
||||||
|
...store.custom,
|
||||||
|
[current.key]: input() + stripAnsiSequences(decodePasteBytes(event.bytes)),
|
||||||
|
})
|
||||||
|
setStore("editing", true)
|
||||||
|
})
|
||||||
|
|
||||||
function commitInput(text: string) {
|
function commitInput(text: string) {
|
||||||
const current = answerField()
|
const current = answerField()
|
||||||
if (!current) return false
|
if (!current) return false
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { SplitBorder } from "../../ui/border"
|
|||||||
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
|
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
|
||||||
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
|
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
|
||||||
import { PatchDiff } from "../../component/patch-diff"
|
import { PatchDiff } from "../../component/patch-diff"
|
||||||
import { createSyntaxStyleMemo, ThemeContextProvider, useTheme, useThemes } from "../../context/theme"
|
import { ThemeContextProvider, useTheme, useThemes } from "../../context/theme"
|
||||||
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
|
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
|
||||||
import { Prompt, type PromptRef } from "../../component/prompt"
|
import { Prompt, type PromptRef } from "../../component/prompt"
|
||||||
import type {
|
import type {
|
||||||
@@ -100,7 +100,6 @@ import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallbac
|
|||||||
import { useSessionTabs } from "../../context/session-tabs"
|
import { useSessionTabs } from "../../context/session-tabs"
|
||||||
import { createSingleFlight } from "../../util/single-flight"
|
import { createSingleFlight } from "../../util/single-flight"
|
||||||
import type { SessionPending } from "@opencode-ai/schema/session-pending"
|
import type { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||||
import { generateThinkingSyntax } from "./thinking-syntax"
|
|
||||||
|
|
||||||
addDefaultParsers(parsers.parsers)
|
addDefaultParsers(parsers.parsers)
|
||||||
|
|
||||||
@@ -1433,7 +1432,6 @@ function SessionReasoningGroupView(props: {
|
|||||||
const ctx = use()
|
const ctx = use()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const { currentSyntax: syntax } = useThemes()
|
const { currentSyntax: syntax } = useThemes()
|
||||||
const thinkingSyntax = createSyntaxStyleMemo(() => generateThinkingSyntax(syntax(), theme.text.subdued))
|
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
const [expanded, setExpanded] = createSignal(false)
|
const [expanded, setExpanded] = createSignal(false)
|
||||||
const [hover, setHover] = createSignal(false)
|
const [hover, setHover] = createSignal(false)
|
||||||
@@ -1529,7 +1527,7 @@ function SessionReasoningGroupView(props: {
|
|||||||
filetype="markdown"
|
filetype="markdown"
|
||||||
drawUnstyledText={false}
|
drawUnstyledText={false}
|
||||||
streaming={part()?.time?.completed === undefined && message()?.time.completed === undefined}
|
streaming={part()?.time?.completed === undefined && message()?.time.completed === undefined}
|
||||||
syntaxStyle={thinkingSyntax()}
|
syntaxStyle={syntax()}
|
||||||
content={content()}
|
content={content()}
|
||||||
conceal={ctx.markdownMode() === "rendered"}
|
conceal={ctx.markdownMode() === "rendered"}
|
||||||
fg={theme.text.subdued}
|
fg={theme.text.subdued}
|
||||||
@@ -2062,7 +2060,6 @@ function ReasoningPart(props: {
|
|||||||
}) {
|
}) {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const { currentSyntax: syntax } = useThemes()
|
const { currentSyntax: syntax } = useThemes()
|
||||||
const thinkingSyntax = createSyntaxStyleMemo(() => generateThinkingSyntax(syntax(), theme.text.subdued))
|
|
||||||
const ctx = use()
|
const ctx = use()
|
||||||
// Collapsed by default in hide mode: a single line throughout, so the
|
// Collapsed by default in hide mode: a single line throughout, so the
|
||||||
// layout never shifts. Click to open the full markdown block, click to close.
|
// layout never shifts. Click to open the full markdown block, click to close.
|
||||||
@@ -2115,7 +2112,7 @@ function ReasoningPart(props: {
|
|||||||
filetype="markdown"
|
filetype="markdown"
|
||||||
drawUnstyledText={false}
|
drawUnstyledText={false}
|
||||||
streaming={true}
|
streaming={true}
|
||||||
syntaxStyle={thinkingSyntax()}
|
syntaxStyle={syntax()}
|
||||||
content={content()}
|
content={content()}
|
||||||
conceal={ctx.markdownMode() === "rendered"}
|
conceal={ctx.markdownMode() === "rendered"}
|
||||||
fg={theme.text.subdued}
|
fg={theme.text.subdued}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import { SyntaxStyle, type RGBA } from "@opentui/core"
|
|
||||||
|
|
||||||
export function generateThinkingSyntax(syntax: SyntaxStyle, foreground: RGBA) {
|
|
||||||
return SyntaxStyle.fromStyles(
|
|
||||||
Object.fromEntries(
|
|
||||||
syntax.getRegisteredNames().map((name) => [name, { ...syntax.getStyle(name), fg: foreground }]),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { testRender } from "@opentui/solid"
|
import { testRender } from "@opentui/solid"
|
||||||
|
import { mkdtempSync, rmSync } from "fs"
|
||||||
|
import { tmpdir } from "os"
|
||||||
|
import path from "path"
|
||||||
import { onMount } from "solid-js"
|
import { onMount } from "solid-js"
|
||||||
import { DialogOpen } from "../../../src/component/dialog-open"
|
import { DialogOpen } from "../../../src/component/dialog-open"
|
||||||
import { ConfigProvider } from "../../../src/config"
|
import { ConfigProvider } from "../../../src/config"
|
||||||
@@ -11,13 +14,12 @@ import { LocationProvider, useLocation } from "../../../src/context/location"
|
|||||||
import { RouteProvider, useRoute } from "../../../src/context/route"
|
import { RouteProvider, useRoute } from "../../../src/context/route"
|
||||||
import { TuiAppProvider } from "../../../src/context/runtime"
|
import { TuiAppProvider } from "../../../src/context/runtime"
|
||||||
import { SessionTabsProvider } from "../../../src/context/session-tabs"
|
import { SessionTabsProvider } from "../../../src/context/session-tabs"
|
||||||
import { StorageProvider, useStorage } from "../../../src/context/storage"
|
import { StorageProvider } from "../../../src/context/storage"
|
||||||
import { ThemeProvider } from "../../../src/context/theme"
|
import { ThemeProvider } from "../../../src/context/theme"
|
||||||
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
|
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
|
||||||
import { ToastProvider } from "../../../src/ui/toast"
|
import { ToastProvider } from "../../../src/ui/toast"
|
||||||
import { createApi, createEventStream, createFetch, json, type FetchHandler } from "../../fixture/tui-client"
|
import { createApi, createEventStream, createFetch, json, type FetchHandler } from "../../fixture/tui-client"
|
||||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||||
import { tmpdir } from "../../fixture/fixture"
|
|
||||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||||
|
|
||||||
test("selecting an unhydrated session preserves its location", async () => {
|
test("selecting an unhydrated session preserves its location", async () => {
|
||||||
@@ -50,7 +52,7 @@ test("selecting an unhydrated session preserves its location", async () => {
|
|||||||
expect(fixture.route.data).toEqual({ type: "session", sessionID: "ses_remote" })
|
expect(fixture.route.data).toEqual({ type: "session", sessionID: "ses_remote" })
|
||||||
expect(fixture.location.ref).toEqual(remote)
|
expect(fixture.location.ref).toEqual(remote)
|
||||||
} finally {
|
} finally {
|
||||||
await fixture.dispose()
|
fixture.dispose()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -92,7 +94,7 @@ test("shows the current project and opens its root", async () => {
|
|||||||
expect(fixture.route.data).toEqual({ type: "home", location: { directory: root } })
|
expect(fixture.route.data).toEqual({ type: "home", location: { directory: root } })
|
||||||
expect(fixture.location.ref).toEqual({ directory: root })
|
expect(fixture.location.ref).toEqual({ directory: root })
|
||||||
} finally {
|
} finally {
|
||||||
await fixture.dispose()
|
fixture.dispose()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -147,7 +149,7 @@ test("preserves a moved project when sessions arrive", async () => {
|
|||||||
|
|
||||||
expect(fixture.route.data).toEqual({ type: "home", location: { directory: "/tmp/opencode/second" } })
|
expect(fixture.route.data).toEqual({ type: "home", location: { directory: "/tmp/opencode/second" } })
|
||||||
} finally {
|
} finally {
|
||||||
await fixture.dispose()
|
fixture.dispose()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -158,21 +160,18 @@ async function renderOpen(
|
|||||||
location: ReturnType<typeof useLocation>
|
location: ReturnType<typeof useLocation>
|
||||||
}) => void | Promise<void>,
|
}) => void | Promise<void>,
|
||||||
) {
|
) {
|
||||||
const temporary = await tmpdir()
|
const state = mkdtempSync(path.join(tmpdir(), "opencode-dialog-open-"))
|
||||||
const state = temporary.path
|
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
const calls = createFetch(handler, events)
|
const calls = createFetch(handler, events)
|
||||||
let route!: ReturnType<typeof useRoute>
|
let route!: ReturnType<typeof useRoute>
|
||||||
let location!: ReturnType<typeof useLocation>
|
let location!: ReturnType<typeof useLocation>
|
||||||
let data!: ReturnType<typeof useData>
|
let data!: ReturnType<typeof useData>
|
||||||
let storage!: ReturnType<typeof useStorage>
|
|
||||||
|
|
||||||
function Probe() {
|
function Probe() {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
route = useRoute()
|
route = useRoute()
|
||||||
location = useLocation()
|
location = useLocation()
|
||||||
data = useData()
|
data = useData()
|
||||||
storage = useStorage()
|
|
||||||
onMount(
|
onMount(
|
||||||
() => void Promise.resolve(beforeOpen?.({ data, location })).then(() => dialog.replace(() => <DialogOpen />)),
|
() => void Promise.resolve(beforeOpen?.({ data, location })).then(() => dialog.replace(() => <DialogOpen />)),
|
||||||
)
|
)
|
||||||
@@ -224,10 +223,9 @@ async function renderOpen(
|
|||||||
get data() {
|
get data() {
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
async dispose() {
|
dispose() {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
await storage.flush()
|
rmSync(state, { recursive: true, force: true })
|
||||||
await temporary[Symbol.asyncDispose]()
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
|
|||||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||||
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
|
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
|
||||||
|
|
||||||
async function mountForm(root: string, width = 80) {
|
async function mountForm(root: string, width = 80, fields?: FormWithLocation["fields"]) {
|
||||||
const state = path.join(root, "state")
|
const state = path.join(root, "state")
|
||||||
await mkdir(state, { recursive: true })
|
await mkdir(state, { recursive: true })
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ async function mountForm(root: string, width = 80) {
|
|||||||
id: "frm_test",
|
id: "frm_test",
|
||||||
sessionID: "ses_test",
|
sessionID: "ses_test",
|
||||||
title: "Authorization required",
|
title: "Authorization required",
|
||||||
fields: [
|
fields: fields ?? [
|
||||||
{
|
{
|
||||||
key: "authorization",
|
key: "authorization",
|
||||||
type: "external",
|
type: "external",
|
||||||
@@ -126,3 +126,57 @@ test("includes external acknowledgements in progress", async () => {
|
|||||||
prompt.app.renderer.destroy()
|
prompt.app.renderer.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("pasting on a custom choice opens its editor without submitting", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const prompt = await mountForm(tmp.path, 80, [
|
||||||
|
{
|
||||||
|
key: "target",
|
||||||
|
type: "string",
|
||||||
|
options: [{ value: "staging", label: "Staging" }],
|
||||||
|
custom: true,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
try {
|
||||||
|
await prompt.app.mockInput.pasteBracketedText("production\nwest")
|
||||||
|
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor?.plainText === "production\nwest")
|
||||||
|
|
||||||
|
expect(prompt.app.captureCharFrame()).toContain("Type your own answer")
|
||||||
|
expect(prompt.replies).toEqual([])
|
||||||
|
} finally {
|
||||||
|
prompt.app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("text fields retain default paste behavior", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const prompt = await mountForm(tmp.path, 80, [{ key: "notes", type: "string" }])
|
||||||
|
try {
|
||||||
|
await prompt.app.mockInput.pasteBracketedText("normal paste")
|
||||||
|
|
||||||
|
expect(prompt.app.renderer.currentFocusedEditor?.plainText).toBe("normal paste")
|
||||||
|
expect(prompt.replies).toEqual([])
|
||||||
|
} finally {
|
||||||
|
prompt.app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("pasting on a choice without custom answers does not open an editor", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const prompt = await mountForm(tmp.path, 80, [
|
||||||
|
{
|
||||||
|
key: "target",
|
||||||
|
type: "string",
|
||||||
|
options: [{ value: "staging", label: "Staging" }],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
try {
|
||||||
|
await prompt.app.mockInput.pasteBracketedText("production")
|
||||||
|
|
||||||
|
expect(prompt.app.renderer.currentFocusedEditor).toBeNull()
|
||||||
|
expect(prompt.app.captureCharFrame()).not.toContain("production")
|
||||||
|
expect(prompt.replies).toEqual([])
|
||||||
|
} finally {
|
||||||
|
prompt.app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
import { expect, test } from "bun:test"
|
import { afterAll, expect, test } from "bun:test"
|
||||||
import type { OpenCodeEvent } from "@opencode-ai/client"
|
import type { OpenCodeEvent } from "@opencode-ai/client"
|
||||||
import { testRender } from "@opentui/solid"
|
import { testRender } from "@opentui/solid"
|
||||||
import { mkdirSync, watch } from "fs"
|
import { mkdirSync, mkdtempSync, readdirSync, rmSync, watch } from "fs"
|
||||||
|
import { tmpdir } from "os"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { ConfigProvider } from "../../src/config"
|
import { ConfigProvider } from "../../src/config"
|
||||||
import { ClientProvider, useClient } from "../../src/context/client"
|
import { ClientProvider, useClient } from "../../src/context/client"
|
||||||
@@ -11,10 +12,9 @@ import { RouteProvider, useRoute } from "../../src/context/route"
|
|||||||
import { TuiAppProvider } from "../../src/context/runtime"
|
import { TuiAppProvider } from "../../src/context/runtime"
|
||||||
import { SessionTabsProvider, useSessionTabs } from "../../src/context/session-tabs"
|
import { SessionTabsProvider, useSessionTabs } from "../../src/context/session-tabs"
|
||||||
import { NEW_SESSION_TAB_TITLE } from "../../src/context/session-tabs-model"
|
import { NEW_SESSION_TAB_TITLE } from "../../src/context/session-tabs-model"
|
||||||
import { StorageProvider, useStorage } from "../../src/context/storage"
|
import { StorageProvider } from "../../src/context/storage"
|
||||||
import { createApi, createEventStream, createFetch, directory, json } from "../fixture/tui-client"
|
import { createApi, createEventStream, createFetch, directory, json } from "../fixture/tui-client"
|
||||||
import { TestTuiContexts } from "../fixture/tui-environment"
|
import { TestTuiContexts } from "../fixture/tui-environment"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
|
||||||
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
|
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
|
||||||
|
|
||||||
async function wait(fn: () => boolean | Promise<boolean>, timeout = 2_000) {
|
async function wait(fn: () => boolean | Promise<boolean>, timeout = 2_000) {
|
||||||
@@ -25,12 +25,35 @@ async function wait(fn: () => boolean | Promise<boolean>, timeout = 2_000) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// State directories are removed after the whole suite instead of per test: persistence writes are
|
||||||
|
// fire-and-forget behind a file lock, so a teardown-time removal races any still-queued write.
|
||||||
|
const stateDirs: string[] = []
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const dir of stateDirs) {
|
||||||
|
// Drain any lock still held by a late write before deleting the tree beneath it.
|
||||||
|
await wait(() => {
|
||||||
|
try {
|
||||||
|
return readdirSync(path.join(dir, "test", "locks")).length === 0
|
||||||
|
} catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}).catch(() => undefined)
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function stateDir(prefix: string) {
|
||||||
|
const dir = mkdtempSync(path.join(tmpdir(), prefix))
|
||||||
|
stateDirs.push(dir)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
async function renderSessionTabs(
|
async function renderSessionTabs(
|
||||||
initialSessionID: string,
|
initialSessionID: string,
|
||||||
options?: { state?: string; title?: string; home?: boolean; persisted?: string[]; sessionGate?: Promise<void> },
|
options?: { state?: string; title?: string; home?: boolean; persisted?: string[]; sessionGate?: Promise<void> },
|
||||||
) {
|
) {
|
||||||
const temporary = options?.state ? undefined : await tmpdir()
|
const state = options?.state ?? stateDir("opencode-session-tabs-")
|
||||||
const state = options?.state ?? temporary!.path
|
|
||||||
if (options?.persisted) {
|
if (options?.persisted) {
|
||||||
const file = path.join(state, "test", "tui", "tabs.json")
|
const file = path.join(state, "test", "tui", "tabs.json")
|
||||||
mkdirSync(path.dirname(file), { recursive: true })
|
mkdirSync(path.dirname(file), { recursive: true })
|
||||||
@@ -65,14 +88,12 @@ async function renderSessionTabs(
|
|||||||
let route!: ReturnType<typeof useRoute>
|
let route!: ReturnType<typeof useRoute>
|
||||||
let client!: ReturnType<typeof useClient>
|
let client!: ReturnType<typeof useClient>
|
||||||
let data!: ReturnType<typeof useData>
|
let data!: ReturnType<typeof useData>
|
||||||
let storage!: ReturnType<typeof useStorage>
|
|
||||||
|
|
||||||
function Probe() {
|
function Probe() {
|
||||||
tabs = useSessionTabs()
|
tabs = useSessionTabs()
|
||||||
route = useRoute()
|
route = useRoute()
|
||||||
client = useClient()
|
client = useClient()
|
||||||
data = useData()
|
data = useData()
|
||||||
storage = useStorage()
|
|
||||||
return <box />
|
return <box />
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,10 +127,8 @@ async function renderSessionTabs(
|
|||||||
sessions,
|
sessions,
|
||||||
state,
|
state,
|
||||||
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
|
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
|
||||||
async destroy() {
|
destroy() {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
await storage.flush()
|
|
||||||
await temporary?.[Symbol.asyncDispose]()
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,7 +149,7 @@ test("loads persisted tab metadata concurrently on connect", async () => {
|
|||||||
await wait(() => setup.data.session.get("first") !== undefined && setup.data.session.get("second") !== undefined)
|
await wait(() => setup.data.session.get("first") !== undefined && setup.data.session.get("second") !== undefined)
|
||||||
} finally {
|
} finally {
|
||||||
release()
|
release()
|
||||||
await setup.destroy()
|
setup.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -140,19 +159,17 @@ test("stores session tabs for the current working directory by default", async (
|
|||||||
try {
|
try {
|
||||||
const file = path.join(setup.state, "test", "tui", "tabs.json")
|
const file = path.join(setup.state, "test", "tui", "tabs.json")
|
||||||
await wait(() => Bun.file(file).size > 0)
|
await wait(() => Bun.file(file).size > 0)
|
||||||
const stored = await Bun.file(file).json()
|
expect(await Bun.file(file).json()).toEqual({
|
||||||
expect(stored.global).toEqual({ tabs: [], unread: {} })
|
global: { tabs: [], unread: {} },
|
||||||
expect(Object.keys(stored.cwd)).toEqual([directory])
|
cwd: { [directory]: { tabs: [{ sessionID: "first" }], unread: {} } },
|
||||||
expect(stored.cwd[directory].tabs.map((tab: { sessionID: string }) => tab.sessionID)).toEqual(["first"])
|
})
|
||||||
expect(stored.cwd[directory].unread).toEqual({})
|
|
||||||
} finally {
|
} finally {
|
||||||
await setup.destroy()
|
setup.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("concurrent TUIs do not alternate shared tab titles from divergent session caches", async () => {
|
test("concurrent TUIs do not alternate shared tab titles from divergent session caches", async () => {
|
||||||
await using temporary = await tmpdir()
|
const state = stateDir("opencode-session-tabs-shared-")
|
||||||
const state = temporary.path
|
|
||||||
let titled: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
|
let titled: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
|
||||||
let untitled: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
|
let untitled: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
|
||||||
|
|
||||||
@@ -189,8 +206,8 @@ test("concurrent TUIs do not alternate shared tab titles from divergent session
|
|||||||
|
|
||||||
expect(observed).toEqual(["Generated title"])
|
expect(observed).toEqual(["Generated title"])
|
||||||
} finally {
|
} finally {
|
||||||
if (titled) await titled.destroy()
|
titled?.destroy()
|
||||||
if (untitled) await untitled.destroy()
|
untitled?.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -238,7 +255,7 @@ test("user prompt admissions pulse an already-busy background tab", async () =>
|
|||||||
expect(setup.tabs.status("active").promptPulse).toBe(0)
|
expect(setup.tabs.status("active").promptPulse).toBe(0)
|
||||||
expect(setup.tabs.status("background")).toMatchObject({ promptPulse: 2, busy: true })
|
expect(setup.tabs.status("background")).toMatchObject({ promptPulse: 2, busy: true })
|
||||||
} finally {
|
} finally {
|
||||||
await setup.destroy()
|
setup.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -269,6 +286,6 @@ test("tracks a temporary new session tab across close and creation", async () =>
|
|||||||
expect(setup.tabs.newTab()).toBe(false)
|
expect(setup.tabs.newTab()).toBe(false)
|
||||||
expect(setup.tabs.tabs().find((tab) => tab.sessionID === "third")?.title).toBe(NEW_SESSION_TAB_TITLE)
|
expect(setup.tabs.tabs().find((tab) => tab.sessionID === "third")?.title).toBe(NEW_SESSION_TAB_TITLE)
|
||||||
} finally {
|
} finally {
|
||||||
await setup.destroy()
|
setup.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -277,6 +277,41 @@ test("direct footer preserves a partial multi-field form draft across permission
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("direct footer paste opens a custom choice editor without submitting", async () => {
|
||||||
|
const replies: unknown[] = []
|
||||||
|
const app = await renderFooter({
|
||||||
|
height: 12,
|
||||||
|
view: {
|
||||||
|
type: "form",
|
||||||
|
request: {
|
||||||
|
id: "frm_custom_paste",
|
||||||
|
sessionID: "ses_child",
|
||||||
|
title: "Deployment target",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: "target",
|
||||||
|
type: "string",
|
||||||
|
options: [{ value: "staging", label: "Staging" }],
|
||||||
|
custom: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onFormReply: (reply) => replies.push(reply),
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.renderOnce()
|
||||||
|
await app.mockInput.pasteBracketedText("production\nwest")
|
||||||
|
await app.renderOnce()
|
||||||
|
|
||||||
|
expect(app.renderer.currentFocusedEditor?.plainText).toBe("production\nwest")
|
||||||
|
expect(replies).toEqual([])
|
||||||
|
} finally {
|
||||||
|
app.cleanup()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function expectPaletteList(list: BoxRenderable, selectedIndex: number) {
|
function expectPaletteList(list: BoxRenderable, selectedIndex: number) {
|
||||||
expect(list.backgroundColor.toInts()).toEqual((RUN_THEME_FALLBACK.footer.shade as RGBA).toInts())
|
expect(list.backgroundColor.toInts()).toEqual((RUN_THEME_FALLBACK.footer.shade as RGBA).toInts())
|
||||||
expect((list.getChildren()[selectedIndex] as BoxRenderable).backgroundColor.toInts()).toEqual(
|
expect((list.getChildren()[selectedIndex] as BoxRenderable).backgroundColor.toInts()).toEqual(
|
||||||
|
|||||||
@@ -20,25 +20,17 @@ export function has(content: Uint8Array) {
|
|||||||
return content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf
|
return content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decodeBytes(content: Uint8Array) {
|
|
||||||
return split(decode(content))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function syncBytes(content: Uint8Array, bom: boolean) {
|
|
||||||
const decoded = decode(content)
|
|
||||||
const current = split(decoded)
|
|
||||||
const canonical = join(current.text, bom)
|
|
||||||
return { text: current.text, bytes: decoded === canonical ? undefined : new TextEncoder().encode(canonical) }
|
|
||||||
}
|
|
||||||
|
|
||||||
export const readFile = Effect.fn("Bom.readFile")(function* (fs: FSUtil.Interface, filepath: string) {
|
export const readFile = Effect.fn("Bom.readFile")(function* (fs: FSUtil.Interface, filepath: string) {
|
||||||
return decodeBytes(yield* fs.readFile(filepath))
|
return split(decode(yield* fs.readFile(filepath)))
|
||||||
})
|
})
|
||||||
|
|
||||||
export const syncFile = Effect.fn("Bom.syncFile")(function* (fs: FSUtil.Interface, filepath: string, bom: boolean) {
|
export const syncFile = Effect.fn("Bom.syncFile")(function* (fs: FSUtil.Interface, filepath: string, bom: boolean) {
|
||||||
const synced = syncBytes(yield* fs.readFile(filepath), bom)
|
const decoded = decode(yield* fs.readFile(filepath))
|
||||||
if (synced.bytes) yield* fs.writeWithDirs(filepath, synced.bytes)
|
const current = split(decoded)
|
||||||
return synced.text
|
const canonical = join(current.text, bom)
|
||||||
|
if (decoded === canonical) return current.text
|
||||||
|
yield* fs.writeWithDirs(filepath, canonical)
|
||||||
|
return current.text
|
||||||
})
|
})
|
||||||
|
|
||||||
function decode(content: Uint8Array) {
|
function decode(content: Uint8Array) {
|
||||||
|
|||||||
Reference in New Issue
Block a user