Compare commits

..

18 Commits

Author SHA1 Message Date
Aiden Cline ab03b05c00 fix(ai): replay function call item ids 2026-08-07 22:30:52 -05:00
Aiden Cline 0f67def34d fix(ai): keep response ids provider-owned 2026-08-07 17:15:00 -05:00
Aiden Cline 0657dcbad2 fix(ai): stop generating response item ids 2026-08-07 14:26:50 -05:00
Aiden Cline 82afcfd4e0 fix(ai): generate uuidv7 item ids 2026-08-07 14:04:15 -05:00
Aiden Cline a1cbcc8641 fix(ai): preserve responses item ids 2026-08-07 12:57:48 -05:00
opencode-agent[bot] db3b54a30d fix(ai): preserve Gemini agent loop parity (#41109)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-07 12:27:22 -05:00
Kit Langton b4f769f695 fix(core): normalize tool images once (#41097) 2026-08-07 13:02:13 -04:00
Kit Langton e5ef00b8b8 fix(core): bound project filesystem watches (#41096) 2026-08-07 12:38:12 -04:00
Kit Langton 917d6449e3 refactor(core): move exec tools onto environment (#41095) 2026-08-07 11:59:59 -04:00
Kit Langton db31c42e39 refactor(core): move mutation path onto environment (#41091) 2026-08-07 11:42:37 -04:00
Kit Langton c79ced174e fix(core): reload changed skill sources (#40954) 2026-08-07 15:35:23 +00:00
Kit Langton 8ba8af1dd9 refactor(core): move read tool onto environment (#41084) 2026-08-07 11:22:50 -04:00
opencode-agent[bot] 6e82f5d3b9 fix(core): connect custom providers (#40761)
Co-authored-by: Dax Raad <826656+thdxr@users.noreply.github.com>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-07 10:14:14 -05:00
opencode-agent[bot] 48d1a6e5b9 fix(tui): mute expanded thinking content (#41082)
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-08-07 11:12:51 -04:00
opencode-agent[bot] bc47030d4d fix(core): serialize edit and patch transactions (#40641)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-08-07 10:07:48 -05:00
Kit Langton e6d20440f9 test: fix cross-platform unit failures (#41075) 2026-08-07 10:53:39 -04:00
Kit Langton c9cbd2b1f4 feat(core): add local environment driver (#41076) 2026-08-07 10:53:35 -04:00
Aiden Cline 292dfa3036 feat(core): add restart continuation message (#40989) 2026-08-07 09:52:51 -05:00
81 changed files with 2902 additions and 1592 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

+1
View File
@@ -395,6 +395,7 @@
"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",
+37 -5
View File
@@ -25,8 +25,20 @@ 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
@@ -145,6 +157,9 @@ 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),
}) })
@@ -202,11 +217,13 @@ 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 objects, derive `nullable: true` from `type: [..., "null"]`, // drop empty root parameter schemas while preserving nested empty objects,
// coerce `const` to `[const]` enum, recurse properties/items, propagate // expand type arrays into `anyOf`, derive `nullable: true` from null members,
// 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,
// properties, items, allOf, anyOf, oneOf, minLength). Anything outside the // nullable, enum, properties, items, allOf, anyOf, oneOf, minLength).
// allowlist (e.g. `additionalProperties`, `$ref`) is silently dropped. // Anything outside the allowlist (e.g. `additionalProperties`, `$ref`) is
// 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
@@ -282,6 +299,8 @@ 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"])
@@ -294,7 +313,17 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
continue continue
} }
if (part.type === "tool-call") { if (part.type === "tool-call") {
parts.push(lowerToolCall(part)) const lowered = 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
} }
} }
@@ -388,6 +417,9 @@ 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,
} }
+125 -64
View File
@@ -90,10 +90,15 @@ const OpenResponsesFunctionCallOutput = Schema.Union([
]) ])
export const InputItem = Schema.Union([ export const InputItem = Schema.Union([
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }), Schema.Struct({ role: Schema.tag("system"), id: Schema.optionalKey(Schema.String), content: Schema.String }),
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }), Schema.Struct({
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),
}), }),
@@ -101,19 +106,23 @@ 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 }>
@@ -128,7 +137,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"> type OpenResponsesReasoningReplay = Omit<OpenResponsesReasoningInput, "id"> & { id?: string }
export const Tool = Schema.Struct({ export const Tool = Schema.Struct({
type: Schema.tag("function"), type: Schema.tag("function"),
@@ -254,6 +263,11 @@ 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 }
@@ -310,36 +324,48 @@ 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 lowerToolCall = (part: ToolCallPart): OpenResponsesInputItem => ({ const metadataItemID = (
type: "function_call", part: { readonly itemId?: string; readonly providerMetadata?: ProviderMetadata },
call_id: part.id, providerMetadataKey: string,
name: part.name, ) => {
arguments: ProviderShared.encodeJson(part.input), if (part.itemId) return part.itemId
})
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
const metadata = part.providerMetadata?.[providerMetadataKey]
if (!ProviderShared.isRecord(metadata) || typeof metadata.itemId !== "string" || metadata.itemId.length === 0)
return undefined
const encryptedContent =
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
? metadata.reasoningEncryptedContent
: undefined
return {
type: "reasoning",
id: metadata.itemId,
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
encrypted_content: encryptedContent,
}
}
const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) => {
const metadata = part.providerMetadata?.[providerMetadataKey] const metadata = part.providerMetadata?.[providerMetadataKey]
return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0 return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
? metadata.itemId ? metadata.itemId
: undefined : 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 metadata = part.providerMetadata?.[providerMetadataKey]
const itemId = metadataItemID(part, providerMetadataKey)
if (!itemId) return undefined
const encryptedContent =
ProviderShared.isRecord(metadata) &&
(typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null)
? metadata.reasoningEncryptedContent
: undefined
return {
type: "reasoning",
id: itemId,
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
encrypted_content: encryptedContent,
}
}
const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) =>
metadataItemID(part, providerMetadataKey)
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* ( const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
part: MediaPart, part: MediaPart,
request: LLMRequest, request: LLMRequest,
@@ -397,17 +423,18 @@ 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 = OpenResponsesOptions.resolve(request).store const store = options.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") if (previous && "role" in previous && previous.role === "user" && Array.isArray(previous.content))
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 }],
@@ -427,24 +454,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 reasoningReferences = new Set<string>() const hostedToolItems = 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<Array<{ phase: MessagePhase | null | undefined; parts: TextPart[] }>>( const groups = content.reduce<
(groups, part) => { Array<{ phase: MessagePhase | null | undefined; itemId: string | undefined; parts: TextPart[] }>
const metadata = part.providerMetadata?.[providerMetadataKey] >((groups, part) => {
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined const metadata = part.providerMetadata?.[providerMetadataKey]
const group = groups.at(-1) const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined
if (group && group.phase === phase) group.parts.push(part) const itemId = metadataItemID(part, providerMetadataKey)
else groups.push({ phase, parts: [part] }) const group = groups.at(-1)
return groups if (group && group.phase === phase && group.itemId === itemId) group.parts.push(part)
}, 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 }),
})), })),
@@ -460,11 +487,6 @@ 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)
@@ -474,6 +496,7 @@ 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,
} }
@@ -484,22 +507,24 @@ 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)) input.push(lowerToolCall(part, providerMetadataKey))
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)
if (store !== false && itemID && !hostedToolReferences.has(itemID)) const providerItem = extension.lowerProviderItem?.(part, providerMetadataKey, store)
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 (store === false && part.result.type === "content") { if (!providerItem && 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) hostedToolReferences.add(itemID) if (itemID) hostedToolItems.add(itemID)
continue continue
} }
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [ return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
@@ -641,9 +666,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 = phase === undefined ? undefined : providerMetadata(state, { phase }) const metadata = providerMetadata(state, { itemId: id, ...(phase === undefined ? {} : { phase }) })
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata) const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata, id)
return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events] return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta, metadata, id) }, events]
} }
const onOutputTextDone = (state: ParserState, event: Event, id: string): StepResult => { const onOutputTextDone = (state: ParserState, event: Event, id: string): StepResult => {
@@ -652,7 +677,13 @@ 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 [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events] return [
{
...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 => {
@@ -663,7 +694,14 @@ export const onReasoningDelta = (state: ParserState, event: Event, itemID: strin
return [ return [
{ {
...state, ...state,
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta), lifecycle: Lifecycle.reasoningDelta(
state.lifecycle,
events,
id,
event.delta,
providerMetadata(state, { itemId: itemID }),
itemID,
),
}, },
events, events,
] ]
@@ -705,7 +743,13 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
return [ return [
{ {
...state, ...state,
lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(state, item)), lifecycle: Lifecycle.reasoningStart(
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" } },
@@ -724,6 +768,7 @@ 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,
@@ -731,7 +776,12 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
}, },
[ [
...events, ...events,
LLMEvent.toolInputStart({ id: item.call_id ?? item.id, name: item.name ?? "", providerMetadata: metadata }), LLMEvent.toolInputStart({
id: item.call_id ?? item.id,
itemId: item.id,
name: item.name ?? "",
providerMetadata: metadata,
}),
], ],
] ]
} }
@@ -750,6 +800,7 @@ 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,
@@ -770,6 +821,7 @@ 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,
) )
@@ -781,6 +833,7 @@ 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,
@@ -816,6 +869,7 @@ 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: {
@@ -870,7 +924,8 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
state.lifecycle, state.lifecycle,
events, events,
item.id, item.id,
phase === undefined ? undefined : providerMetadata(state, { phase }), providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) }),
item.id,
), ),
messageItems, messageItems,
messagePhases, messagePhases,
@@ -881,9 +936,15 @@ 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, { id: item.call_id, name: item.name }) : ToolStream.start(state.tools, item.id, {
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)
@@ -913,7 +974,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), (lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata, item.id),
state.lifecycle, state.lifecycle,
) )
const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems
@@ -921,12 +982,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, providerMetadata: metadata })) events.push(LLMEvent.reasoningStart({ id: item.id, itemId: item.id, providerMetadata: metadata }))
events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata })) events.push(LLMEvent.reasoningEnd({ id: item.id, itemId: 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) }, { ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata, item.id) },
events, events,
] satisfies StepResult ] satisfies StepResult
} }
+32 -3
View File
@@ -38,10 +38,14 @@ 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 = {
@@ -80,6 +84,25 @@ 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) => {
@@ -195,23 +218,29 @@ const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function*
item: HostedToolItem, item: HostedToolItem,
) { ) {
const tool = HOSTED_TOOLS[item.type] const tool = HOSTED_TOOLS[item.type]
const providerMetadata = OpenResponses.providerMetadata(state, { itemId: item.id }) const callMetadata = 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, providerMetadata: callMetadata,
}), }),
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, providerMetadata: resultMetadata,
}), }),
) )
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
@@ -61,37 +61,57 @@ 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): Record<string, unknown> | undefined => { const projectNode = (schema: unknown, nested = false): Record<string, unknown> | undefined => {
if (!isRecord(schema)) return undefined if (!isRecord(schema)) return undefined
if (emptyObjectSchema(schema)) return undefined if (!nested && emptyObjectSchema(schema)) return undefined
return Object.fromEntries( const types = Array.isArray(schema.type) ? schema.type.filter((type) => type !== "null") : undefined
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", Array.isArray(schema.type) ? schema.type.filter((type) => type !== "null")[0] : schema.type], ["type", types ? (types.length === 0 ? "null" : undefined) : 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)])) ? Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => [key, projectNode(value, true)]))
: undefined, : undefined,
], ],
[ [
"items", "items",
Array.isArray(schema.items) Array.isArray(schema.items)
? schema.items.map(projectNode) ? schema.items.map((item) => projectNode(item, true))
: schema.items === undefined : schema.items === undefined
? undefined ? undefined
: projectNode(schema.items), : projectNode(schema.items, true),
], ],
["allOf", Array.isArray(schema.allOf) ? schema.allOf.map(projectNode) : undefined], ["allOf", Array.isArray(schema.allOf) ? schema.allOf.map((item) => projectNode(item, true)) : undefined],
["anyOf", Array.isArray(schema.anyOf) ? schema.anyOf.map(projectNode) : undefined], [
["oneOf", Array.isArray(schema.oneOf) ? schema.oneOf.map(projectNode) : undefined], "anyOf",
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))
+40 -12
View File
@@ -1,4 +1,10 @@
import { LLMEvent, type FinishReasonDetails, type ProviderMetadata, type Usage } from "../../schema" import {
LLMEvent,
type FinishReasonDetails,
type ProviderMetadata,
type ResponseItemID,
type Usage,
} from "../../schema"
export interface State { export interface State {
readonly stepStarted: boolean readonly stepStarted: boolean
@@ -14,16 +20,29 @@ export const stepStart = (state: State, events: LLMEvent[]): State => {
return { ...state, stepStarted: true } return { ...state, stepStarted: true }
} }
export const textStart = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => { export const textStart = (
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, providerMetadata })) events.push(LLMEvent.textStart({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
return { ...stepped, text: new Set([...stepped.text, id]) } return { ...stepped, text: new Set([...stepped.text, id]) }
} }
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => { export const textDelta = (
const started = textStart(state, events, id) state: State,
events.push(LLMEvent.textDelta({ id, text })) events: LLMEvent[],
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
} }
@@ -32,10 +51,11 @@ 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, providerMetadata })) events.push(LLMEvent.reasoningStart({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) } return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) }
} }
@@ -45,9 +65,10 @@ 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) const started = reasoningStart(state, events, id, providerMetadata, itemId)
events.push(LLMEvent.reasoningDelta({ id, text, providerMetadata })) events.push(LLMEvent.reasoningDelta({ id, ...(itemId === undefined ? {} : { itemId }), text, providerMetadata }))
return started return started
} }
@@ -56,19 +77,26 @@ 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, providerMetadata })) events.push(LLMEvent.reasoningEnd({ id, ...(itemId === undefined ? {} : { itemId }), 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 = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => { export const textEnd = (
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, providerMetadata })) events.push(LLMEvent.textEnd({ id, ...(itemId === undefined ? {} : { itemId }), 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 }
+23 -2
View File
@@ -1,5 +1,12 @@
import { Effect } from "effect" import { Effect } from "effect"
import { AIError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema" import {
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
@@ -10,6 +17,7 @@ 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
} }
@@ -52,6 +60,7 @@ 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,
@@ -60,6 +69,7 @@ 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,
}) })
@@ -70,6 +80,7 @@ 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,
@@ -82,6 +93,7 @@ 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,
}), }),
@@ -93,7 +105,15 @@ 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>(
@@ -148,6 +168,7 @@ 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,
} }
+72 -24
View File
@@ -1,5 +1,5 @@
import { Schema } from "effect" import { Schema } from "effect"
import { ContentBlockID, FinishReason, ProviderMetadata, ToolCallID } from "./ids" import { ContentBlockID, FinishReason, ProviderMetadata, ResponseItemID, 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,6 +84,7 @@ 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>
@@ -92,6 +93,7 @@ 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>
@@ -99,6 +101,7 @@ 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>
@@ -106,6 +109,7 @@ 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>
@@ -114,6 +118,7 @@ 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>
@@ -121,6 +126,7 @@ 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>
@@ -129,6 +135,7 @@ 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" })
@@ -137,6 +144,7 @@ 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" })
@@ -146,6 +154,7 @@ 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>
@@ -154,6 +163,7 @@ 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" })
@@ -162,6 +172,7 @@ 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),
@@ -172,6 +183,7 @@ 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),
@@ -183,6 +195,7 @@ 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()),
@@ -334,12 +347,14 @@ 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
} }
@@ -385,11 +400,27 @@ const appendEvent = (state: ResponseState, event: LLMEvent): ResponseState => {
} }
} }
const textContent = (text: string, providerMetadata: ProviderMetadata | undefined): ContentPart => const textContent = (
providerMetadata === undefined ? { type: "text", text } : { type: "text", text, providerMetadata } text: string,
itemId: ResponseItemID | undefined,
providerMetadata: ProviderMetadata | undefined,
): ContentPart => ({
type: "text",
text,
...(itemId === undefined ? {} : { itemId }),
...(providerMetadata === undefined ? {} : { providerMetadata }),
})
const reasoningContent = (text: string, providerMetadata: ProviderMetadata | undefined): ContentPart => const reasoningContent = (
providerMetadata === undefined ? { type: "reasoning", text } : { type: "reasoning", text, providerMetadata } text: string,
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,
@@ -404,26 +435,32 @@ 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 = (state: ResponseState, id: string, providerMetadata?: ProviderMetadata): ResponseState => { const ensureText = (
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("", providerMetadata)), ...appendContent(state, textContent("", itemId, providerMetadata)),
textParts: { textParts: {
...state.textParts, ...state.textParts,
[id]: { contentIndex: state.message.content.length, text: "", providerMetadata }, [id]: { contentIndex: state.message.content.length, text: "", itemId, providerMetadata },
}, },
} }
} }
const reduceTextDelta = (state: ResponseState, event: TextDelta): ResponseState => { const reduceTextDelta = (state: ResponseState, event: TextDelta): ResponseState => {
const started = ensureText(state, event.id, event.providerMetadata) const started = ensureText(state, event.id, event.itemId, 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, providerMetadata)), ...replaceContent(started, current.contentIndex, textContent(text, itemId, providerMetadata)),
textParts: { ...started.textParts, [event.id]: { ...current, text, providerMetadata } }, textParts: { ...started.textParts, [event.id]: { ...current, text, itemId, providerMetadata } },
} }
} }
@@ -431,32 +468,39 @@ 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, providerMetadata)), ...replaceContent(state, current.contentIndex, textContent(current.text, itemId, providerMetadata)),
textParts: { ...state.textParts, [event.id]: { ...current, providerMetadata } }, textParts: { ...state.textParts, [event.id]: { ...current, itemId, providerMetadata } },
} }
} }
const ensureReasoning = (state: ResponseState, id: string, providerMetadata?: ProviderMetadata): ResponseState => { const ensureReasoning = (
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("", providerMetadata)), ...appendContent(state, reasoningContent("", itemId, providerMetadata)),
reasoningParts: { reasoningParts: {
...state.reasoningParts, ...state.reasoningParts,
[id]: { contentIndex: state.message.content.length, text: "", providerMetadata }, [id]: { contentIndex: state.message.content.length, text: "", itemId, providerMetadata },
}, },
} }
} }
const reduceReasoningDelta = (state: ResponseState, event: ReasoningDelta): ResponseState => { const reduceReasoningDelta = (state: ResponseState, event: ReasoningDelta): ResponseState => {
const started = ensureReasoning(state, event.id, event.providerMetadata) const started = ensureReasoning(state, event.id, event.itemId, 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, providerMetadata)), ...replaceContent(started, current.contentIndex, reasoningContent(text, itemId, providerMetadata)),
reasoningParts: { ...started.reasoningParts, [event.id]: { ...current, text, providerMetadata } }, reasoningParts: { ...started.reasoningParts, [event.id]: { ...current, text, itemId, providerMetadata } },
} }
} }
@@ -464,9 +508,10 @@ 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, providerMetadata)), ...replaceContent(state, current.contentIndex, reasoningContent(current.text, itemId, providerMetadata)),
reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, providerMetadata } }, reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, itemId, providerMetadata } },
} }
} }
@@ -474,7 +519,7 @@ const reduceToolInputStart = (state: ResponseState, event: ToolInputStart): Resp
...state, ...state,
toolInputs: { toolInputs: {
...state.toolInputs, ...state.toolInputs,
[event.id]: { name: event.name, text: "", providerMetadata: event.providerMetadata }, [event.id]: { name: event.name, text: "", itemId: event.itemId, providerMetadata: event.providerMetadata },
}, },
}) })
@@ -495,6 +540,7 @@ 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,
}, },
}, },
@@ -504,6 +550,7 @@ 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 }),
@@ -513,6 +560,7 @@ 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 }),
@@ -528,13 +576,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.providerMetadata) return ensureText(next, event.id, event.itemId, 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.providerMetadata) return ensureReasoning(next, event.id, event.itemId, 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,6 +21,9 @@ 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>
+7 -2
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 } from "./ids" import { JsonSchema, MessageRole, ProviderMetadata, ResponseItemID } 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,6 +25,7 @@ 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),
@@ -121,6 +122,7 @@ 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),
@@ -138,6 +140,7 @@ 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),
@@ -154,6 +157,7 @@ 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,
@@ -168,6 +172,7 @@ 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)),
@@ -181,7 +186,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(Schema.String), id: Schema.optional(ResponseItemID),
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,
providerMetadata: call.providerMetadata, ...(call.providerMetadata === undefined ? {} : { 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,
providerMetadata: call.providerMetadata, ...(call.providerMetadata === undefined ? {} : { 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
+14 -5
View File
@@ -8,7 +8,6 @@ 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"
@@ -61,9 +60,10 @@ 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: call.providerMetadata, providerMetadata: dispatched.events.find(LLMEvent.is.toolResult)?.providerMetadata,
}), }),
), ),
], ],
@@ -89,9 +89,15 @@ 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) appendText(assistantContent, event.type === "text-delta" ? "text" : "reasoning", event.text, event.itemId)
} else if (event.type === "text-end" || event.type === "reasoning-end") { } else if (event.type === "text-end" || event.type === "reasoning-end") {
appendText(assistantContent, event.type === "text-end" ? "text" : "reasoning", "", event.providerMetadata) appendText(
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)
@@ -99,6 +105,7 @@ 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,
@@ -118,6 +125,7 @@ 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)
@@ -125,11 +133,12 @@ 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, providerMetadata }) content.push({ type, text, itemId, 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,6 +16,13 @@ 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,
@@ -86,6 +93,39 @@ 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(
@@ -350,6 +390,100 @@ 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(
@@ -536,6 +670,44 @@ 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).toEqual({ expect(prepared.body).toMatchObject({
model: "example-model", model: "example-model",
input: [ input: [
{ role: "system", content: "You are concise." }, { role: "system", content: "You are concise." },
@@ -53,6 +53,8 @@ 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,6 +69,9 @@ 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,
}) })
}), }),
) )
@@ -329,7 +332,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/v1/", baseURL: "https://opencode-test.openai.azure.com/openai/",
apiKey: "azure-key", apiKey: "azure-key",
headers: { authorization: "Bearer stale" }, headers: { authorization: "Bearer stale" },
}).responses("gpt-4.1-mini"), }).responses("gpt-4.1-mini"),
@@ -410,7 +413,7 @@ describe("OpenAI Responses route", () => {
}), }),
) )
expect(prepared.body).toEqual({ expect(prepared.body).toMatchObject({
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?" }] },
@@ -425,6 +428,65 @@ 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"' },
])
}), }),
) )
@@ -864,9 +926,21 @@ 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" }, { 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: "!" }, type: "text-delta",
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",
@@ -923,17 +997,20 @@ describe("OpenAI Responses route", () => {
{ {
type: "text", type: "text",
text: "Checking.", text: "Checking.",
providerMetadata: { openai: { phase: "commentary" } }, itemId: "msg_commentary",
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
}, },
{ {
type: "text", type: "text",
text: "Finished.", text: "Finished.",
providerMetadata: { openai: { phase: "final_answer" } }, itemId: "msg_final",
providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
}, },
{ {
type: "text", type: "text",
text: "Unclassified.", text: "Unclassified.",
providerMetadata: { openai: { phase: null } }, itemId: "msg_null",
providerMetadata: { openai: { itemId: "msg_null", phase: null } },
}, },
]) ])
@@ -941,16 +1018,19 @@ 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,
}, },
@@ -1043,12 +1123,24 @@ 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" }, { type: "text-start", id: "msg_1", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-delta", id: "msg_1", text: "First" }, {
{ type: "text-end", id: "msg_1" }, type: "text-delta",
{ type: "text-start", id: "msg_2" }, id: "msg_1",
{ type: "text-delta", id: "msg_2", text: "Second" }, itemId: "msg_1",
{ type: "text-end", id: "msg_2" }, text: "First",
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" } } },
]) ])
}), }),
) )
@@ -1068,9 +1160,15 @@ 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" }, { type: "reasoning-start", id: "rs_1", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
{ type: "reasoning-delta", id: "rs_1", text: "thinking" }, {
{ type: "text-start", id: "msg_1" }, type: "reasoning-delta",
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" },
@@ -1079,8 +1177,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" }, { type: "reasoning", text: "thinking", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
{ type: "text", text: "Hello" }, { type: "text", text: "Hello", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
]) ])
}), }),
) )
@@ -1111,6 +1209,7 @@ 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" } },
}), }),
) )
@@ -1151,19 +1250,34 @@ 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-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } }, type: "reasoning-delta",
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 } },
@@ -1201,8 +1315,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", providerMetadata: { openai: { itemId: "rs_1" } } }, { type: "reasoning-end", id: "rs_1:0", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
{ type: "reasoning-end", id: "rs_1:1", providerMetadata: { openai: { itemId: "rs_1" } } }, { type: "reasoning-end", id: "rs_1:1", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
]) ])
}), }),
) )
@@ -1250,7 +1364,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]).not.toHaveProperty("id") expect(body.input[1]).toHaveProperty("id", "rs_1")
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." },
@@ -1297,6 +1411,7 @@ 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." }],
}, },
@@ -1305,7 +1420,7 @@ describe("OpenAI Responses route", () => {
}), }),
) )
it.effect("references stored reasoning items by id", () => it.effect("replays complete stored reasoning items with their id", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
LLM.request({ LLM.request({
@@ -1323,7 +1438,14 @@ describe("OpenAI Responses route", () => {
}), }),
) )
expect(prepared.body.input).toEqual([{ type: "item_reference", id: "rs_1" }]) expect(prepared.body.input).toEqual([
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
encrypted_content: undefined,
},
])
}), }),
) )
@@ -1432,6 +1554,7 @@ 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" },
@@ -1511,6 +1634,7 @@ 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 } },
@@ -1521,30 +1645,35 @@ 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,
@@ -1564,6 +1693,17 @@ 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" } },
},
])
}), }),
) )
@@ -1596,6 +1736,7 @@ 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',
}) })
@@ -1652,6 +1793,7 @@ 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,
@@ -1660,11 +1802,35 @@ 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,
},
]) ])
}), }),
) )
@@ -1742,6 +1908,7 @@ 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,
@@ -1751,10 +1918,12 @@ 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" } }, providerMetadata: { openai: { itemId: "ci_1", item } },
output: undefined,
}) })
}), }),
) )
+37
View File
@@ -49,6 +49,43 @@ 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" }),
+29 -5
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).toEqual([ expect(dispatched.events).toMatchObject([
LLMEvent.toolResult({ LLMEvent.toolResult({
id: "call_projected", id: "call_projected",
name: "projected", name: "projected",
@@ -180,6 +180,7 @@ 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()
}), }),
) )
@@ -197,7 +198,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).toEqual([ expect(dispatched.events).toMatchObject([
LLMEvent.toolResult({ LLMEvent.toolResult({
id: "call_1", id: "call_1",
name: "tool", name: "tool",
@@ -206,12 +207,13 @@ 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", name: "missing", input: {}, providerMetadata }), LLMEvent.toolCall({ id: "call_2", itemId: "fc_failed", name: "missing", input: {}, providerMetadata }),
) )
expect(failed.events).toEqual([ expect(failed.events).toMatchObject([
LLMEvent.toolError({ LLMEvent.toolError({
id: "call_2", id: "call_2",
name: "missing", name: "missing",
@@ -225,6 +227,27 @@ 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()
}), }),
) )
@@ -437,7 +460,7 @@ describe("LLMClient tools", () => {
) )
expect(dispatched.result).toEqual(callerOwned) expect(dispatched.result).toEqual(callerOwned)
expect(dispatched.events).toEqual([ expect(dispatched.events).toMatchObject([
LLMEvent.toolResult({ LLMEvent.toolResult({
id: "call_1", id: "call_1",
name: "eventful", name: "eventful",
@@ -445,6 +468,7 @@ describe("LLMClient tools", () => {
output: { structured: { ok: true }, content: [] }, output: { structured: { ok: true }, content: [] },
}), }),
]) ])
expect(dispatched.events[0]?.itemId).toBeUndefined()
}), }),
) )
+12 -13
View File
@@ -688,6 +688,8 @@ 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()
@@ -701,6 +703,16 @@ 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
@@ -947,19 +959,6 @@ 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,6 +118,7 @@
"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",
+6 -4
View File
@@ -13,12 +13,14 @@ 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 (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue if (!integrations.get(integrationID)) {
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
}) })
@@ -0,0 +1,26 @@
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()} ${loadMetadata("-L")}
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 "$1" -mindepth 1 -maxdepth 1 -printf '%y\\0%f\\0' find -H "$1" -mindepth 1 -maxdepth 1 -printf '%y\\0%f\\0'
` `
const moveScript = ` const moveScript = `
+19 -2
View File
@@ -30,7 +30,8 @@ export class Failed extends Schema.TaggedErrorClass<Failed>()("Environment.Faile
export interface FilesImpl { export interface FilesImpl {
/** /**
* Reads a file, following a final symlink so `info` describes the target whose bytes are returned. * Content operations (`read`, `list`) follow final symlinks; metadata operations (`stat` and entry
* 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.
*/ */
@@ -41,7 +42,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>
/** Lists a directory entry without following a final symlink; intermediate symlinks are traversed. */ /** Follows a final symlink to the listed directory while preserving each returned entry's own type. */
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>
@@ -50,4 +51,20 @@ 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,10 +9,13 @@ 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
@@ -0,0 +1,103 @@
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, false) ?? key(value) const target = resolveKey(value, true) ?? 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 }))
+48 -12
View File
@@ -5,6 +5,8 @@ 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
@@ -29,13 +31,36 @@ export interface WriteResult {
} }
export interface Interface { export interface Interface {
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, FSUtil.Error> /** Serialize a complete read/prepare/write mutation transaction by resolved path. */
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: (input: TextWriteInput) => Effect.Effect<WriteResult, FSUtil.Error> readonly writeTextPreservingBom: (
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
@@ -44,8 +69,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Fi
const layer = Layer.effect( const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* FSUtil.Service const environment = yield* Environment.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>) =>
@@ -61,8 +90,14 @@ 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* fs.exists(input.target.absolute) const existed = yield* environment.files.stat(input.target.absolute).pipe(
yield* fs.writeWithDirs(input.target.absolute, input.content) Effect.as(true),
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)
}), }),
), ),
@@ -72,23 +107,24 @@ 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* fs const current = yield* environment.files.read(input.target.absolute, { offset: 0, length: 3 }).pipe(
.readFile(input.target.absolute) Effect.map((result) => result.bytes),
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))) Effect.catchTag("Environment.NotFound", () => Effect.succeed(undefined)),
yield* fs.writeWithDirs( )
yield* environment.files.write(
input.target.absolute, input.target.absolute,
Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom), new TextEncoder().encode(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({ write, writeTextPreservingBom }) return Service.of({ withLock, write, writeTextPreservingBom })
}), }),
) )
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] }) export const node = makeLocationNode({ service: Service, layer, deps: [Environment.node] })
/** /**
* Deferred until the corresponding integrations exist. * Deferred until the corresponding integrations exist.
@@ -11,15 +11,6 @@ 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 {}
@@ -44,19 +35,6 @@ 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
@@ -64,10 +42,7 @@ 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 ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap( const updates = yield* watcher.subscribe({ path: path.join(vcs, "HEAD"), type: "file" })
(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,6 +8,7 @@ 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"
@@ -53,6 +54,7 @@ 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,6 +16,7 @@ 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"
@@ -70,6 +71,7 @@ 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
@@ -102,6 +104,7 @@ 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),
+5 -1
View File
@@ -14,6 +14,7 @@ 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"
@@ -282,7 +283,9 @@ const layer = Layer.effect(
}) })
const updates = Stream.merge( const updates = Stream.merge(
config.changes().pipe( config.changes().pipe(
Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path))), Stream.filterEffect((update) =>
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]),
@@ -320,6 +323,7 @@ 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,
+7 -5
View File
@@ -3,8 +3,9 @@ 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 { makeGlobalNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { AppProcess, collectStream, waitForAbort } from "@opencode-ai/util/process" import { 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"
@@ -93,7 +94,7 @@ const isInvalidPattern = (stderr: string) =>
const layer = Layer.effect( const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const process = yield* AppProcess.Service const environment = yield* Environment.Service
const binary = yield* RipgrepBinary.Service const binary = yield* RipgrepBinary.Service
const run = <A>(input: { const run = <A>(input: {
@@ -107,7 +108,8 @@ const layer = Layer.effect(
}) => { }) => {
const program = Effect.scoped( const program = Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
const handle = yield* process.spawn( // Hosted environments will resolve rg through their driver image; the spawner is the execution seam.
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(
@@ -275,4 +277,4 @@ const layer = Layer.effect(
}), }),
) )
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] }) export const node = makeLocationNode({ service: Service, layer, deps: [Environment.node, RipgrepBinary.node] })
+16 -1
View File
@@ -2,9 +2,14 @@ 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.
@@ -26,6 +31,7 @@ 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)
@@ -37,6 +43,11 @@ 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))
}), }),
@@ -47,4 +58,8 @@ export const layer = Layer.effect(
}), }),
) )
export const node = makeGlobalNode({ service: Service, layer, deps: [SessionStore.node, SessionExecution.node] }) export const node = makeGlobalNode({
service: Service,
layer,
deps: [SessionStore.node, SessionExecution.node, Bus.node],
})
+257 -258
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,285 +65,284 @@ 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) => Layer.effect( export const layer = (options?: ShellSelect.Options) =>
Service, Layer.effect(
Effect.gen(function* () { Service,
const bus = yield* Bus.Service Effect.gen(function* () {
const location = yield* Location.Service const bus = yield* Bus.Service
const config = yield* Config.Service const location = yield* Location.Service
const global = yield* Global.Service const config = yield* Config.Service
const appProcess = yield* AppProcess.Service const global = yield* Global.Service
const hooks = yield* PluginHooks.Service const environment = yield* Environment.Service
const context = yield* Effect.context() const hooks = yield* PluginHooks.Service
const runFork = Effect.runForkWith(context) const context = yield* Effect.context()
const sessions = new Map<string, Active>() const runFork = Effect.runForkWith(context)
const exitOrder: string[] = [] const sessions = new Map<string, Active>()
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()
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,
} }
sessions.clear() })
exitOrder.length = 0
}),
)
const require = Effect.fn("Shell.require")(function* (id: Shell.ID) { const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
const session = sessions.get(id) input: Shell.CreateInput,
if (!session) return yield* new NotFoundError({ id }) before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
return session ) {
}) 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 removeSession = Effect.fnUntraced(function* (id: Shell.ID) { const id = Shell.ID.ascending()
const session = sessions.get(id) const args = ShellSelect.args(invocation.shell, invocation.command)
if (!session) return const file = path.join(outputDir, `${id}.out`)
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) { const info: Info = {
yield* require(id) id,
yield* removeSession(id) status: "running",
}) command: invocation.command,
cwd: invocation.cwd,
shell: invocation.shell,
file,
metadata: input.metadata ?? {},
time: { started: Date.now() },
}
const list = Effect.fn("Shell.list")(function* () { // Spawn through the Environment and stream combined output to the file. The handle is scope-bound, so
return Array.from(sessions.values()) // the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
.filter((session) => session.info.status === "running") // end). `create` returns once `ready` resolves with the registered session.
.map((session) => session.info) const ready = Deferred.makeUnsafe<Active>()
}) runFork(
Effect.scoped(
const get = Effect.fn("Shell.get")(function* (id: Shell.ID) { Effect.gen(function* () {
return (yield* require(id)).info const handle = yield* environment.spawner.spawn(
}) ChildProcess.make(invocation.shell, args, {
cwd: invocation.cwd,
const wait = Effect.fn("Shell.wait")(function* (id: Shell.ID) { env: invocation.env,
return yield* Deferred.await((yield* require(id)).done) stdin: "ignore",
}) detached: process.platform !== "win32",
forceKillAfter: Duration.seconds(3),
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
}), }),
), )
) const session: Active = {
runFork( info: produce(info, (draft) => {
Effect.gen(function* () { draft.pid = handle.pid
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())
}), }),
) file,
size: 0,
done: Deferred.makeUnsafe<Info, NotFoundError>(),
}
sessions.set(id, session)
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) => const stream = createWriteStream(file)
Effect.gen(function* () { const outputDone = Deferred.makeUnsafe<void>()
if (session.info.status !== "running") return const pump = handle.all.pipe(
session.info = produce(session.info, (draft) => { Stream.runForEach((chunk: Uint8Array) =>
draft.status = status Effect.sync(() => {
if (exit !== undefined) draft.exit = exit stream.write(chunk)
draft.time.completed = Date.now() session.size += chunk.length
}) }),
yield* beforeWait ),
yield* Deferred.await(outputDone) )
// Resolve waiters with the terminal Info before any retention eviction, so an evicted runFork(
// session still reports success rather than the removal NotFoundError. This runs before Effect.gen(function* () {
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel yield* pump.pipe(Effect.catch(() => Effect.void))
// this very fiber (finish is invoked by the timeout fiber) before waiters are resolved. yield* Effect.promise(
yield* Deferred.succeed(session.done, session.info) () =>
yield* bus.publish(Shell.Event.Exited, { new Promise<void>((resolve) => {
id, stream.end(() => resolve())
...(exit !== undefined ? { exit } : {}), }),
status, )
}) yield* Deferred.succeed(outputDone, undefined)
exitOrder.push(id) }).pipe(Effect.catch(() => Deferred.succeed(outputDone, undefined))),
while (exitOrder.length > EXITED_LIMIT) { )
const oldest = exitOrder[0] yield* Effect.promise(
if (!oldest) break () =>
yield* removeSession(Shell.ID.make(oldest)) new Promise<void>((resolve) => {
} stream.once("open", () => resolve())
// Cancel a pending timeout once the command exits on its own. Interrupting last avoids stream.once("error", () => resolve())
// aborting finish when finish itself runs on the timeout fiber. }),
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber) )
})
session.timeout = (duration) => const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) =>
Effect.gen(function* () { Effect.gen(function* () {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber) if (session.info.status !== "running") return
session.timeoutFiber = undefined session.info = produce(session.info, (draft) => {
if (duration === 0 || session.info.status !== "running") return draft.status = status
session.timeoutFiber = runFork( if (exit !== undefined) draft.exit = exit
Effect.sleep(Duration.millis(duration)).pipe( draft.time.completed = Date.now()
Effect.flatMap(() => })
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))), 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),
), ),
Effect.catch(() => Effect.void), )
), })
)
})
yield* session.timeout(invocation.timeout) yield* session.timeout(invocation.timeout)
runFork( runFork(
handle.exitCode.pipe( handle.exitCode.pipe(
Effect.flatMap((code) => finish("exited", code)), Effect.flatMap((code) => finish("exited", code)),
Effect.catch(() => Effect.void), Effect.catch(() => Effect.void),
), ),
) )
yield* bus.publish(Shell.Event.Created, { info }) yield* bus.publish(Shell.Event.Created, { info })
yield* Deferred.succeed(ready, session) yield* Deferred.succeed(ready, session)
// Hold the handle's scope open until the command terminates; closing it earlier would // Hold the handle's scope open until the command terminates; closing it earlier would
// release (kill) the process before its exit is observed. // release (kill) the process before its exit is observed.
yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void)) yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void))
}), }),
).pipe(Effect.catch(() => Effect.void)), ).pipe(Effect.catch(() => Effect.void)),
) )
const session = yield* Deferred.await(ready) const session = yield* Deferred.await(ready)
return session.info return session.info
}) })
return Service.of({ name, create, list, get, wait, timeout, output, remove }) 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, AppProcess.node, PluginHooks.node], deps: [Bus.node, Location.node, Config.node, Global.node, Environment.node, PluginHooks.node],
}) })
} }
+105 -34
View File
@@ -2,8 +2,7 @@ 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, Layer, Schema, Stream, Types } from "effect" import { Context, Effect, FiberMap, Layer, PubSub, Schema, Semaphore, 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"
@@ -13,6 +12,7 @@ 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,6 +81,82 @@ 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",
@@ -92,7 +168,10 @@ const layer = Layer.effect(
}, },
list: () => draft.sources as Source[], list: () => draft.sources as Source[],
}), }),
finalize: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid), finalize: () =>
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) {
@@ -104,14 +183,22 @@ const layer = Layer.effect(
directories: [], directories: [],
skills: [source.skill.id], skills: [source.skill.id],
}) })
return { skills: [source.skill], directories: [] } return { skills: [source.skill], paths: [] }
} }
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)
@@ -139,38 +226,22 @@ const layer = Layer.effect(
directories, directories,
skills: skills.map((skill) => skill.id), skills: skills.map((skill) => skill.id),
}) })
return { skills, directories } return { skills, paths }
}) })
const cache = new Map<string, { skills: Info[]; directories: readonly string[] }>()
const invalidate = Effect.fn("Skill.invalidateFromWatcher")(function* (file: string) {
const invalidated = Array.from(cache.entries()).filter(([, loaded]) =>
loaded.directories.some((directory) => FSUtil.contains(directory, file)),
)
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 list = Effect.fn("Skill.list")(function* () {
const skills = new Map<ID, Info>() return yield* lock.withPermit(
for (const source of state.get().sources) { Effect.gen(function* () {
const key = Source.key(source) const skills = new Map<ID, Info>()
const loaded = cache.get(key) ?? (yield* load(source)) for (const source of state.get().sources) {
cache.set(key, loaded) const key = Source.key(source)
for (const skill of loaded.skills) skills.set(skill.id, skill) const loaded = cache.get(key) ?? (yield* load(source))
} cache.set(key, loaded)
return Array.from(skills.values()) for (const skill of loaded.skills) skills.set(skill.id, skill)
}
return Array.from(skills.values())
}),
)
}) })
return Service.of({ return Service.of({
@@ -187,5 +258,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], deps: [SkillDiscovery.node, FSUtil.node, Bus.node, Watcher.node],
}) })
+1 -2
View File
@@ -118,13 +118,12 @@ 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: content.length > 0 ? content : execution.value.content, content: execution.value.content,
...(execution.value.metadata === undefined ? {} : { metadata: execution.value.metadata }), ...(execution.value.metadata === undefined ? {} : { metadata: execution.value.metadata }),
}, },
} }
+20 -17
View File
@@ -11,9 +11,11 @@ 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 { FSUtil } from "@opencode-ai/util/fs-util" import { Location } from "../../location"
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"
@@ -109,9 +111,10 @@ 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 files = yield* FileMutation.Service const fileMutation = yield* FileMutation.Service
const environment = yield* Environment.Service
const formatter = yield* Formatter.Service const formatter = yield* Formatter.Service
const fs = yield* FSUtil.Service const location = yield* Location.Service
const permission = yield* Permission.Service const permission = yield* Permission.Service
yield* ctx.tool yield* ctx.tool
@@ -152,17 +155,16 @@ export const Plugin = {
}) })
} }
const info = yield* fs const original = yield* FileMutation.readText(environment.files, target.absolute).pipe(
.stat(target.absolute) Effect.catchTag("Environment.NotFound", () =>
.pipe( Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
Effect.catchReason("PlatformError", "NotFound", () => ),
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })), Effect.catchTag("Environment.WrongKind", (error) =>
), error.actual === "directory"
) ? Effect.fail(new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` }))
if (info.type === "Directory") { : Effect.fail(new ToolFailure({ message: `Unable to edit ${input.path}`, error })),
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)
@@ -204,19 +206,20 @@ export const Plugin = {
}) })
} }
const replacementBom = replaced.startsWith("\uFEFF") const replacementBom = replaced.startsWith("\uFEFF")
const result = yield* files.write({ const result = yield* fileMutation.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* Bom.syncFile(fs, target.absolute, bom) ? yield* FileMutation.syncTextBom(environment.files, target.absolute, bom)
: (yield* Bom.readFile(fs, target.absolute)).text : (yield* FileMutation.readText(environment.files, 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"})`,
+10 -12
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 fs = yield* FSUtil.Service const environment = yield* Environment.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,22 +82,20 @@ export const Plugin = {
agent: context.agent, agent: context.agent,
source, source,
}) })
const info = yield* fs const type = yield* Environment.typeFollowing(environment.files, target.absolute).pipe(
.stat(target.absolute) Effect.catchTag("Environment.NotFound", () =>
.pipe( Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
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 = path.resolve(location.directory, searchPath ?? ".") const root = target.absolute
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: target.absolute, cwd: root,
pattern: input.pattern, pattern: input.pattern,
limit: limit + 1, limit: limit + 1,
}) })
+90 -94
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.check( pattern: FileSystem.GrepInput.fields.pattern
Schema.isMinLength(1, { message: "Pattern must not be empty" }), .check(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 fs = yield* FSUtil.Service const environment = yield* Environment.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,104 +66,100 @@ export const Plugin = {
yield* ctx.tool yield* ctx.tool
.transform((draft) => .transform((draft) =>
draft.add( draft.add({
({ name,
name, options: { codemode: false },
options: { codemode: false }, description:
description: "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.",
"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.", input: Input,
input: Input, output: Output,
output: Output, execute: (input, context) =>
execute: (input, context) => Effect.gen(function* () {
Effect.gen(function* () { const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
const source = { type: "tool" as const, messageID: context.messageID, id: context.id } const target = yield* mutation.resolve({ path: input.path ?? "." })
const target = yield* mutation.resolve({ path: input.path ?? "." }) if (target.externalDirectory)
if (target.externalDirectory)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
sessionID: context.sessionID,
agent: context.agent,
source,
})
yield* permission.assert({ yield* permission.assert({
action: name, ...LocationMutation.externalDirectoryPermission(target.externalDirectory),
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,
}) })
const root = path.resolve(location.directory, input.path ?? ".") yield* permission.assert({
const info = yield* fs action: name,
.stat(root) resources: [input.pattern],
.pipe( save: ["*"],
Effect.catchReason("PlatformError", "NotFound", () => metadata: {
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })), root: ".",
), path: input.path,
) include: input.include,
const cwd = info?.type === "Directory" ? root : path.dirname(root) limit: input.limit,
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT },
const matches = yield* ripgrep sessionID: context.sessionID,
.grep({ agent: context.agent,
cwd, source,
pattern: input.pattern, })
file: info?.type === "File" ? path.basename(root) : undefined, const root = target.absolute
include: input.include, const type = yield* Environment.typeFollowing(environment.files, root).pipe(
limit: limit + 1, Effect.catchTag("Environment.NotFound", () =>
}) Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
.pipe( ),
Effect.timeoutOrElse({ )
duration: FileSystem.DEFAULT_SEARCH_TIMEOUT_MS, const cwd = type === "directory" ? root : path.dirname(root)
orElse: () => const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
Effect.fail( const matches = yield* ripgrep
new ToolFailure({ .grep({
message: `Search timed out after ${FileSystem.DEFAULT_SEARCH_TIMEOUT_MS / 1_000} seconds. Consider using a more specific path or pattern.`, cwd,
}), pattern: input.pattern,
), file: type === "file" ? path.basename(root) : undefined,
}), include: input.include,
Effect.map((result) => limit: limit + 1,
result.map((match) => })
FileSystem.Match.make({ .pipe(
...match, Effect.timeoutOrElse({
entry: FileSystem.Entry.make({ duration: FileSystem.DEFAULT_SEARCH_TIMEOUT_MS,
...match.entry, orElse: () =>
path: RelativePath.make( Effect.fail(
path.relative(location.directory, path.resolve(cwd, match.entry.path)), new ToolFailure({
), 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 }
Effect.mapError((error) => }).pipe(
error instanceof ToolFailure Effect.map((result) => ({
? error output: result.matches,
: error instanceof Ripgrep.InvalidPatternError content: toModelContent(
? new ToolFailure({ message: `Invalid regex pattern: ${error.message}` }) result.matches.map((match) => ({
: new ToolFailure({ message: `Unable to grep for ${input.pattern}`, error }), ...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 }),
), ),
}), ),
), }),
) )
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
+41 -42
View File
@@ -4,12 +4,13 @@ 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, Schema } from "effect" import { Effect, Result, 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"
@@ -44,7 +45,13 @@ export const toModelOutput = (output: Output) =>
].join("\n") ].join("\n")
type Prepared = type Prepared =
| (Extract<Patch.Hunk, { readonly type: "add" | "delete" }> & { | (Extract<Patch.Hunk, { readonly type: "add" }> & {
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
@@ -69,7 +76,8 @@ 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 fs = yield* FSUtil.Service const environment = yield* Environment.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
@@ -84,6 +92,13 @@ 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({
@@ -97,7 +112,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(Patch.parse(input.patchText)).pipe( const hunks = yield* Effect.fromResult(parsed).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) {
@@ -125,18 +140,19 @@ 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( after: Bom.split(content).text,
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* Bom.readFile(fs, target.absolute).pipe( const content = yield* FileMutation.readText(environment.files, target.absolute).pipe(
Effect.mapError( Effect.mapError(
(error) => (error) =>
new ToolFailure({ new ToolFailure({
@@ -151,20 +167,7 @@ export const Plugin = {
const original = const original =
previous ?? previous ??
(yield* Effect.gen(function* () { (yield* Effect.gen(function* () {
const stats = yield* fs.stat(target.absolute).pipe( const content = yield* FileMutation.readText(environment.files, 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({
@@ -233,13 +236,8 @@ export const Plugin = {
(change) => (change) =>
Effect.gen(function* () { Effect.gen(function* () {
if (change.type === "add") { if (change.type === "add") {
yield* fs yield* environment.files
.writeWithDirs( .write(change.target.absolute, new TextEncoder().encode(change.content))
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,
@@ -249,7 +247,7 @@ export const Plugin = {
return return
} }
if (change.type === "delete") { if (change.type === "delete") {
yield* fs yield* environment.files
.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({
@@ -261,10 +259,10 @@ export const Plugin = {
} }
if (change.moveTarget) { if (change.moveTarget) {
const moveTarget = change.moveTarget const moveTarget = change.moveTarget
yield* fs yield* environment.files
.writeWithDirs(moveTarget.absolute, change.content) .write(moveTarget.absolute, new TextEncoder().encode(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* fs yield* environment.files
.remove(change.target.absolute) .remove(change.target.absolute)
.pipe( .pipe(
Effect.mapError((error) => Effect.mapError((error) =>
@@ -278,8 +276,8 @@ export const Plugin = {
}) })
return return
} }
yield* fs yield* environment.files
.writeWithDirs(change.target.absolute, change.content) .write(change.target.absolute, new TextEncoder().encode(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,
@@ -294,13 +292,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* Bom.readFile(fs, target).pipe( const current = yield* FileMutation.readText(environment.files, 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* Bom.syncFile(fs, target, current.bom).pipe( ? yield* FileMutation.syncTextBom(environment.files, 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,
@@ -315,6 +313,7 @@ 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),
@@ -345,10 +344,10 @@ export const Plugin = {
} }
function errorMessage(error: unknown) { function errorMessage(error: unknown) {
if (error instanceof PlatformError) { if (error instanceof Environment.NotFound) return "file does not exist"
if (error.reason._tag === "NotFound") return "file does not exist" if (error instanceof Environment.WrongKind)
return error.reason.description ?? error.reason.message return error.actual === "directory" ? "path is a directory" : `path is ${error.actual}`
} 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)
} }
+8 -11
View File
@@ -11,6 +11,7 @@ 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"
@@ -72,16 +73,12 @@ export const Plugin = {
agent: context.agent, agent: context.agent,
source, source,
}) })
const type = yield* reader const content = yield* reader.read(absolute, resource, { offset: input.offset, limit: input.limit }).pipe(
.inspect(absolute) Effect.catchIf(
.pipe(Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.absolute))) (error) => error instanceof Environment.NotFound,
const content = () => missing(input.path, target.absolute),
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
@@ -95,7 +92,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: type === "directory" ? resolved : dirname(resolved), start: content.type === "list-page" ? 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(
+8 -10
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 fsUtil = yield* FSUtil.Service const environment = yield* Environment.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,14 +179,12 @@ export const Plugin = {
agent: context.agent, agent: context.agent,
source, source,
}) })
const workdir = yield* fsUtil const workdir = yield* Environment.typeFollowing(environment.files, target.absolute).pipe(
.stat(target.absolute) Effect.catchTag("Environment.NotFound", () =>
.pipe( Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)),
Effect.catchReason("PlatformError", "NotFound", () => ),
Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)), )
), if (workdir !== "directory")
)
if (workdir.type !== "Directory")
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.absolute}`)) return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.absolute}`))
}), }),
) )
+10 -8
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 { FSUtil } from "@opencode-ai/util/fs-util" import { Environment } from "../../environment"
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 files = yield* FileMutation.Service const fileMutation = 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* Bom.readFile(fs, target.absolute).pipe( const current = yield* FileMutation.readText(environment.files, target.absolute).pipe(
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)), Effect.catchTag("Environment.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,9 +91,11 @@ export const Plugin = {
agent: context.agent, agent: context.agent,
source, source,
}) })
const result = yield* files.writeTextPreservingBom({ target, content: input.content }) const result = yield* fileMutation.writeTextPreservingBom({ target, content: input.content })
const bom = (yield* Bom.readFile(fs, target.absolute)).bom const bom = (yield* FileMutation.readText(environment.files, target.absolute)).bom
if (yield* formatter.file(target.absolute)) yield* Bom.syncFile(fs, target.absolute, bom) if (yield* formatter.file(target.absolute)) {
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) })),
+152 -191
View File
@@ -2,17 +2,22 @@ export * as ReadToolFileSystem from "./read-filesystem"
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { FileSystem } from "../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
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 { Mime } from "../mime"
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,
@@ -52,8 +57,13 @@ export class PathKindError extends Schema.TaggedErrorClass<PathKindError>()("Rea
} }
} }
export type InspectError = FSUtil.Error | PathKindError export type ReadError =
export type ReadError = FSUtil.Error | BinaryFileError | MediaIngestLimitError | OffsetOutOfRangeError | PathKindError | Environment.NotFound
| Environment.Failed
| BinaryFileError
| MediaIngestLimitError
| OffsetOutOfRangeError
| PathKindError
export const PageInput = Schema.Struct({ export const PageInput = Schema.Struct({
offset: Schema.optionalKey(NonNegativeInt), offset: Schema.optionalKey(NonNegativeInt),
@@ -90,202 +100,113 @@ 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, ReadError> ) => Effect.Effect<FileContent | TextPage | ListPage, 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 startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value) const mimeType = (value: string) => lookup(value) || "application/octet-stream"
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* (
fs: FSUtil.Interface, files: Files,
input: string, input: AbsolutePath,
resource: string, resource: string,
page: PageInput = {}, page: PageInput = {},
) { ) {
const real = yield* fs.realPath(input) const first = yield* files.read(input, { offset: 0, length: FIRST_CHUNK }).pipe(
return yield* Effect.scoped( Effect.catchTag("Environment.WrongKind", (error) => {
Effect.gen(function* () { if (error.actual !== "directory")
const file = yield* fs.open(real, { flag: "r" }) return Effect.fail(new PathKindError({ resource, expected: "a file or directory" }))
const info = yield* file.stat return files.list(input).pipe(
if (info.type !== "File") return yield* Effect.fail(new PathKindError({ resource, expected: "a file" })) Effect.map((entries) => list(entries, page)),
const first = Option.getOrElse( Effect.catchTag("Environment.WrongKind", () =>
yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || 4 * 1024)), Effect.fail(new PathKindError({ resource, expected: "a file or directory" })),
() => 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)
}
}) })
export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface, input: string, page: PageInput = {}) { const readFile = (
const real = yield* fs.realPath(input) files: Files,
const items = yield* fs.readDirectoryEntries(real) input: AbsolutePath,
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
@@ -316,18 +237,58 @@ export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface,
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 fs = yield* FSUtil.Service const environment = yield* Environment.Service
return Service.of({ return Service.of({ read: (path, resource, page) => read(environment.files, path, resource, page) })
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: [FSUtil.node] }) export const node = makeLocationNode({ service: Service, layer, deps: [Environment.node] })
@@ -50,6 +50,33 @@ 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
+50 -1
View File
@@ -1,11 +1,39 @@
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 { execDefaults, Failed, makeFiles, makeMemoryDriver } from "../src/environment/index" import {
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(() => {
@@ -18,6 +46,27 @@ 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",
() => () =>
+63 -12
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 { FSUtil } from "@opencode-ai/util/fs-util" import { Environment } from "@opencode-ai/core/environment"
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, filesystemLayer = LayerNode.compile(FSUtil.node)) { function provide(directory: string, environmentLayer = LayerNode.compile(Environment.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, filesystemLayer = LayerNode.compile(FSUtil.n
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],
[FSUtil.node, filesystemLayer], [Environment.node, environmentLayer],
]), ]),
) )
} }
@@ -152,6 +152,57 @@ 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* () {
@@ -191,16 +242,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(
FSUtil.Service, Environment.Service,
Effect.gen(function* () { Effect.gen(function* () {
const filesystem = yield* FSUtil.Service const environment = yield* Environment.Service
return FSUtil.Service.of({ return Environment.Service.of({
...filesystem, ...environment,
writeWithDirs: (target, content, mode) => run(filesystem.writeWithDirs(target, content, mode), target), files: {
writeFile: (target, content, options) => run(filesystem.writeFile(target, content, options), target), ...environment.files,
writeFileString: (target, content, options) => write: (target, content) => run(environment.files.write(target, content), target),
run(filesystem.writeFileString(target, content, options), target), },
}) })
}), }),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) ).pipe(Layer.provide(LayerNode.compile(Environment.node)))
} }
+93 -126
View File
@@ -17,9 +17,8 @@ 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])))
@@ -75,10 +74,9 @@ 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.subscribe({ path: "/pending", type: "directory" }).pipe( const consumer = yield* watcher
Effect.flatMap(Stream.runDrain), .subscribe({ path: "/pending", type: "directory" })
Effect.forkScoped({ startImmediately: true }), .pipe(Effect.flatMap(Stream.runDrain), 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)
@@ -99,10 +97,9 @@ 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.subscribe({ path: "/shared", type: "directory" }).pipe( watcher
Effect.flatMap(Stream.runDrain), .subscribe({ path: "/shared", type: "directory" })
Effect.forkScoped({ startImmediately: true }), .pipe(Effect.flatMap(Stream.runDrain), 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
@@ -138,22 +135,26 @@ describe("Watcher lifecycle", () => {
}) })
}) })
function provide(directory: string, vcs?: Location.Interface["vcs"]) { function provide(directory: string, vcs?: Location.Interface["vcs"], watcher?: Layer.Layer<Watcher.Service>) {
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 })),
) )
return Effect.provide( const built = AppNodeBuilder.build(LocationWatcher.node, [
AppNodeBuilder.build(LocationWatcher.node, [ [Config.node, configLayer],
[Config.node, configLayer], [Location.node, locationLayer],
[Location.node, locationLayer], ...(watcher ? ([[Watcher.node, watcher]] as const) : []),
]), ])
) 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?: { vcs?: "git" | "hg"; init?: (directory: string) => Promise<void> }, options?: {
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 () => {
@@ -173,9 +174,57 @@ 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)))) ).pipe(Effect.flatMap(({ tmp, vcs }) => f(tmp.path, vcs).pipe(provide(tmp.path, vcs, options?.watcher))))
} }
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
@@ -226,31 +275,18 @@ function eventuallyUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: (
) )
} }
function noUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>, timeout = 500) { function ready(file: string, eventFile = file) {
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 === file, (event) => event.file === eventFile,
() => fs.writeFileString(file, `ready-${Math.random()}`), () => fs.writeFileString(file, content),
).pipe(Effect.ensuring(fs.remove(file, { force: true }).pipe(Effect.ignore)), Effect.asVoid) ).pipe(Effect.asVoid)
}) })
} }
describeWatcher("LocationWatcher", () => { describeNative("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* () {
@@ -276,94 +312,25 @@ describeWatcher("LocationWatcher", () => {
), ),
) )
it.live("publishes root create, update, and delete events", () => it.live("detects creation of a missing directory target", () =>
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 file = path.join(directory, "plain.txt") const watcher = yield* Watcher.Service
yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "plain")) const target = path.join(directory, "generated")
}), 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)))
it.live("ignores dependency, VCS, and build directories at any depth", () => expect(event.valueOrUndefined?.path).toBe(target)
withTmp( }).pipe(Effect.provide(AppNodeBuilder.build(Watcher.node))),
(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" },
), ),
) )
@@ -374,11 +341,11 @@ describeWatcher("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(directory) yield* ready(head)
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`)),
).toMatchObject({ file: head }) ).toEqual({ file: head, event: "change" })
}), }),
{ vcs: "git" }, { vcs: "git" },
), ),
@@ -393,8 +360,8 @@ describeWatcher("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(
@@ -422,7 +389,7 @@ describeWatcher("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(directory) yield* ready(branch)
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,19 +105,29 @@ export const environmentConformance = <E>(
}), }),
) )
check("reports symlinks without resolving them", (harness) => check("preserves symlink metadata while following symlinks for content", (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")
const listError = yield* Effect.flip(harness.files.list(`${harness.root}/link-dir`)) expect(
expect(listError).toBeInstanceOf(WrongKind) (yield* harness.files.list(`${harness.root}/link-dir`)).toSorted((a, b) => a.name.localeCompare(b.name)),
expect((listError as WrongKind).actual).toBe("symlink") ).toEqual([
{ 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,6 +13,7 @@ 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"
@@ -127,23 +128,34 @@ 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,10 +3,12 @@ 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"
@@ -26,10 +28,14 @@ const imageStore = Layer.mock(Image.Service, {
maxBytes: 5, maxBytes: 5,
}), }),
) )
return Effect.succeed({ ...content, content: "bm9ybWFsaXplZA==", mime: "image/jpeg" }) return Effect.succeed({
...content,
content: Buffer.from(`${Buffer.from(content.content, "base64").toString()} normalized`).toString("base64"),
mime: "image/jpeg",
})
}, },
}) })
const registryLayer = AppNodeBuilder.build(Tool.node, [[Image.node, imageStore]]) const registryLayer = AppNodeBuilder.build(LayerNode.group([Tool.node, PluginHooks.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"),
@@ -344,7 +350,7 @@ describe("Tool", () => {
}), }),
) )
it.effect("normalizes image tool output at execution and drops unresizable images", () => it.effect("normalizes image tool output once 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,
@@ -376,7 +382,12 @@ 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.]" },
@@ -384,6 +395,34 @@ 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
+218 -17
View File
@@ -6,11 +6,10 @@ 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 { FileSystem } from "@opencode-ai/schema/filesystem" import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
@@ -25,8 +24,15 @@ const discovery = Layer.succeed(
}, },
}), }),
) )
const watcherLayer = Watcher.testLayer
const it = testEffect( const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Skill.node, Agent.node, Bus.node]), [[SkillDiscovery.node, discovery]]), Layer.mergeAll(
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) {
@@ -53,6 +59,24 @@ 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* () {
@@ -89,6 +113,7 @@ 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) })
@@ -119,6 +144,21 @@ 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" },
])
}), }),
), ),
), ),
@@ -198,7 +238,7 @@ metadata:
), ),
) )
it.live("invalidates cached skills and publishes updates for watcher changes", () => it.live("clears cached skills when sources reload", () =>
Effect.acquireRelease( Effect.acquireRelease(
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
@@ -210,26 +250,187 @@ 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)
expect((yield* skill.list()).find((item) => item.name === "deploy")?.description).toBe("Initial deploy") const deploy = path.join(tmp.path, "deploy", "SKILL.md")
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"))
expect((yield* skill.list()).find((item) => item.name === "deploy")?.description).toBe("Initial deploy") yield* emitAndWait({ type: "update", path: deploy })
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Updated deploy")
yield* Effect.acquireUseRelease( yield* Effect.promise(async () => {
waitForSkillUpdate(), await fs.mkdir(path.join(tmp.path, "review"), { recursive: true })
({ deferred }) => await write(tmp.path, "review", "Review changes")
bus })
.publish(FileSystem.Event.Changed, { file, event: "change" }) const review = path.join(tmp.path, "review", "SKILL.md")
.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")), yield* emitAndWait({ type: "create", path: review })
({ fiber }) => Fiber.interrupt(fiber), expect((yield* skill.list()).map((item) => item.id)).toEqual([
) Skill.ID.make("deploy"),
Skill.ID.make("review"),
])
expect((yield* skill.list()).find((item) => item.name === "deploy")?.description).toBe("Updated deploy") yield* Effect.promise(() => fs.rm(path.join(tmp.path, "review"), { recursive: true }))
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" },
])
}), }),
), ),
), ),
+68 -33
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,7 +23,15 @@ 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: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node], deps: [
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")
@@ -72,29 +80,28 @@ const reset = () => {
formatFile = () => Effect.succeed(false) formatFile = () => Effect.succeed(false)
} }
const filesystem = Layer.effect( const environment = Layer.effect(
FSUtil.Service, Environment.Service,
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* FSUtil.Service const current = yield* Environment.Service
return FSUtil.Service.of({ return Environment.Service.of({
...fs, ...current,
readFile: (target) => files: {
fs ...current.files,
.readFile(target) read: (target, range) =>
.pipe( current.files
Effect.tap((content) => .read(target, range)
Effect.sync(() => reads++).pipe(Effect.andThen(Effect.suspend(() => afterRead(target, content)))), .pipe(
Effect.tap((result) =>
Effect.sync(() => reads++).pipe(Effect.andThen(Effect.suspend(() => afterRead(target, result.bytes)))),
),
), ),
), write: (target, content) =>
writeWithDirs: (target, content, mode) => Effect.sync(() => writes.push(target)).pipe(Effect.andThen(current.files.write(target, content))),
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(FSUtil.node))) ).pipe(Layer.provide(LayerNode.compile(Environment.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(
@@ -106,15 +113,9 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
}).pipe( }).pipe(
Effect.provide( Effect.provide(
AppNodeBuilder.build( AppNodeBuilder.build(
LayerNode.group([ LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, editToolNode]),
Tool.node,
Tool.node,
LocationMutation.node,
FileMutation.node,
editToolNode,
]),
[ [
[FSUtil.node, filesystem], [Environment.node, environment],
[Location.node, activeLocation], [Location.node, activeLocation],
[Formatter.node, formatter], [Formatter.node, formatter],
[Permission.node, permission], [Permission.node, permission],
@@ -471,10 +472,7 @@ describe("EditTool", () => {
withTool(tmp.path, (registry) => withTool(tmp.path, (registry) =>
Effect.gen(function* () { Effect.gen(function* () {
expect( expect(
yield* executeTool( yield* executeTool(registry, call({ path: "missing.ts", oldString: "before", newString: "after" })),
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" },
@@ -645,6 +643,43 @@ 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()),
+65 -42
View File
@@ -2,11 +2,12 @@ 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"
@@ -22,7 +23,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, Formatter.node, FSUtil.node, Location.node, Permission.node], deps: [Tool.node, FileMutation.node, Environment.node, Formatter.node, Location.node, Permission.node],
}) })
const sessionID = Session.ID.make("ses_patch_tool_test") const sessionID = Session.ID.make("ses_patch_tool_test")
@@ -81,48 +82,33 @@ const reset = () => {
formatFile = () => Effect.succeed(false) formatFile = () => Effect.succeed(false)
} }
const filesystem = Layer.effect( const environment = Layer.effect(
FSUtil.Service, Environment.Service,
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* FSUtil.Service const current = yield* Environment.Service
return FSUtil.Service.of({ return Environment.Service.of({
...fs, ...current,
readFile: (target) => files: {
Effect.sync(() => { ...current.files,
if (!editApproved) readsBeforeEditApproval++ read: (target, range) =>
}).pipe(Effect.andThen(fs.readFile(target))), Effect.sync(() => {
remove: (target, options) => { if (!editApproved) readsBeforeEditApproval++
if (failRemoveTarget && path.basename(target) === failRemoveTarget) return Effect.die("forced remove failure") }).pipe(Effect.andThen(current.files.read(target, range))),
if (failRemoveErrorTarget && path.basename(target) === failRemoveErrorTarget) { remove: (target) => {
return Effect.fail( if (failRemoveTarget && path.basename(target) === failRemoveTarget) return Effect.die("forced remove failure")
systemError({ if (failRemoveErrorTarget && path.basename(target) === failRemoveErrorTarget)
_tag: "Unknown", return Effect.fail(new Environment.Failed({ path: target, cause: new Error("forced remove failure") }))
module: "FileSystem", return current.files.remove(target)
method: "remove", },
description: "forced remove failure", write: (target, content) => {
pathOrDescriptor: target, if (failWriteTarget && path.basename(target) === failWriteTarget)
}), 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(FSUtil.node))) ).pipe(Layer.provide(LayerNode.compile(Environment.node)))
const withTool = <A, E, R>( const withTool = <A, E, R>(
directory: string, directory: string,
@@ -139,8 +125,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, patchToolNode]), [ AppNodeBuilder.build(LayerNode.group([Tool.node, FileMutation.node, patchToolNode]), [
[FSUtil.node, filesystem], [Environment.node, environment],
[Location.node, activeLocation], [Location.node, activeLocation],
[Formatter.node, formatter], [Formatter.node, formatter],
[Permission.node, permission], [Permission.node, permission],
@@ -262,6 +248,43 @@ 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")
+161 -51
View File
@@ -1,66 +1,65 @@
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 { Effect, FileSystem } from "effect" import { Environment } from "@opencode-ai/core/environment"
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 { FSUtil } from "@opencode-ai/util/fs-util" import { Effect, FileSystem } from "effect"
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem" import { ChildProcessSpawner } from "effect/unstable/process"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
const it = testEffect(LayerNode.compile(LayerNode.group([FSUtil.node, LayerNodePlatform.filesystem]))) const it = testEffect(LayerNode.compile(LayerNode.group([CrossSpawnSpawner.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 { fs, files, directory } return { environment: Environment.makeFiles(Environment.makeLocalDriver(spawner)), files, directory }
}) })
const absolute = (value: string) => AbsolutePath.make(value)
describe("ReadToolFileSystem", () => { describe("ReadToolFileSystem", () => {
it.effect("fails with a typed filesystem error when a resolved file disappears", () => it.effect("preserves the environment not-found error", () =>
Effect.gen(function* () { Effect.gen(function* () {
const { fs, directory } = yield* fixture const { environment, directory } = yield* fixture
const file = path.join(directory, "missing.txt") const file = path.join(directory, "missing.txt")
const error = yield* ReadToolFileSystem.read(fs, file, "missing.txt").pipe(Effect.flip) const error = yield* ReadToolFileSystem.read(environment, absolute(file), "missing.txt").pipe(Effect.flip)
expect(error).toMatchObject({ _tag: "PlatformError" }) expect(error).toBeInstanceOf(Environment.NotFound)
}), }),
) )
it.effect("fails when a file becomes the wrong path kind", () => it.effect("returns a listing when read reports a directory", () =>
Effect.gen(function* () { Effect.gen(function* () {
const { fs, directory } = yield* fixture const { environment, files, directory } = yield* fixture
yield* files.makeDirectory(path.join(directory, "folder"))
yield* files.writeFileString(path.join(directory, "file.txt"), "hello")
const error = yield* ReadToolFileSystem.read(fs, directory, "folder").pipe(Effect.flip) const result = yield* ReadToolFileSystem.read(environment, absolute(directory), "folder")
expect(error).toBeInstanceOf(ReadToolFileSystem.PathKindError) expect(result).toMatchObject({
}), type: "list-page",
) entries: [
{ path: `folder${path.sep}`, type: "directory" },
it.effect("fails with a typed filesystem error when directory listing fails", () => { path: "file.txt", type: "file" },
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 { fs, files, directory } = yield* fixture const { environment, 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(fs, binary, "archive.dat").pipe(Effect.flip) const binaryError = yield* ReadToolFileSystem.read(environment, absolute(binary), "archive.dat").pipe(Effect.flip)
const malformedResult = yield* ReadToolFileSystem.read(fs, malformed, "malformed.txt") const malformedResult = yield* ReadToolFileSystem.read(environment, absolute(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")
@@ -70,11 +69,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 { fs, files, directory } = yield* fixture const { environment, 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(fs, file, "notes.docx") const result = yield* ReadToolFileSystem.read(environment, absolute(file), "notes.docx")
expect(result).toMatchObject({ type: "file", content: "plain text", encoding: "utf8" }) expect(result).toMatchObject({ type: "file", content: "plain text", encoding: "utf8" })
}), }),
@@ -83,15 +82,17 @@ 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 { fs: service, files, directory } = yield* fixture const { environment, 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.list(service, directory) const result = yield* ReadToolFileSystem.read(environment, absolute(directory), "folder")
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" },
@@ -101,45 +102,154 @@ 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 { fs, files, directory } = yield* fixture const { environment, 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(fs, file, "short.txt", { offset: 2 }).pipe(Effect.flip) const error = yield* ReadToolFileSystem.read(environment, absolute(file), "short.txt", { offset: 2 }).pipe(
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("stops reading after the requested page is complete", () => it.effect("pages text with one-based offsets", () =>
Effect.gen(function* () { Effect.gen(function* () {
const { fs, files, directory } = yield* fixture const { environment, files, directory } = yield* fixture
const prefix = new TextEncoder().encode("one\n") const file = path.join(directory, "lines.txt")
for (const [name, trailing] of [ yield* files.writeFileString(file, "one\r\ntwo\nthree")
["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(fs, file, name, { limit: 1 }) const result = yield* ReadToolFileSystem.read(environment, absolute(file), "lines.txt", {
offset: 2,
limit: 1,
})
expect(result).toMatchObject({ type: "text-page", content: "one", truncated: true, next: 2 }) expect(result).toMatchObject({ type: "text-page", content: "two", offset: 2, truncated: true, next: 3 })
}),
)
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 { fs, files, directory } = yield* fixture const { environment, 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(fs, file, "oversized.png").pipe(Effect.flip) const error = yield* ReadToolFileSystem.read(environment, absolute(file), "oversized.png").pipe(Effect.flip)
expect(error).toBeInstanceOf(ReadToolFileSystem.MediaIngestLimitError) expect(error).toBeInstanceOf(ReadToolFileSystem.MediaIngestLimitError)
expect(error.message).toBe( expect(error.message).toBe(
@@ -150,11 +260,11 @@ describe("ReadToolFileSystem", () => {
it.effect("reads PDFs as bounded media", () => it.effect("reads PDFs as bounded media", () =>
Effect.gen(function* () { Effect.gen(function* () {
const { fs, files, directory } = yield* fixture const { environment, 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(fs, file, "document.pdf") const result = yield* ReadToolFileSystem.read(environment, absolute(file), "document.pdf")
expect(result).toMatchObject({ expect(result).toMatchObject({
type: "file", type: "file",
+23 -48
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, PlatformError, Stream } from "effect" import { Effect, Exit, Layer, 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,6 +21,7 @@ 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"
@@ -42,24 +43,13 @@ 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 = { let readResult: ReadToolFileSystem.FileContent | ReadToolFileSystem.TextPage | ReadToolFileSystem.ListPage = {
type: "file", type: "file",
uri: "file:///README.md", uri: "file:///README.md",
name: "README.md", name: "README.md",
@@ -71,22 +61,12 @@ 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
@@ -125,17 +105,6 @@ 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),
}), }),
), ),
), ),
@@ -195,11 +164,8 @@ 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",
@@ -210,7 +176,6 @@ 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", () =>
@@ -672,7 +637,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* () {
inspectFailure = notFound(missingAbsolutePath) readFailure = new Environment.NotFound({ path: missingAbsolutePath })
directoryEntries = [ directoryEntries = [
"__missing_read_target__.txt.bak", "__missing_read_target__.txt.bak",
"copy___missing_read_target__.txt", "copy___missing_read_target__.txt",
@@ -696,14 +661,18 @@ 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* () {
resolvedType = "directory" readResult = new ReadToolFileSystem.ListPage({
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" }),
@@ -726,7 +695,7 @@ describe("ReadTool", () => {
}) })
expect(result).toMatchObject({ expect(result).toMatchObject({
status: "completed", status: "completed",
output: { entries: listResult.entries, truncated: true, next: 4 }, output: { entries: readResult.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 })
@@ -737,14 +706,15 @@ describe("ReadTool", () => {
}, },
]) ])
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }]) expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }])
expect(listCalls).toEqual([{ offset: 2, limit: 10 }]) expect(readCalls).toEqual([
{ 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(
@@ -754,7 +724,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(listCalls).toEqual([]) expect(readCalls).toEqual([])
}), }),
) )
@@ -773,7 +743,12 @@ 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 },
},
])
}), }),
) )
+6 -19
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,19 +24,12 @@ 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: [ deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
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, FSUtil.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node], deps: [Tool.node, Environment.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")
@@ -186,9 +179,7 @@ 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( Effect.andThen(withTools(tmp.path, (registry) => executeTool(registry, call("grep", { pattern: "needle" })))),
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({
@@ -297,9 +288,7 @@ 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) => withTools(tmp.path, (registry) => executeTool(registry, call("glob", { path: "file.txt", pattern: "*" }))),
executeTool(registry, call("glob", { path: "file.txt", pattern: "*" })),
),
), ),
Effect.tap((result) => Effect.tap((result) =>
Effect.sync(() => { Effect.sync(() => {
@@ -331,9 +320,7 @@ 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([ expect(assertions[0]?.resources).toEqual([path.join(outside.path, "*").replaceAll("\\", "/")])
path.join(outside.path, "*").replaceAll("\\", "/"),
])
}), }),
), ),
) )
+2 -1
View File
@@ -524,7 +524,8 @@ 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")
expect(content.text).toStartWith("two\nthree") // Windows shells emit CRLF; the assertion targets line limits, not line endings.
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:")
}) })
}, },
+14 -11
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 { FSUtil } from "@opencode-ai/util/fs-util" import { Environment } from "@opencode-ai/core/environment"
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, Formatter.node, FSUtil.node, Permission.node], deps: [Tool.node, LocationMutation.node, FileMutation.node, Environment.node, Formatter.node, Permission.node],
}) })
const sessionID = Session.ID.make("ses_write_tool_test") const sessionID = Session.ID.make("ses_write_tool_test")
@@ -68,17 +68,20 @@ const reset = () => {
denyAction = undefined denyAction = undefined
} }
const filesystem = Layer.effect( const environment = Layer.effect(
FSUtil.Service, Environment.Service,
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* FSUtil.Service const current = yield* Environment.Service
return FSUtil.Service.of({ return Environment.Service.of({
...fs, ...current,
writeWithDirs: (target, content, mode) => files: {
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeWithDirs(target, content, mode))), ...current.files,
write: (target, content) =>
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(current.files.write(target, content))),
},
}) })
}), }),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) ).pipe(Layer.provide(LayerNode.compile(Environment.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(
@@ -92,7 +95,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]),
[ [
[FSUtil.node, filesystem], [Environment.node, environment],
[Location.node, activeLocation], [Location.node, activeLocation],
[Formatter.node, formatter], [Formatter.node, formatter],
[Permission.node, permission], [Permission.node, permission],
+20 -2
View File
@@ -27,6 +27,7 @@ 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) {
@@ -46,6 +47,7 @@ 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 })
@@ -66,8 +68,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) => {
Flock.withLock( const operation = Flock.withLock(
file, file,
async () => { async () => {
const draft = load() const draft = load()
@@ -78,6 +80,13 @@ 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
@@ -90,6 +99,15 @@ 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()))
+2 -10
View File
@@ -1,6 +1,6 @@
/** @jsxImportSource @opentui/solid */ /** @jsxImportSource @opentui/solid */
import { decodePasteBytes, stripAnsiSequences, type TextareaRenderable } from "@opentui/core" import type { TextareaRenderable } from "@opentui/core"
import { useKeyboard, usePaste } from "@opentui/solid" import { useKeyboard } 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,14 +149,6 @@ 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()) {
+2 -15
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 { usePaste, useRenderer, useTerminalDimensions } from "@opentui/solid" import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { decodePasteBytes, stripAnsiSequences, type ScrollBoxRenderable, type TextareaRenderable } from "@opentui/core" import type { ScrollBoxRenderable, 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,19 +265,6 @@ 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
+6 -3
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 { ThemeContextProvider, useTheme, useThemes } from "../../context/theme" import { createSyntaxStyleMemo, 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,6 +100,7 @@ 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)
@@ -1432,6 +1433,7 @@ 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)
@@ -1527,7 +1529,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={syntax()} syntaxStyle={thinkingSyntax()}
content={content()} content={content()}
conceal={ctx.markdownMode() === "rendered"} conceal={ctx.markdownMode() === "rendered"}
fg={theme.text.subdued} fg={theme.text.subdued}
@@ -2060,6 +2062,7 @@ 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.
@@ -2112,7 +2115,7 @@ function ReasoningPart(props: {
filetype="markdown" filetype="markdown"
drawUnstyledText={false} drawUnstyledText={false}
streaming={true} streaming={true}
syntaxStyle={syntax()} syntaxStyle={thinkingSyntax()}
content={content()} content={content()}
conceal={ctx.markdownMode() === "rendered"} conceal={ctx.markdownMode() === "rendered"}
fg={theme.text.subdued} fg={theme.text.subdued}
@@ -0,0 +1,9 @@
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 }]),
),
)
}
+12 -10
View File
@@ -1,9 +1,6 @@
/** @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"
@@ -14,12 +11,13 @@ 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 } from "../../../src/context/storage" import { StorageProvider, useStorage } 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 () => {
@@ -52,7 +50,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 {
fixture.dispose() await fixture.dispose()
} }
}) })
@@ -94,7 +92,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 {
fixture.dispose() await fixture.dispose()
} }
}) })
@@ -149,7 +147,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 {
fixture.dispose() await fixture.dispose()
} }
}) })
@@ -160,18 +158,21 @@ async function renderOpen(
location: ReturnType<typeof useLocation> location: ReturnType<typeof useLocation>
}) => void | Promise<void>, }) => void | Promise<void>,
) { ) {
const state = mkdtempSync(path.join(tmpdir(), "opencode-dialog-open-")) const temporary = await tmpdir()
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 />)),
) )
@@ -223,9 +224,10 @@ async function renderOpen(
get data() { get data() {
return data return data
}, },
dispose() { async dispose() {
app.renderer.destroy() app.renderer.destroy()
rmSync(state, { recursive: true, force: true }) await storage.flush()
await temporary[Symbol.asyncDispose]()
}, },
} }
} }
+2 -56
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, fields?: FormWithLocation["fields"]) { async function mountForm(root: string, width = 80) {
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, fields?: FormWithLocation["fi
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,57 +126,3 @@ 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()
}
})
+24 -41
View File
@@ -1,9 +1,8 @@
/** @jsxImportSource @opentui/solid */ /** @jsxImportSource @opentui/solid */
import { afterAll, expect, test } from "bun:test" import { 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, mkdtempSync, readdirSync, rmSync, watch } from "fs" import { mkdirSync, 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"
@@ -12,9 +11,10 @@ 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 } from "../../src/context/storage" import { StorageProvider, useStorage } 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,35 +25,12 @@ 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 state = options?.state ?? stateDir("opencode-session-tabs-") const temporary = options?.state ? undefined : await tmpdir()
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 })
@@ -88,12 +65,14 @@ 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 />
} }
@@ -127,8 +106,10 @@ async function renderSessionTabs(
sessions, sessions,
state, state,
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }), emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
destroy() { async destroy() {
app.renderer.destroy() app.renderer.destroy()
await storage.flush()
await temporary?.[Symbol.asyncDispose]()
}, },
} }
} }
@@ -149,7 +130,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()
setup.destroy() await setup.destroy()
} }
}) })
@@ -159,17 +140,19 @@ 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)
expect(await Bun.file(file).json()).toEqual({ const stored = await Bun.file(file).json()
global: { tabs: [], unread: {} }, expect(stored.global).toEqual({ tabs: [], unread: {} })
cwd: { [directory]: { tabs: [{ sessionID: "first" }], unread: {} } }, expect(Object.keys(stored.cwd)).toEqual([directory])
}) expect(stored.cwd[directory].tabs.map((tab: { sessionID: string }) => tab.sessionID)).toEqual(["first"])
expect(stored.cwd[directory].unread).toEqual({})
} finally { } finally {
setup.destroy() await 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 () => {
const state = stateDir("opencode-session-tabs-shared-") await using temporary = await tmpdir()
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
@@ -206,8 +189,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 {
titled?.destroy() if (titled) await titled.destroy()
untitled?.destroy() if (untitled) await untitled.destroy()
} }
}) })
@@ -255,7 +238,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 {
setup.destroy() await setup.destroy()
} }
}) })
@@ -286,6 +269,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 {
setup.destroy() await setup.destroy()
} }
}) })
@@ -277,41 +277,6 @@ 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(
+15 -7
View File
@@ -20,17 +20,25 @@ 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 split(decode(yield* fs.readFile(filepath))) return decodeBytes(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 decoded = decode(yield* fs.readFile(filepath)) const synced = syncBytes(yield* fs.readFile(filepath), bom)
const current = split(decoded) if (synced.bytes) yield* fs.writeWithDirs(filepath, synced.bytes)
const canonical = join(current.text, bom) return synced.text
if (decoded === canonical) return current.text
yield* fs.writeWithDirs(filepath, canonical)
return current.text
}) })
function decode(content: Uint8Array) { function decode(content: Uint8Array) {