Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton 60124ac57d feat(tui): paste into custom form answers 2026-08-07 10:32:08 -04:00
81 changed files with 1596 additions and 2906 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

-1
View File
@@ -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",
+5 -37
View File
@@ -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,
} }
+56 -117
View File
@@ -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
} }
+3 -32
View File
@@ -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))
+12 -40
View File
@@ -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 }
+2 -23
View File
@@ -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,
} }
+24 -72
View File
@@ -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":
-3
View File
@@ -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>
+2 -7
View File
@@ -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)),
+2 -2
View File
@@ -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,
}), }),
], ],
} }
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+5 -14
View File
@@ -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 => {
-172
View File
@@ -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,
}) })
}), }),
) )
-37
View File
@@ -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" }),
+5 -29
View File
@@ -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()
}), }),
) )
+13 -12
View File
@@ -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,
-1
View File
@@ -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",
+4 -6
View File
@@ -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 = `
+2 -19
View File
@@ -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"
-3
View File
@@ -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"
-103
View File
@@ -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"
+1 -1
View File
@@ -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 }))
+12 -48
View File
@@ -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)
} }
} }
-2
View File
@@ -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,
-3
View File
@@ -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),
+1 -5
View File
@@ -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,
+5 -7
View File
@@ -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] })
+1 -16
View File
@@ -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
View File
@@ -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
View File
@@ -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],
}) })
+2 -1
View File
@@ -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 }),
}, },
} }
+17 -20
View File
@@ -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"})`,
+12 -10
View File
@@ -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,
}) })
+94 -90
View File
@@ -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)
}), }),
+42 -41
View File
@@ -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 -8
View File
@@ -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(
+10 -8
View File
@@ -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}`))
}), }),
) )
+8 -10
View File
@@ -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) })),
+191 -152
View File
@@ -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 -50
View File
@@ -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",
() => () =>
+12 -63
View File
@@ -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)))
} }
+126 -93
View File
@@ -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
+17 -218
View File
@@ -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" },
])
}), }),
), ),
), ),
+33 -68
View 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()),
+42 -65
View File
@@ -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")
+51 -161
View File
@@ -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",
+48 -23
View 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 },
},
])
}), }),
) )
+19 -6
View File
@@ -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("\\", "/"),
])
}), }),
), ),
) )
+1 -2
View File
@@ -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:")
}) })
}, },
+11 -14
View File
@@ -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],
+2 -20
View File
@@ -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()))
+10 -2
View File
@@ -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()) {
+15 -2
View File
@@ -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
+3 -6
View File
@@ -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 }]),
),
)
}
+10 -12
View File
@@ -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]()
}, },
} }
} }
+56 -2
View File
@@ -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()
}
})
+41 -24
View File
@@ -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(
+7 -15
View File
@@ -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) {